Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Ingress api networking.k8s.io/v1 #233

Merged
merged 5 commits into from
Sep 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions client/src/main/scala/skuber/networking/v1/Ingress.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package skuber.networking.v1

import play.api.libs.json.{Format, JsPath, Json}
import skuber.ResourceSpecification.{Names, Scope}
import skuber.networking.v1.Ingress.PathType.{ImplementationSpecific, PathType}
import skuber.{NameablePort, NonCoreResourceSpecification, ObjectMeta, ObjectResource, ResourceDefinition}

import scala.util.Try

case class Ingress(
kind: String = "Ingress",
override val apiVersion: String = ingressAPIVersion,
metadata: ObjectMeta = ObjectMeta(),
spec: Option[Ingress.Spec] = None,
status: Option[Ingress.Status] = None)
extends ObjectResource {

import Ingress.Backend

lazy val copySpec: Ingress.Spec = this.spec.getOrElse(new Ingress.Spec)

/**
* Fluent API method for building out ingress rules e.g.
* {{{
* val ingress = Ingress("microservices").
* addHttpRule("foo.bar.com",
* ImplementationSpecific,
* Map("/order" -> "orderService:80",
* "inventory" -> "inventoryService:80")).
* addHttpRule("foo1.bar.com",
* Map("/ship" -> "orderService:80",
* "inventory" -> "inventoryService:80")).
* }}}
*/
def addHttpRule(host: String, pathType: PathType, pathsMap: Map[String, String]): Ingress =
addHttpRule(Some(host), pathType: PathType, pathsMap)

/**
* Fluent API method for building out ingress rules without host e.g.
* {{{
* val ingress = Ingress("microservices").
* addHttpRule(ImplementationSpecific,
* Map("/order" -> "orderService:80",
* "inventory" -> "inventoryService:80")).
* addHttpRule(ImplementationSpecific,
* Map("/ship" -> "orderService:80",
* "inventory" -> "inventoryService:80")).
* }}}
*/
def addHttpRule(pathType: PathType, pathsMap: Map[String, String]): Ingress =
addHttpRule(Option.empty, pathType: PathType, pathsMap)

/**
* Fluent API method for building out ingress rules e.g.
* {{{
* val ingress =
* Ingress("microservices")
* .addHttpRule("foo.bar.com",
* ImplementationSpecific,
* "/order" -> "orderService:80",
* "inventory" -> "inventoryService:80")
* .addHttpRule("foo1.bar.com",
* ImplementationSpecific,
* "/ship" -> "orderService:80",
* "inventory" -> "inventoryService:80").
* }}}
*/
def addHttpRule(host: String, pathType: PathType, pathsMap: (String, String)*): Ingress =
addHttpRule(Some(host), pathType: PathType, pathsMap.toMap)

/**
* Fluent API method for building out ingress rules without host e.g.
* {{{
* val ingress =
* Ingress("microservices")
* .addHttpRule(ImplementationSpecific,
* "/order" -> "orderService:80",
* "inventory" -> "inventoryService:80")
* .addHttpRule(ImplementationSpecific,
* "/ship" -> "orderService:80",
* "inventory" -> "inventoryService:80").
* }}}
*/
def addHttpRule(pathType: PathType, pathsMap: (String, String)*): Ingress =
addHttpRule(Option.empty, pathType, pathsMap.toMap)

private val backendSpec = "(\\S+):(\\S+)".r

/**
* Fluent API method for building out ingress rules e.g.
* {{{
* val ingress =
* Ingress("microservices")
* .addHttpRule(Some("foo.bar.com"),
* Exact,
* Map("/order" -> "orderService:80",
* "inventory" -> "inventoryService:80"))
* .addHttpRule(None,
* ImplementationSpecific,
* Map("/ship" -> "orderService:80",
* "inventory" -> "inventoryService:80")).
* }}}
*/
def addHttpRule(host: Option[String], pathType: PathType, pathsMap: Map[String, String]): Ingress = {
val paths: List[Ingress.Path] = pathsMap.map {
case (path: String, backendService: String) =>
backendService match {
case backendSpec(serviceName, servicePort) =>
Ingress.Path(
path,
Ingress.Backend(
Option(Ingress.ServiceType(serviceName, Ingress.Port(number = toNameablePort(servicePort))))
),
pathType
)
case _ =>
throw new Exception(
s"invalid backend format: expected 'serviceName:servicePort' (got '$backendService', for host: $host)"
)
}

}.toList
val httpRule = Ingress.HttpRule(paths)
val rule = Ingress.Rule(host, httpRule)
val withRuleSpec = copySpec.copy(rules = copySpec.rules :+ rule)

this.copy(spec = Some(withRuleSpec))
}

/**
* set the default backend i.e. if no ingress rule matches the incoming traffic then it gets routed to the specified service
*
* @param serviceNameAndPort - service name and port as 'serviceName:servicePort'
* @return copy of this Ingress with default backend set
*/
def withDefaultBackendService(serviceNameAndPort: String): Ingress = {
serviceNameAndPort match {
case backendSpec(serviceName, servicePort) =>
withDefaultBackendService(serviceName, toNameablePort(servicePort))
case _ =>
throw new Exception(s"invalid default backend format: expected 'serviceName:servicePort' (got '$serviceNameAndPort')")
}
}

/**
* set the default backend i.e. if no ingress rule matches the incoming traffic then it gets routed to the specified service
*
* @param serviceName - service name
* @param servicePort - service port
* @return copy of this Ingress with default backend set
*/
def withDefaultBackendService(serviceName: String, servicePort: NameablePort): Ingress = {
val be = Backend(Option(Ingress.ServiceType(serviceName, Ingress.Port(number = servicePort))))
this.copy(spec = Some(copySpec.copy(backend = Some(be))))
}

def addAnnotations(newAnnos: Map[String, String]): Ingress =
this.copy(metadata = this.metadata.copy(annotations = this.metadata.annotations ++ newAnnos))

private def toNameablePort(port: String): NameablePort =
Try(port.toInt).toEither.left.map(_ => port).swap
}

object Ingress {
val specification: NonCoreResourceSpecification = NonCoreResourceSpecification(
apiGroup = "networking.k8s.io",
version = "v1",
scope = Scope.Namespaced,
names = Names(
plural = "ingresses",
singular = "ingress",
kind = "Ingress",
shortNames = List("ing")
)
)

implicit val ingDef : ResourceDefinition[Ingress] = new ResourceDefinition[Ingress] {
def spec: NonCoreResourceSpecification = specification
}
implicit val ingListDef: ResourceDefinition[IngressList] = new ResourceDefinition[IngressList] {
def spec: NonCoreResourceSpecification = specification
}

def apply(name: String): Ingress = Ingress(metadata = ObjectMeta(name = name))

case class Port(name: Option[String] = None, number: NameablePort)
case class ServiceType(name: String, port: Port)

// Backend contains either service or resource
case class Backend(service: Option[ServiceType] = None, resource: Option[String] = None)
case class Path(path: String, backend: Backend, pathType: PathType = ImplementationSpecific)
case class HttpRule(paths: List[Path] = List())
case class Rule(host: Option[String], http: HttpRule)
case class TLS(hosts: List[String] = List(), secretName: Option[String] = None)

object PathType extends Enumeration {
type PathType = Value
val ImplementationSpecific, Exact, Prefix = Value
}

case class Spec(
backend: Option[Backend] = None,
rules: List[Rule] = List(),
tls: List[TLS] = List(),
ingressClassName: Option[String] = None)

case class Status(loadBalancer: Option[Status.LoadBalancer] = None)

object Status {
case class LoadBalancer(ingress: List[LoadBalancer.Ingress])
object LoadBalancer {
case class Ingress(ip: Option[String] = None, hostName: Option[String] = None)
}
}

// json formatters

import play.api.libs.functional.syntax._
import skuber.json.format._

implicit val ingressPortFmt: Format[Ingress.Port] = Json.format[Ingress.Port]

implicit val ingressServiceFmt: Format[Ingress.ServiceType] = (
(JsPath \ "name").format[String] and
(JsPath \ "port").format[Ingress.Port]
) (Ingress.ServiceType.apply _, i => (i.name, i.port))

implicit val ingressBackendFmt: Format[Ingress.Backend] = (
(JsPath \ "service").formatNullable[Ingress.ServiceType] and
(JsPath \ "resource").formatNullable[String]
) (Ingress.Backend.apply _, i => (i.service, i.resource))

implicit val ingressPathFmt: Format[Ingress.Path] = (
(JsPath \ "path").formatMaybeEmptyString() and
(JsPath \ "backend").format[Ingress.Backend] and
(JsPath \ "pathType").formatEnum(PathType, PathType.ImplementationSpecific.toString)
) (Ingress.Path.apply _, i => (i.path, i.backend, i.pathType))

implicit val ingressHttpRuledFmt: Format[Ingress.HttpRule] = Json.format[Ingress.HttpRule]
implicit val ingressRuleFmt : Format[Ingress.Rule] = Json.format[Ingress.Rule]
implicit val ingressTLSFmt : Format[Ingress.TLS] = Json.format[Ingress.TLS]


implicit val ingressSpecFormat: Format[Ingress.Spec] = (
(JsPath \ "defaultBackend").formatNullable[Ingress.Backend] and
(JsPath \ "rules").formatMaybeEmptyList[Ingress.Rule] and
(JsPath \ "tls").formatMaybeEmptyList[Ingress.TLS] and
(JsPath \ "ingressClassName").formatNullable[String]
) (Ingress.Spec.apply _, i => (i.backend, i.rules, i.tls, i.ingressClassName))


implicit val ingrlbingFormat: Format[Ingress.Status.LoadBalancer.Ingress] =
Json.format[Ingress.Status.LoadBalancer.Ingress]

implicit val ingrlbFormat: Format[Ingress.Status.LoadBalancer] =
(JsPath \ "ingress").formatMaybeEmptyList[Ingress.Status.LoadBalancer.Ingress].inmap(
ings => Ingress.Status.LoadBalancer(ings),
lb => lb.ingress
)

implicit val ingressStatusFormat: Format[Ingress.Status] = Json.format[Ingress.Status]

implicit lazy val ingressFormat: Format[Ingress] = (
objFormat and
(JsPath \ "spec").formatNullable[Ingress.Spec] and
(JsPath \ "status").formatNullable[Ingress.Status]
) (Ingress.apply _, i => (i.kind, i.apiVersion, i.metadata, i.spec, i.status))

implicit val ingressListFmt: Format[IngressList] = ListResourceFormat[Ingress]

}
9 changes: 9 additions & 0 deletions client/src/main/scala/skuber/networking/v1/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package skuber.networking

import skuber.ListResource

package object v1 {
val ingressAPIVersion = "networking.k8s.io/v1"

type IngressList = ListResource[Ingress]
}
95 changes: 95 additions & 0 deletions client/src/test/scala/skuber/network/v1/IngressSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package skuber.network.v1

import org.specs2.mutable.Specification
import play.api.libs.json._
import skuber._
import skuber.networking.v1.Ingress
import skuber.networking.v1.Ingress.PathType._


class IngressSpec extends Specification {
"This is a unit specification for the skuber Ingress class from v1 api version. ".txt

"An Ingress object can be written to Json and then read back again successfully" >> {
val ingress = Ingress("example")
.addHttpRule("example.com", Exact, Map(
"/" -> "service:80",
"/about" -> "another-service:http"
))

val readIng = Json.fromJson[Ingress](Json.toJson(ingress)).get
readIng mustEqual ingress
}

"An Ingress object with empty Path can be read directly from a JSON string" >> {
val ingJsonStr =
"""
|{
| "apiVersion": "networking.k8s.io/v1",
| "kind": "Ingress",
| "metadata": {
| "creationTimestamp": "2017-04-02T19:39:34Z",
| "generation": 3,
| "labels": {
| "app": "ingress"
| },
| "name": "example-ingress",
| "namespace": "default",
| "resourceVersion": "1313499",
| "selfLink": "/apis/extensions/v1/namespaces/default/ingresses/example",
| "uid": "192dd131-17dc-11e7-bd9c-0a5e79684354"
| },
| "spec": {
| "rules": [
| {
| "host": "example.com",
| "http": {
| "paths": [
| {
| "backend": {
| "service": {
| "name": "example-svc",
| "port": {
| "number": 8080
| }
| },
| "pathType": "Exact"
| }
| }
| ]
| }
| }
| ],
| "tls": [
| {
| "hosts": ["abc","def"]
| }
| ],
| "ingressClassName": "nginx"
| },
| "status": {
| "loadBalancer": {
| "ingress": [
| {
| "hostname": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111.us-east-1.elb.amazonaws.com"
| }
| ]
| }
| }
|}""".stripMargin

val ing = Json.parse(ingJsonStr).as[Ingress]
ing.kind mustEqual "Ingress"
ing.name mustEqual "example-ingress"

ing.spec.get.rules.head.host must beSome("example.com")
ing.spec.get.rules.head.http.paths must_== List(
Ingress.Path(path = "", backend = Ingress.Backend(Some(Ingress.ServiceType("example-svc", Ingress.Port(number = 8080))))),
)
ing.spec.get.tls must_== List(Ingress.TLS(
hosts = List("abc", "def"),
secretName = None
))

}
}