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 and fetch /apis #339

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ target/
.idea/
.bloop/
.metals/
.bsp/
.vscode/
*.sc
*.launch
*.s??
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package skuber.api.client
import akka.stream.scaladsl.{Sink, Source}
import akka.util.ByteString
import play.api.libs.json.{Writes,Format}
import skuber.{DeleteOptions, HasStatusSubresource, LabelSelector, ListOptions, ListResource, ObjectResource, Pod, ResourceDefinition, Scale}
import skuber.{APIGroup, DeleteOptions, HasStatusSubresource, LabelSelector, ListOptions, ListResource, ObjectResource, Pod, ResourceDefinition, Scale}
import skuber.api.patch.Patch

import scala.concurrent.{Future, Promise}
Expand Down Expand Up @@ -357,6 +357,13 @@ trait KubernetesClient {
*/
def getServerAPIVersions(implicit lc: LoggingContext): Future[List[String]]

/**
* Return list of API groups with their versions supported by the server
* @param lc
* @return a future containing the list of API groups
*/
def getAPIGroupList(implicit lc: LoggingContext): Future[List[APIGroup]]

/**
* Create a new KubernetesClient instance that reuses this clients configuration and connection resources, but with a different
* target namespace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import skuber.api.security.{HTTPRequestAuth, TLS}
import skuber.api.watch.{LongPollingPool, Watch, WatchSource}
import skuber.json.PlayJsonSupportForAkkaHttp._
import skuber.json.format.apiobj.statusReads
import skuber.json.format.{apiVersionsFormat, deleteOptionsFmt, namespaceListFmt}
import skuber.json.format.{apiGroupListFormat, apiVersionsFormat, deleteOptionsFmt, namespaceListFmt}
import skuber.api.patch._

import scala.concurrent.duration._
Expand Down Expand Up @@ -606,6 +606,17 @@ class KubernetesClientImpl private[client] (
} yield apiVersionResource.versions
}

// get API groups with versions supported by the cluster
override def getAPIGroupList(implicit lc: LoggingContext): Future[List[APIGroup]] = {
val url = clusterServer + "/apis"
val noAuthReq = requestMaker(Uri(url), HttpMethods.GET)
val request = HTTPRequestAuth.addAuth(noAuthReq, requestAuth)
for {
response <- invoke(request)
getAPIGroupListResource <- toKubernetesResponse[APIGroupList](response)
} yield getAPIGroupListResource.groups
}

/*
* Execute a command in a pod
*/
Expand Down
4 changes: 4 additions & 0 deletions client/src/main/scala/skuber/json/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ package object format {

implicit val apiVersionsFormat = Json.format[APIVersions]

implicit val apiGroupVersionFormat: Format[APIGroupVersion] = Json.format[APIGroupVersion]
implicit val apiGroupFormat : Format[APIGroup] = Json.format[APIGroup]
implicit val apiGroupListFormat : Format[APIGroupList] = Json.format[APIGroupList]

implicit val objRefFormat: Format[ObjectReference] = (
(JsPath \ "kind").formatMaybeEmptyString() and
(JsPath \ "apiVersion").formatMaybeEmptyString() and
Expand Down
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 _, unlift(Ingress.ServiceType.unapply))

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

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

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 _, unlift(Ingress.Spec.unapply))


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 _, unlift(Ingress.unapply))

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]
}
17 changes: 17 additions & 0 deletions client/src/main/scala/skuber/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ package object skuber {
kind: String,
versions: List[String])

case class APIGroupList(
kind: String,
apiVersion: String,
groups: List[APIGroup]
)

case class APIGroup(
name: String,
versions: List[APIGroupVersion],
preferredVersion: APIGroupVersion
)

case class APIGroupVersion(
groupVersion: String,
version: String
)

// type for classes that can be items of some Kubernetes list type
// e.g. a Pod can be an item in a PodList, Node can be in a NodeList etc.
// Just a type alias to ObjectResource
Expand Down
Loading