├── project ├── package.mill ├── model.mill └── Dependencies.mill ├── kubernetes-client ├── src │ └── com │ │ └── goyeau │ │ └── kubernetes │ │ └── client │ │ ├── KubeConfigNotFoundError.scala │ │ ├── util │ │ ├── Uris.scala │ │ ├── cache │ │ │ ├── AuthorizationWithExpiration.scala │ │ │ ├── AuthorizationParse.scala │ │ │ ├── ExecToken.scala │ │ │ └── AuthorizationCache.scala │ │ ├── Text.scala │ │ ├── CirceEntityCodec.scala │ │ ├── SslContexts.scala │ │ └── Yamls.scala │ │ ├── crd │ │ ├── JSON.scala │ │ ├── JSONSchemaPropsOrBool.scala │ │ ├── JSONSchemaPropsOrArray.scala │ │ ├── JSONSchemaPropsOrStringArray.scala │ │ ├── CustomResource.scala │ │ └── RawExtension.scala │ │ ├── IntOrString.scala │ │ ├── operation │ │ ├── GroupDeletable.scala │ │ ├── Gettable.scala │ │ ├── Listable.scala │ │ ├── DeletableTerminated.scala │ │ ├── Proxy.scala │ │ ├── Deletable.scala │ │ ├── Replaceable.scala │ │ ├── package.scala │ │ ├── Watchable.scala │ │ └── Creatable.scala │ │ ├── api │ │ ├── NodesApi.scala │ │ ├── NamespacesApi.scala │ │ ├── RawApi.scala │ │ ├── CustomResourceDefinitionsApi.scala │ │ ├── JobsApi.scala │ │ ├── ServicesApi.scala │ │ ├── LeasesApi.scala │ │ ├── ConfigMapsApi.scala │ │ ├── IngressesApi.scala │ │ ├── CronJobsApi.scala │ │ ├── ReplicaSetsApi.scala │ │ ├── DeploymentsApi.scala │ │ ├── StatefulSetsApi.scala │ │ ├── ServiceAccountsApi.scala │ │ ├── PodDisruptionBudgetsApi.scala │ │ ├── PersistentVolumeClaimsApi.scala │ │ ├── SecretsApi.scala │ │ ├── HorizontalPodAutoscalersApi.scala │ │ └── CustomResourcesApi.scala │ │ ├── WatchEvent.scala │ │ └── KubernetesClient.scala └── test │ ├── src │ └── com │ │ └── goyeau │ │ └── kubernetes │ │ └── client │ │ ├── api │ │ ├── ContextProvider.scala │ │ ├── NodesApiTest.scala │ │ ├── RawApiTest.scala │ │ ├── ServicesApiTest.scala │ │ ├── ConfigMapsApiTest.scala │ │ ├── ServiceAccountsApiTest.scala │ │ ├── LeasesApiTest.scala │ │ ├── IngressesApiTest.scala │ │ ├── JobsApiTest.scala │ │ ├── ReplicaSetsApiTest.scala │ │ ├── CronJobsApiTest.scala │ │ ├── HorizontalPodAutoscalersApiTest.scala │ │ ├── DeploymentsApiTest.scala │ │ ├── StatefulSetsApiTest.scala │ │ ├── PersistentVolumeClaimsApiTest.scala │ │ ├── SecretsApiTest.scala │ │ ├── CustomResourcesApiTest.scala │ │ ├── NamespacesApiTest.scala │ │ └── CustomResourceDefinitionsApiTest.scala │ │ ├── TestPodSpec.scala │ │ ├── Utils.scala │ │ ├── operation │ │ ├── GettableTests.scala │ │ ├── DeletableTerminatedTests.scala │ │ ├── MinikubeClientProvider.scala │ │ ├── DeletableTests.scala │ │ ├── ReplaceableTests.scala │ │ ├── ListableTests.scala │ │ ├── CreatableTests.scala │ │ └── WatchableTests.scala │ │ └── cache │ │ └── AuthorizationCacheTest.scala │ └── resources │ └── logback-test.xml ├── .scalafix.conf ├── .git-blame-ignore-revs ├── .scalafmt.conf ├── .gitignore ├── .github └── workflows │ └── ci.yml └── README.md /project/package.mill: -------------------------------------------------------------------------------- 1 | package build.project 2 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/KubeConfigNotFoundError.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | case object KubeConfigNotFoundError extends RuntimeException("Kubernetes configuration not found") 4 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/Uris.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | 3 | import org.http4s.Uri 4 | 5 | object Uris { 6 | def addLabels(labels: Map[String, String], uri: Uri): Uri = 7 | if (labels.nonEmpty) uri +? ("labelSelector" -> labels.map { case (k, v) => s"$k=$v" }.mkString(",")) 8 | else uri 9 | } 10 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationWithExpiration.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util.cache 2 | 3 | import org.http4s.headers.Authorization 4 | 5 | import java.time.Instant 6 | 7 | case class AuthorizationWithExpiration( 8 | expirationTimestamp: Option[Instant], 9 | authorization: Authorization 10 | ) 11 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSON.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import io.circe.syntax.* 4 | import io.circe.{Decoder, Encoder} 5 | 6 | final case class JSON(value: String) 7 | 8 | object JSON { 9 | implicit val encode: Encoder[JSON] = _.value.asJson 10 | implicit val decode: Decoder[JSON] = _.as[String].map(JSON(_)) 11 | } 12 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ContextProvider.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.Parallel 4 | import cats.effect.unsafe.implicits.global 5 | import cats.effect.IO 6 | 7 | trait ContextProvider { 8 | def unsafeRunSync[A](f: IO[A]): A = f.unsafeRunSync() 9 | 10 | implicit lazy val parallel: Parallel[IO] = IO.parallelForIO 11 | } 12 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/Text.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | 3 | import cats.effect.Sync 4 | import cats.syntax.all.* 5 | import fs2.io.file.{Files, Path} 6 | 7 | object Text { 8 | 9 | def readFile[F[_]: Sync: Files](path: Path): F[String] = 10 | Files[F].readAll(path).through(fs2.text.utf8.decode).compile.toList.map(_.mkString) 11 | 12 | } 13 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | DisableSyntax 3 | LeakingImplicitClassVal 4 | NoValInForComprehension 5 | ] 6 | 7 | DisableSyntax.noVars = true 8 | DisableSyntax.noThrows = false 9 | DisableSyntax.noNulls = true 10 | DisableSyntax.noReturns = true 11 | DisableSyntax.noAsInstanceOf = true 12 | DisableSyntax.noIsInstanceOf = true 13 | DisableSyntax.noXml = true 14 | DisableSyntax.noFinalVal = true 15 | DisableSyntax.noFinalize = true 16 | DisableSyntax.noValPatterns = true 17 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.6.1 2 | fb2b38eaaf64326eac0c53fb297b08cdf9f3cba6 3 | 4 | # Scala Steward: Reformat with scalafmt 3.7.0 5 | 78c83923337dba2bffae765eed8e9c9077fd340e 6 | 7 | # Scala Steward: Reformat with scalafmt 3.7.3 8 | 4d46123397e5fdc126e4680cafac078e8181f675 9 | 10 | # Scala Steward: Reformat with scalafmt 3.9.0 11 | db5a13ef3ea7d0177d652be71944f5543e33efcc 12 | 13 | # Scala Steward: Reformat with scalafmt 3.9.8 14 | 97d028fdaba44e73cd588c16135f8d3b1d726157 15 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.10.1 2 | runner.dialect = scala213source3 3 | fileOverride."glob:**.sc".runner.dialect = scala213 4 | project.git = true 5 | maxColumn = 120 6 | align.preset = more 7 | assumeStandardLibraryStripMargin = true 8 | rewrite.rules = [AvoidInfix, RedundantBraces, RedundantParens, SortModifiers, Imports] 9 | rewrite.neverInfix.excludeFilters."+" = ["zip", "diff", """\+\?""", """\+\?\?""", """\+\+\?"""] 10 | rewrite.redundantBraces.ifElseExpressions = true 11 | rewrite.redundantBraces.stringInterpolation = true 12 | rewrite.imports.sort = original 13 | rewrite.scala3.convertToNewSyntax = true 14 | rewrite.scala3.removeOptionalBraces = oldSyntaxToo 15 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/CirceEntityCodec.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | 3 | import cats.effect.Concurrent 4 | import io.circe.{Decoder, Encoder, Printer} 5 | import org.http4s.{EntityDecoder, EntityEncoder} 6 | import org.http4s.circe.CirceInstances 7 | 8 | private[client] object CirceEntityCodec extends CirceInstances { 9 | override val defaultPrinter: Printer = Printer.noSpaces.copy(dropNullValues = true) 10 | 11 | implicit def circeEntityEncoder[F[_], A: Encoder]: EntityEncoder[F, A] = jsonEncoderOf[F, A] 12 | implicit def circeEntityDecoder[F[_]: Concurrent, A: Decoder]: EntityDecoder[F, A] = jsonOf[F, A] 13 | } 14 | -------------------------------------------------------------------------------- /project/model.mill: -------------------------------------------------------------------------------- 1 | package build.project 2 | 3 | import io.circe.Codec 4 | 5 | case class Definition( 6 | description: Option[String], 7 | required: Option[Seq[String]], 8 | properties: Option[Map[String, Property]], 9 | `type`: Option[String], 10 | `x-kubernetes-group-version-kind`: Option[Seq[Kind]] 11 | ) derives Codec 12 | 13 | case class Property( 14 | description: Option[String], 15 | format: Option[String], 16 | `type`: Option[String], 17 | items: Option[Property], 18 | additionalProperties: Option[Property], 19 | $ref: Option[String] 20 | ) derives Codec 21 | 22 | case class Kind( 23 | group: String, 24 | kind: String, 25 | version: String 26 | ) derives Codec 27 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/IntOrString.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | import io.circe.{Decoder, Encoder, Json} 4 | import cats.implicits.* 5 | 6 | trait IntOrString 7 | case class IntValue(value: Int) extends IntOrString 8 | case class StringValue(value: String) extends IntOrString 9 | 10 | object IntOrString { 11 | implicit val encode: Encoder[IntOrString] = { 12 | case IntValue(int) => Json.fromInt(int) 13 | case StringValue(string) => Json.fromString(string) 14 | } 15 | 16 | implicit val decode: Decoder[IntOrString] = cursor => { 17 | val decodeInt = cursor.as[Int].map(IntValue.apply) 18 | val decodeString = cursor.as[String].map(StringValue.apply) 19 | decodeInt.leftFlatMap(_ => decodeString) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/GroupDeletable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.util.Uris.addLabels 6 | import org.http4s.* 7 | import org.http4s.client.Client 8 | import org.http4s.Method.* 9 | import org.http4s.headers.Authorization 10 | 11 | private[client] trait GroupDeletable[F[_]] { 12 | protected def httpClient: Client[F] 13 | implicit protected val F: Async[F] 14 | protected def config: KubeConfig[F] 15 | protected def authorization: Option[F[Authorization]] 16 | protected def resourceUri: Uri 17 | 18 | def deleteAll(labels: Map[String, String] = Map.empty): F[Status] = { 19 | val uri = addLabels(labels, config.server.resolve(resourceUri)) 20 | httpClient.status(Request[F](DELETE, uri).withOptionalAuthorization(authorization)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Gettable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 6 | import io.circe.* 7 | import org.http4s.* 8 | import org.http4s.client.Client 9 | import org.http4s.Method.* 10 | import org.http4s.headers.Authorization 11 | 12 | private[client] trait Gettable[F[_], Resource] { 13 | protected def httpClient: Client[F] 14 | implicit protected val F: Async[F] 15 | protected def config: KubeConfig[F] 16 | protected def authorization: Option[F[Authorization]] 17 | protected def resourceUri: Uri 18 | implicit protected def resourceDecoder: Decoder[Resource] 19 | 20 | def get(name: String): F[Resource] = 21 | httpClient.expectF[Resource]( 22 | Request[F](GET, config.server.resolve(resourceUri) / name) 23 | .withOptionalAuthorization(authorization) 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/NodesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.Decoder 7 | import io.circe.Encoder 8 | import io.k8s.api.core.v1.Node 9 | import io.k8s.api.core.v1.NodeList 10 | import org.http4s.Uri 11 | import org.http4s.client.Client 12 | import org.http4s.headers.Authorization 13 | import org.http4s.implicits.* 14 | 15 | private[client] class NodesApi[F[_]]( 16 | val httpClient: Client[F], 17 | val config: KubeConfig[F], 18 | val authorization: Option[F[Authorization]] 19 | )(implicit 20 | val F: Async[F], 21 | val listDecoder: Decoder[NodeList], 22 | val resourceEncoder: Encoder[Node], 23 | val resourceDecoder: Decoder[Node] 24 | ) extends Gettable[F, Node] 25 | with Listable[F, NodeList] 26 | with Watchable[F, Node] { 27 | protected val resourceUri: Uri = uri"/api" / "v1" / "nodes" 28 | } 29 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrBool.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import cats.syntax.either.* 4 | import io.circe.syntax.* 5 | import io.circe.{Decoder, Encoder, Json} 6 | import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps 7 | 8 | trait JSONSchemaPropsOrBool 9 | case class SchemaNotBoolValue(value: JSONSchemaProps) extends JSONSchemaPropsOrBool 10 | case class BoolValue(value: Boolean) extends JSONSchemaPropsOrBool 11 | 12 | object JSONSchemaPropsOrBool { 13 | implicit val encode: Encoder[JSONSchemaPropsOrBool] = { 14 | case SchemaNotBoolValue(schema) => schema.asJson 15 | case BoolValue(bool) => Json.fromBoolean(bool) 16 | } 17 | 18 | implicit val decode: Decoder[JSONSchemaPropsOrBool] = cursor => { 19 | val decodeSchema = cursor.as[JSONSchemaProps].map(SchemaNotBoolValue.apply) 20 | val decodeBool = cursor.as[Boolean].map(BoolValue.apply) 21 | decodeSchema.leftFlatMap(_ => decodeBool) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/TestPodSpec.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | import cats.syntax.all.* 4 | import io.k8s.api.core.v1.{Container, PodSpec, ResourceRequirements} 5 | import io.k8s.apimachinery.pkg.api.resource.Quantity 6 | 7 | object TestPodSpec { 8 | 9 | val alpine: PodSpec = alpine(None) 10 | 11 | def alpine(command: Seq[String]): PodSpec = alpine(command.some) 12 | 13 | private def alpine(command: Option[Seq[String]]): PodSpec = PodSpec( 14 | containers = Seq( 15 | Container( 16 | name = "test", 17 | image = "alpine".some, 18 | command = command, 19 | imagePullPolicy = "IfNotPresent".some, 20 | resources = ResourceRequirements( 21 | requests = Map( 22 | "memory" -> Quantity("10Mi") 23 | ).some, 24 | limits = Map( 25 | "memory" -> Quantity("10Mi") 26 | ).some 27 | ).some 28 | ) 29 | ), 30 | terminationGracePeriodSeconds = 0L.some 31 | ) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrArray.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import cats.syntax.either.* 4 | import io.circe.syntax.* 5 | import io.circe.{Decoder, Encoder} 6 | import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps 7 | 8 | trait JSONSchemaPropsOrArray 9 | case class SchemaNotArrayValue(value: JSONSchemaProps) extends JSONSchemaPropsOrArray 10 | case class ArrayValue(value: Array[JSONSchemaProps]) extends JSONSchemaPropsOrArray 11 | 12 | object JSONSchemaPropsOrArray { 13 | implicit val encode: Encoder[JSONSchemaPropsOrArray] = { 14 | case SchemaNotArrayValue(schema) => schema.asJson 15 | case ArrayValue(array) => array.asJson 16 | } 17 | 18 | implicit val decode: Decoder[JSONSchemaPropsOrArray] = cursor => { 19 | val decodeSchema = cursor.as[JSONSchemaProps].map(SchemaNotArrayValue.apply) 20 | val decodeArray = cursor.as[Array[JSONSchemaProps]].map(ArrayValue.apply) 21 | decodeSchema.leftFlatMap(_ => decodeArray) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Listable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 6 | import com.goyeau.kubernetes.client.util.Uris.addLabels 7 | import io.circe.* 8 | import org.http4s.* 9 | import org.http4s.client.Client 10 | import org.http4s.Method.* 11 | import org.http4s.headers.Authorization 12 | 13 | private[client] trait Listable[F[_], Resource] { 14 | protected def httpClient: Client[F] 15 | implicit protected val F: Async[F] 16 | protected def config: KubeConfig[F] 17 | protected def authorization: Option[F[Authorization]] 18 | protected def resourceUri: Uri 19 | implicit protected def listDecoder: Decoder[Resource] 20 | 21 | def list(labels: Map[String, String] = Map.empty): F[Resource] = { 22 | val uri = addLabels(labels, config.server.resolve(resourceUri)) 23 | httpClient.expectF[Resource](Request[F](GET, uri).withOptionalAuthorization(authorization)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrStringArray.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import cats.syntax.either.* 4 | import io.circe.syntax.* 5 | import io.circe.{Decoder, Encoder} 6 | import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps 7 | 8 | trait JSONSchemaPropsOrStringArray 9 | case class SchemaNotStringArrayValue(value: JSONSchemaProps) extends JSONSchemaPropsOrStringArray 10 | case class StringArrayValue(value: Array[String]) extends JSONSchemaPropsOrStringArray 11 | 12 | object JSONSchemaPropsOrStringArray { 13 | implicit val encode: Encoder[JSONSchemaPropsOrStringArray] = { 14 | case SchemaNotStringArrayValue(schema) => schema.asJson 15 | case StringArrayValue(array) => array.asJson 16 | } 17 | 18 | implicit val decode: Decoder[JSONSchemaPropsOrStringArray] = cursor => { 19 | val decodeSchema = cursor.as[JSONSchemaProps].map(SchemaNotStringArrayValue.apply) 20 | val decodeArray = cursor.as[Array[String]].map(StringArrayValue.apply) 21 | decodeSchema.leftFlatMap(_ => decodeArray) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/DeletableTerminated.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.syntax.all.* 4 | import scala.concurrent.duration.* 5 | import cats.effect.Temporal 6 | import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions 7 | import org.http4s.* 8 | 9 | private[client] trait DeletableTerminated[F[_]] { this: Deletable[F] => 10 | 11 | def deleteTerminated(name: String, deleteOptions: Option[DeleteOptions] = None)(implicit 12 | temporal: Temporal[F] 13 | ): F[Status] = { 14 | 15 | def deleteTerminated(firstTry: Boolean): F[Status] = { 16 | 17 | def retry() = 18 | temporal.sleep(1.second) *> deleteTerminated(firstTry = false) 19 | 20 | delete(name, deleteOptions).flatMap { 21 | case status if status.isSuccess => retry() 22 | case Status.Conflict => retry() 23 | case response @ Status.NotFound => 24 | if (firstTry) F.pure(response) else F.pure(Status.Ok) 25 | case status => F.pure(status) 26 | } 27 | } 28 | 29 | deleteTerminated(firstTry = true) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/NamespacesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.{Decoder, Encoder} 7 | import io.k8s.api.core.v1.{Namespace, NamespaceList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class NamespacesApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[NamespaceList], 20 | val resourceEncoder: Encoder[Namespace], 21 | val resourceDecoder: Decoder[Namespace] 22 | ) extends Creatable[F, Namespace] 23 | with Replaceable[F, Namespace] 24 | with Gettable[F, Namespace] 25 | with Listable[F, NamespaceList] 26 | with Deletable[F] 27 | with DeletableTerminated[F] 28 | with Watchable[F, Namespace] { 29 | protected val resourceUri: Uri = uri"/api" / "v1" / "namespaces" 30 | } 31 | -------------------------------------------------------------------------------- /project/Dependencies.mill: -------------------------------------------------------------------------------- 1 | package build.project 2 | 3 | import mill.* 4 | import mill.scalalib.* 5 | 6 | object Dependencies: 7 | lazy val circe = { 8 | val version = "0.14.15" 9 | Seq( 10 | mvn"io.circe::circe-core:$version", 11 | mvn"io.circe::circe-generic:$version", 12 | mvn"io.circe::circe-parser:$version" 13 | ) 14 | } 15 | 16 | lazy val http4s = { 17 | val version = "0.23.32" 18 | val jdkClientVersion = "0.5.0" 19 | Seq( 20 | mvn"org.http4s::http4s-dsl:$version", 21 | mvn"org.http4s::http4s-circe:$version", 22 | mvn"org.http4s::http4s-jdk-http-client:$jdkClientVersion" 23 | ) 24 | } 25 | 26 | lazy val circeYaml = Seq(mvn"io.circe::circe-yaml:0.15.2") 27 | 28 | lazy val bouncycastle = Seq(mvn"org.bouncycastle:bcpkix-jdk18on:1.82") 29 | 30 | lazy val collectionCompat = Seq(mvn"org.scala-lang.modules::scala-collection-compat:2.14.0") 31 | 32 | lazy val logging = Seq(mvn"org.typelevel::log4cats-slf4j:2.7.1") 33 | 34 | lazy val logback = Seq(mvn"ch.qos.logback:logback-classic:1.5.21") 35 | 36 | lazy val java8compat = Seq(mvn"org.scala-lang.modules::scala-java8-compat:1.0.2") 37 | 38 | lazy val tests = Seq(mvn"org.scalameta::munit:1.2.1") 39 | -------------------------------------------------------------------------------- /kubernetes-client/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 6 | 7 | 8 | 9 | %date{yyyy-MM-dd HH:mm:ss.SSSZ, UTC} %-16level %-43thread %-24logger{24} %message%n%xException 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/CustomResource.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import io.circe.generic.semiauto.* 4 | import io.circe.{Decoder, Encoder} 5 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 6 | 7 | case class CustomResource[A, B]( 8 | apiVersion: String, 9 | kind: String, 10 | metadata: Option[ObjectMeta], 11 | spec: A, 12 | status: Option[B] 13 | ) 14 | 15 | object CustomResource { 16 | implicit def encoder[A: Encoder, B: Encoder]: Encoder.AsObject[CustomResource[A, B]] = deriveEncoder 17 | implicit def decoder[A: Decoder, B: Decoder]: Decoder[CustomResource[A, B]] = deriveDecoder 18 | } 19 | 20 | case class CustomResourceList[A, B]( 21 | items: Seq[CustomResource[A, B]], 22 | apiVersion: Option[String] = None, 23 | kind: Option[String] = None, 24 | metadata: Option[io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta] = None 25 | ) 26 | 27 | object CustomResourceList { 28 | implicit def encoder[A: Encoder, B: Encoder]: Encoder.AsObject[CustomResourceList[A, B]] = 29 | deriveEncoder 30 | implicit def decoder[A: Decoder, B: Decoder]: Decoder[CustomResourceList[A, B]] = 31 | deriveDecoder 32 | } 33 | 34 | case class CrdContext(group: String, version: String, plural: String) 35 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Proxy.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import org.http4s.* 6 | import org.http4s.client.Client 7 | import org.http4s.EntityDecoder 8 | import org.http4s.Uri.Path 9 | import org.http4s.headers.{`Content-Type`, Authorization} 10 | 11 | private[client] trait Proxy[F[_]] { 12 | protected def httpClient: Client[F] 13 | implicit protected val F: Async[F] 14 | protected def config: KubeConfig[F] 15 | protected def authorization: Option[F[Authorization]] 16 | protected def resourceUri: Uri 17 | 18 | def proxy( 19 | name: String, 20 | method: Method, 21 | path: Path, 22 | contentType: `Content-Type` = `Content-Type`(MediaType.text.plain), 23 | data: Option[String] = None 24 | ): F[String] = 25 | httpClient.expect[String]( 26 | Request( 27 | method, 28 | (config.server.resolve(resourceUri) / name / "proxy").addPath(path.toRelative.renderString), 29 | body = data.fold[EntityBody[F]](EmptyBody)( 30 | implicitly[EntityEncoder[F, String]].withContentType(contentType).toEntity(_).body 31 | ) 32 | ).withOptionalAuthorization(authorization) 33 | )(EntityDecoder.text) 34 | 35 | } 36 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Deletable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 6 | import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions 7 | import org.http4s.* 8 | import org.http4s.Method.* 9 | import org.http4s.client.Client 10 | import org.http4s.headers.{`Content-Type`, Authorization} 11 | 12 | private[client] trait Deletable[F[_]] { 13 | protected def httpClient: Client[F] 14 | implicit protected val F: Async[F] 15 | protected def config: KubeConfig[F] 16 | protected def authorization: Option[F[Authorization]] 17 | protected def resourceUri: Uri 18 | 19 | def delete(name: String, deleteOptions: Option[DeleteOptions] = None): F[Status] = { 20 | val encoder: EntityEncoder[F, Option[DeleteOptions]] = 21 | EntityEncoder.encodeBy(`Content-Type`(MediaType.application.json)) { maybeOptions => 22 | maybeOptions.fold(Entity[F](EmptyBody.covary[F], Some(0L)))(EntityEncoder[F, DeleteOptions].toEntity(_)) 23 | } 24 | 25 | httpClient.status( 26 | Request[F](method = DELETE, uri = config.server.resolve(resourceUri) / name) 27 | .withEntity(deleteOptions)(encoder) 28 | .withOptionalAuthorization(authorization) 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Replaceable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import scala.language.reflectiveCalls 4 | import cats.effect.Async 5 | import com.goyeau.kubernetes.client.KubeConfig 6 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 7 | import io.circe.* 8 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 9 | import org.http4s.* 10 | import org.http4s.client.Client 11 | import org.http4s.Method.* 12 | import org.http4s.headers.Authorization 13 | 14 | private[client] trait Replaceable[F[_], Resource <: { def metadata: Option[ObjectMeta] }] { 15 | protected def httpClient: Client[F] 16 | implicit protected val F: Async[F] 17 | protected def config: KubeConfig[F] 18 | protected def authorization: Option[F[Authorization]] 19 | protected def resourceUri: Uri 20 | implicit protected def resourceEncoder: Encoder[Resource] 21 | implicit protected def resourceDecoder: Decoder[Resource] 22 | 23 | def replace(resource: Resource): F[Status] = 24 | httpClient.status(buildRequest(resource)) 25 | 26 | def replaceWithResource(resource: Resource): F[Resource] = 27 | httpClient.expect[Resource](buildRequest(resource)) 28 | 29 | private def buildRequest(resource: Resource) = 30 | Request[F](PUT, config.server.resolve(resourceUri) / resource.metadata.get.name.get) 31 | .withEntity(resource) 32 | .withOptionalAuthorization(authorization) 33 | } 34 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.syntax.all.* 4 | import cats.effect.{Async, Resource} 5 | import com.goyeau.kubernetes.client.KubeConfig 6 | import com.goyeau.kubernetes.client.operation.* 7 | import org.http4s.client.Client 8 | import org.http4s.headers.Authorization 9 | import org.http4s.jdkhttpclient.{WSClient, WSConnectionHighLevel, WSRequest} 10 | import org.http4s.{Request, Response} 11 | 12 | private[client] class RawApi[F[_]]( 13 | httpClient: Client[F], 14 | wsClient: WSClient[F], 15 | config: KubeConfig[F], 16 | authorization: Option[F[Authorization]] 17 | )(implicit F: Async[F]) { 18 | 19 | def runRequest( 20 | request: Request[F] 21 | ): Resource[F, Response[F]] = 22 | Request[F]( 23 | method = request.method, 24 | uri = config.server.resolve(request.uri), 25 | httpVersion = request.httpVersion, 26 | headers = request.headers, 27 | body = request.body, 28 | attributes = request.attributes 29 | ).withOptionalAuthorization(authorization) 30 | .toResource 31 | .flatMap(httpClient.run) 32 | 33 | def connectWS( 34 | request: WSRequest 35 | ): Resource[F, WSConnectionHighLevel[F]] = 36 | request 37 | .copy(uri = config.server.resolve(request.uri)) 38 | .withOptionalAuthorization(authorization) 39 | .toResource 40 | .flatMap { request => 41 | wsClient.connectHighLevel(request) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.{Decoder, Encoder} 7 | import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.{CustomResourceDefinition, CustomResourceDefinitionList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class CustomResourceDefinitionsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[CustomResourceDefinitionList], 20 | val resourceEncoder: Encoder[CustomResourceDefinition], 21 | val resourceDecoder: Decoder[CustomResourceDefinition] 22 | ) extends Creatable[F, CustomResourceDefinition] 23 | with Replaceable[F, CustomResourceDefinition] 24 | with Gettable[F, CustomResourceDefinition] 25 | with Listable[F, CustomResourceDefinitionList] 26 | with Deletable[F] 27 | with DeletableTerminated[F] 28 | with GroupDeletable[F] 29 | with Watchable[F, CustomResourceDefinition] { self => 30 | val resourceUri: Uri = uri"/apis" / "apiextensions.k8s.io" / "v1" / "customresourcedefinitions" 31 | override val watchResourceUri: Uri = 32 | uri"/apis" / "apiextensions.k8s.io" / "v1" / "watch" / "customresourcedefinitions" 33 | } 34 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/WatchEvent.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | /** Event represents a single event to a watched resource. */ 4 | import io.circe.* 5 | import io.circe.generic.semiauto.* 6 | import io.circe.syntax.* 7 | 8 | sealed trait EventType 9 | 10 | object EventType { 11 | case object ADDED extends EventType 12 | case object DELETED extends EventType 13 | case object MODIFIED extends EventType 14 | case object ERROR extends EventType 15 | 16 | implicit val encodeEventType: Encoder[EventType] = { 17 | case ADDED => "ADDED".asJson 18 | case DELETED => "DELETED".asJson 19 | case MODIFIED => "MODIFIED".asJson 20 | case ERROR => "ERROR".asJson 21 | } 22 | 23 | implicit val decodeEventType: Decoder[EventType] = Decoder.decodeString.emap { 24 | case "ADDED" => Right(ADDED) 25 | case "DELETED" => Right(DELETED) 26 | case "MODIFIED" => Right(MODIFIED) 27 | case "ERROR" => Right(ERROR) 28 | } 29 | } 30 | 31 | case class WatchEvent[T]( 32 | `type`: EventType, 33 | /* Object is: 34 | * If Type is Added or Modified: the new state of the object. 35 | * If Type is Deleted: the state of the object immediately before deletion. 36 | * If Type is Error: *Status is recommended; other types may make sense depending on context. 37 | */ 38 | `object`: T 39 | ) 40 | 41 | object WatchEvent { 42 | implicit def encoder[T: Encoder]: Encoder.AsObject[WatchEvent[T]] = deriveEncoder[WatchEvent[T]] 43 | implicit def decoder[T: Decoder]: Decoder[WatchEvent[T]] = deriveDecoder[WatchEvent[T]] 44 | } 45 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationParse.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | package cache 3 | 4 | import cats.effect.Async 5 | import cats.syntax.all.* 6 | import io.circe.Codec 7 | import io.circe.generic.semiauto.deriveCodec 8 | import io.circe.parser.* 9 | import org.http4s.{AuthScheme, Credentials} 10 | import org.http4s.headers.Authorization 11 | 12 | import java.nio.charset.StandardCharsets 13 | import java.time.Instant 14 | import java.util.Base64 15 | import scala.util.Try 16 | 17 | private[util] case class JwtPayload( 18 | exp: Option[Long] 19 | ) 20 | 21 | object AuthorizationParse { 22 | 23 | implicit private val jwtPayloadCodec: Codec[JwtPayload] = deriveCodec 24 | 25 | private val base64 = Base64.getDecoder 26 | 27 | def apply[F[_]](retrieve: F[Authorization])(implicit F: Async[F]): F[AuthorizationWithExpiration] = 28 | retrieve.map { token => 29 | val expirationTimestamp = 30 | token match { 31 | case Authorization(Credentials.Token(AuthScheme.Bearer, token)) => 32 | token.split('.') match { 33 | case Array(_, payload, _) => 34 | Try(new String(base64.decode(payload), StandardCharsets.US_ASCII)).toOption 35 | .flatMap(payload => decode[JwtPayload](payload).toOption) 36 | .flatMap(_.exp) 37 | .map(Instant.ofEpochSecond) 38 | 39 | case _ => 40 | none 41 | } 42 | case _ => none 43 | } 44 | AuthorizationWithExpiration(expirationTimestamp = expirationTimestamp, authorization = token) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/Utils.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | import cats.effect.Temporal 4 | import cats.implicits.* 5 | import cats.{ApplicativeError, Defer} 6 | import org.typelevel.log4cats.Logger 7 | import scala.concurrent.duration.* 8 | 9 | object Utils { 10 | def retry[F[_], Result]( 11 | f: F[Result], 12 | initialDelay: FiniteDuration = 1.second, 13 | maxRetries: Int = 10, 14 | actionClue: Option[String] = None, 15 | firstRun: Boolean = true 16 | )(implicit 17 | temporal: Temporal[F], 18 | F: ApplicativeError[F, Throwable], 19 | D: Defer[F], 20 | log: Logger[F] 21 | ): F[Result] = 22 | f 23 | .flatTap { _ => 24 | F.whenA(!firstRun)(log.info(s"Succeeded after retrying${actionClue.map(c => s", action: $c").getOrElse("")}")) 25 | } 26 | .handleErrorWith { exception => 27 | val firstLine = exception.getMessage.takeWhile(_ != '\n') 28 | val message = 29 | if (firstLine.contains(".scala")) 30 | firstLine.split('/').lastOption.getOrElse(firstLine) 31 | else 32 | firstLine 33 | 34 | if (maxRetries > 0) 35 | log.info( 36 | s"$message. Retrying in $initialDelay${actionClue.map(c => s", action: $c").getOrElse("")}. Retries left: $maxRetries" 37 | ) *> 38 | temporal.sleep(initialDelay) *> 39 | D.defer(retry(f, initialDelay, maxRetries - 1, actionClue, firstRun = false)) 40 | else 41 | log.info( 42 | s"Giving up ${actionClue.map(c => s", action: $c").getOrElse("")}. No retries left" 43 | ) *> 44 | F.raiseError(exception) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NodesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.operation.* 7 | import io.k8s.api.core.v1.Node 8 | import munit.FunSuite 9 | import org.typelevel.log4cats.Logger 10 | import org.typelevel.log4cats.slf4j.Slf4jLogger 11 | 12 | class NodesApiTest extends FunSuite with ContextProvider with MinikubeClientProvider[IO] { 13 | implicit lazy val F: Async[IO] = IO.asyncForIO 14 | implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 15 | lazy val resourceName: String = classOf[Node].getSimpleName 16 | def api(implicit client: KubernetesClient[IO]): NodesApi[IO] = client.nodes 17 | 18 | def getChecked(resourceName: String)(implicit client: KubernetesClient[IO]): IO[Node] = 19 | for { 20 | resource <- api.get(resourceName) 21 | _ = assertEquals(resource.metadata.flatMap(_.name), Some(resourceName)) 22 | } yield resource 23 | 24 | test("lists nodes and gets details of first on the list") { 25 | usingMinikube { implicit client => 26 | for { 27 | headOption <- api.list().map(_.items.headOption) 28 | _ <- headOption.traverse(h => getChecked(h.metadata.flatMap(_.name).get)) 29 | } yield () 30 | } 31 | } 32 | 33 | test("cannot get non existing resource") { 34 | usingMinikube { implicit client => 35 | for { 36 | resourceAttempt <- getChecked("non-existing").attempt 37 | _ = assert(resourceAttempt.isLeft) 38 | } yield () 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/package.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | import cats.effect.Resource 4 | import cats.syntax.all.* 5 | import cats.{Applicative, FlatMap} 6 | import org.http4s.client.Client 7 | import org.http4s.headers.Authorization 8 | import org.http4s.jdkhttpclient.WSRequest 9 | import org.http4s.{EntityDecoder, Request, Response} 10 | 11 | package object operation { 12 | implicit private[client] class KubernetesRequestOps[F[_]: Applicative](request: Request[F]) { 13 | def withOptionalAuthorization(authorization: Option[F[Authorization]]): F[Request[F]] = 14 | authorization.fold(request.pure[F]) { authorization => 15 | authorization.map { auth => 16 | request.putHeaders(auth) 17 | } 18 | } 19 | } 20 | 21 | implicit private[client] class KubernetesWsRequestOps[F[_]: Applicative](request: WSRequest) { 22 | def withOptionalAuthorization(authorization: Option[F[Authorization]]): F[WSRequest] = 23 | authorization.fold(request.pure[F]) { authorization => 24 | authorization.map { auth => 25 | request.copy(headers = request.headers.put(auth)) 26 | } 27 | } 28 | } 29 | 30 | implicit private[client] class HttpClientOps[F[_]: FlatMap](httpClient: Client[F]) { 31 | 32 | def runF( 33 | request: F[Request[F]] 34 | ): Resource[F, Response[F]] = 35 | Resource.eval(request).flatMap(httpClient.run) 36 | 37 | def expectOptionF[A](req: F[Request[F]])(implicit d: EntityDecoder[F, A]): F[Option[A]] = 38 | req.flatMap(httpClient.expectOption[A]) 39 | 40 | def expectF[A](req: F[Request[F]])(implicit d: EntityDecoder[F, A]): F[A] = 41 | req.flatMap(httpClient.expect[A]) 42 | 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/JobsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.batch.v1.{Job, JobList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class JobsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[JobList], 20 | val resourceDecoder: Decoder[Job], 21 | encoder: Encoder[Job] 22 | ) extends Listable[F, JobList] 23 | with Watchable[F, Job] { 24 | val resourceUri: Uri = uri"/apis" / "batch" / "v1" / "jobs" 25 | 26 | def namespace(namespace: String): NamespacedJobsApi[F] = 27 | new NamespacedJobsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedJobsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[Job], 38 | val resourceDecoder: Decoder[Job], 39 | val listDecoder: Decoder[JobList] 40 | ) extends Creatable[F, Job] 41 | with Replaceable[F, Job] 42 | with Gettable[F, Job] 43 | with Listable[F, JobList] 44 | with Deletable[F] 45 | with DeletableTerminated[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, Job] { 48 | val resourceUri: Uri = uri"/apis" / "batch" / "v1" / "namespaces" / namespace / "jobs" 49 | } 50 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Watchable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.Async 4 | import cats.syntax.either.* 5 | import com.goyeau.kubernetes.client.util.Uris.addLabels 6 | import com.goyeau.kubernetes.client.{KubeConfig, WatchEvent} 7 | import fs2.Stream 8 | import io.circe.jawn.CirceSupportParser 9 | import io.circe.{Decoder, Json} 10 | import org.typelevel.jawn.fs2.* 11 | import org.http4s.Method.* 12 | import org.http4s.* 13 | import org.http4s.client.Client 14 | import org.http4s.headers.Authorization 15 | import org.typelevel.jawn.Facade 16 | 17 | private[client] trait Watchable[F[_], Resource] { 18 | protected def httpClient: Client[F] 19 | implicit protected val F: Async[F] 20 | protected def config: KubeConfig[F] 21 | protected def authorization: Option[F[Authorization]] 22 | protected def resourceUri: Uri 23 | protected def watchResourceUri: Uri = resourceUri 24 | implicit protected def resourceDecoder: Decoder[Resource] 25 | 26 | implicit val parserFacade: Facade[Json] = new CirceSupportParser(None, false).facade 27 | 28 | def watch( 29 | labels: Map[String, String] = Map.empty, 30 | resourceVersion: Option[String] = None 31 | ): Stream[F, Either[String, WatchEvent[Resource]]] = { 32 | val uri = addLabels(labels, config.server.resolve(watchResourceUri)).+??("resourceVersion" -> resourceVersion) 33 | val req = Request[F](GET, uri.withQueryParam("watch", "1")) 34 | .withOptionalAuthorization(authorization) 35 | jsonStream(req).map(_.as[WatchEvent[Resource]].leftMap(_.getMessage)) 36 | } 37 | 38 | private def jsonStream(req: F[Request[F]]): Stream[F, Json] = 39 | Stream.eval(req).flatMap(httpClient.stream).flatMap(_.body.chunks.parseJsonStream) 40 | } 41 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/ServicesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.core.v1.{Service, ServiceList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class ServicesApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[ServiceList], 20 | val resourceDecoder: Decoder[Service], 21 | encoder: Encoder[Service] 22 | ) extends Listable[F, ServiceList] 23 | with Watchable[F, Service] { 24 | val resourceUri: Uri = uri"/api" / "v1" / "services" 25 | 26 | def namespace(namespace: String): NamespacedServicesApi[F] = 27 | new NamespacedServicesApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedServicesApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[Service], 38 | val resourceDecoder: Decoder[Service], 39 | val listDecoder: Decoder[ServiceList] 40 | ) extends Creatable[F, Service] 41 | with Replaceable[F, Service] 42 | with Gettable[F, Service] 43 | with Listable[F, ServiceList] 44 | with Proxy[F] 45 | with Deletable[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, Service] { 48 | val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "services" 49 | } 50 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/LeasesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.coordination.v1.* 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class LeasesApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[LeaseList], 20 | val resourceDecoder: Decoder[Lease], 21 | encoder: Encoder[Lease] 22 | ) extends Listable[F, LeaseList] 23 | with Watchable[F, Lease] { 24 | 25 | val resourceUri: Uri = uri"/apis" / "coordination.k8s.io" / "v1" / "leases" 26 | 27 | def namespace(namespace: String): NamespacedLeasesApi[F] = 28 | new NamespacedLeasesApi(httpClient, config, authorization, namespace) 29 | 30 | } 31 | 32 | private[client] class NamespacedLeasesApi[F[_]]( 33 | val httpClient: Client[F], 34 | val config: KubeConfig[F], 35 | val authorization: Option[F[Authorization]], 36 | namespace: String 37 | )(implicit 38 | val F: Async[F], 39 | val resourceEncoder: Encoder[Lease], 40 | val resourceDecoder: Decoder[Lease], 41 | val listDecoder: Decoder[LeaseList] 42 | ) extends Creatable[F, Lease] 43 | with Replaceable[F, Lease] 44 | with Gettable[F, Lease] 45 | with Listable[F, LeaseList] 46 | with Deletable[F] 47 | with DeletableTerminated[F] 48 | with GroupDeletable[F] 49 | with Watchable[F, Lease] { 50 | val resourceUri: Uri = uri"/apis" / "coordination.k8s.io" / "v1" / "namespaces" / namespace / "leases" 51 | } 52 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/ConfigMapsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.core.v1.{ConfigMap, ConfigMapList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class ConfigMapsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[ConfigMapList], 20 | val resourceDecoder: Decoder[ConfigMap], 21 | encoder: Encoder[ConfigMap] 22 | ) extends Listable[F, ConfigMapList] 23 | with Watchable[F, ConfigMap] { 24 | val resourceUri: Uri = uri"/api" / "v1" / "configmaps" 25 | 26 | def namespace(namespace: String): NamespacedConfigMapsApi[F] = 27 | new NamespacedConfigMapsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedConfigMapsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | val namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[ConfigMap], 38 | val resourceDecoder: Decoder[ConfigMap], 39 | val listDecoder: Decoder[ConfigMapList] 40 | ) extends Creatable[F, ConfigMap] 41 | with Replaceable[F, ConfigMap] 42 | with Gettable[F, ConfigMap] 43 | with Listable[F, ConfigMapList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, ConfigMap] { 47 | val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "configmaps" 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/IngressesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.networking.v1.{Ingress, IngressList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class IngressessApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[IngressList], 20 | val resourceDecoder: Decoder[Ingress], 21 | encoder: Encoder[Ingress] 22 | ) extends Listable[F, IngressList] 23 | with Watchable[F, Ingress] { 24 | val resourceUri: Uri = uri"/apis" / "networking.k8s.io" / "v1" / "ingresses" 25 | 26 | def namespace(namespace: String): NamespacedIngressesApi[F] = 27 | new NamespacedIngressesApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedIngressesApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[Ingress], 38 | val resourceDecoder: Decoder[Ingress], 39 | val listDecoder: Decoder[IngressList] 40 | ) extends Creatable[F, Ingress] 41 | with Replaceable[F, Ingress] 42 | with Gettable[F, Ingress] 43 | with Listable[F, IngressList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, Ingress] { 47 | val resourceUri: Uri = uri"/apis" / "networking.k8s.io" / "v1" / "namespaces" / namespace / "ingresses" 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/CronJobsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.batch.v1.{CronJob, CronJobList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class CronJobsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[CronJobList], 20 | val resourceDecoder: Decoder[CronJob], 21 | encoder: Encoder[CronJob] 22 | ) extends Listable[F, CronJobList] 23 | with Watchable[F, CronJob] { 24 | val resourceUri: Uri = uri"/apis" / "batch" / "v1" / "cronjobs" 25 | 26 | def namespace(namespace: String): NamespacedCronJobsApi[F] = 27 | new NamespacedCronJobsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedCronJobsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[CronJob], 38 | val resourceDecoder: Decoder[CronJob], 39 | val listDecoder: Decoder[CronJobList] 40 | ) extends Creatable[F, CronJob] 41 | with Replaceable[F, CronJob] 42 | with Gettable[F, CronJob] 43 | with Listable[F, CronJobList] 44 | with Deletable[F] 45 | with DeletableTerminated[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, CronJob] { 48 | val resourceUri: Uri = 49 | uri"/apis" / "batch" / "v1" / "namespaces" / namespace / "cronjobs" 50 | } 51 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/crd/RawExtension.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.crd 2 | 3 | import io.circe.syntax.* 4 | 5 | /** RawExtension is used to hold extensions in external versions. 6 | * 7 | * To use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your 8 | * internal struct. You also need to register your various plugin types. 9 | * 10 | * // Internal package: type MyAPIObject struct { runtime.TypeMeta `json:",inline"` MyPlugin runtime.Object 11 | * `json:"myPlugin"` } type PluginA struct { AOption string `json:"aOption"` } 12 | * 13 | * // External package: type MyAPIObject struct { runtime.TypeMeta `json:",inline"` MyPlugin runtime.RawExtension 14 | * `json:"myPlugin"` } type PluginA struct { AOption string `json:"aOption"` } 15 | * 16 | * // On the wire, the JSON will look something like this: { "kind":"MyAPIObject", "apiVersion":"v1", "myPlugin": { 17 | * "kind":"PluginA", "aOption":"foo", }, } 18 | * 19 | * So what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. 20 | * That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the 21 | * internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON 22 | * stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case 23 | * where the object is of an unknown type, a runtime.Unknown object will be created and stored.) 24 | */ 25 | import io.circe.{Decoder, Encoder} 26 | 27 | case class RawExtension(value: String) 28 | 29 | object RawExtension { 30 | implicit lazy val encoder: Encoder[RawExtension] = _.asJson 31 | implicit lazy val decoder: Decoder[RawExtension] = _.as[String].map(RawExtension(_)) 32 | } 33 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/ReplicaSetsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.apps.v1.{ReplicaSet, ReplicaSetList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class ReplicaSetsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[ReplicaSetList], 20 | val resourceDecoder: Decoder[ReplicaSet], 21 | encoder: Encoder[ReplicaSet] 22 | ) extends Listable[F, ReplicaSetList] 23 | with Watchable[F, ReplicaSet] { 24 | val resourceUri: Uri = uri"/apis" / "apps" / "v1" / "replicasets" 25 | 26 | def namespace(namespace: String): NamespacedReplicaSetsApi[F] = 27 | new NamespacedReplicaSetsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedReplicaSetsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[ReplicaSet], 38 | val resourceDecoder: Decoder[ReplicaSet], 39 | val listDecoder: Decoder[ReplicaSetList] 40 | ) extends Creatable[F, ReplicaSet] 41 | with Replaceable[F, ReplicaSet] 42 | with Gettable[F, ReplicaSet] 43 | with Listable[F, ReplicaSetList] 44 | with Deletable[F] 45 | with DeletableTerminated[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, ReplicaSet] { 48 | val resourceUri = uri"/apis" / "apps" / "v1" / "namespaces" / namespace / "replicasets" 49 | } 50 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/DeploymentsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.apps.v1.{Deployment, DeploymentList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class DeploymentsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[DeploymentList], 20 | val resourceDecoder: Decoder[Deployment], 21 | encoder: Encoder[Deployment] 22 | ) extends Listable[F, DeploymentList] 23 | with Watchable[F, Deployment] { 24 | val resourceUri: Uri = uri"/apis" / "apps" / "v1" / "deployments" 25 | 26 | def namespace(namespace: String): NamespacedDeploymentsApi[F] = 27 | new NamespacedDeploymentsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedDeploymentsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[Deployment], 38 | val resourceDecoder: Decoder[Deployment], 39 | val listDecoder: Decoder[DeploymentList] 40 | ) extends Creatable[F, Deployment] 41 | with Replaceable[F, Deployment] 42 | with Gettable[F, Deployment] 43 | with Listable[F, DeploymentList] 44 | with Deletable[F] 45 | with DeletableTerminated[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, Deployment] { 48 | val resourceUri: Uri = uri"/apis" / "apps" / "v1" / "namespaces" / namespace / "deployments" 49 | } 50 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/StatefulSetsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.apps.v1.{StatefulSet, StatefulSetList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class StatefulSetsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[StatefulSetList], 20 | val resourceDecoder: Decoder[StatefulSet], 21 | encoder: Encoder[StatefulSet] 22 | ) extends Listable[F, StatefulSetList] 23 | with Watchable[F, StatefulSet] { 24 | val resourceUri: Uri = uri"/apis" / "apps" / "v1" / "statefulsets" 25 | 26 | def namespace(namespace: String): NamespacedStatefulSetsApi[F] = 27 | new NamespacedStatefulSetsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedStatefulSetsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[StatefulSet], 38 | val resourceDecoder: Decoder[StatefulSet], 39 | val listDecoder: Decoder[StatefulSetList] 40 | ) extends Creatable[F, StatefulSet] 41 | with Replaceable[F, StatefulSet] 42 | with Gettable[F, StatefulSet] 43 | with Listable[F, StatefulSetList] 44 | with Deletable[F] 45 | with DeletableTerminated[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, StatefulSet] { 48 | val resourceUri: Uri = uri"/apis" / "apps" / "v1" / "namespaces" / namespace / "statefulsets" 49 | } 50 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/ServiceAccountsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.core.v1.{ServiceAccount, ServiceAccountList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class ServiceAccountsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[ServiceAccountList], 20 | val resourceDecoder: Decoder[ServiceAccount], 21 | encoder: Encoder[ServiceAccount] 22 | ) extends Listable[F, ServiceAccountList] 23 | with Watchable[F, ServiceAccount] { 24 | val resourceUri: Uri = uri"/api" / "v1" / "serviceaccounts" 25 | 26 | def namespace(namespace: String): NamespacedServiceAccountsApi[F] = 27 | new NamespacedServiceAccountsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedServiceAccountsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[ServiceAccount], 38 | val resourceDecoder: Decoder[ServiceAccount], 39 | val listDecoder: Decoder[ServiceAccountList] 40 | ) extends Creatable[F, ServiceAccount] 41 | with Replaceable[F, ServiceAccount] 42 | with Gettable[F, ServiceAccount] 43 | with Listable[F, ServiceAccountList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, ServiceAccount] { 47 | val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "serviceaccounts" 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | 10 | # Packages # 11 | ############ 12 | # it's better to unpack these files and commit the raw source 13 | # git has its own built in compression methods 14 | *.7z 15 | *.dmg 16 | *.gz 17 | *.iso 18 | #*.jar 19 | *.rar 20 | *.tar 21 | *.zip 22 | 23 | # Logs and databases # 24 | ###################### 25 | *.log 26 | *.data 27 | *.redo 28 | 29 | # OS generated files # 30 | ###################### 31 | .DS_Store* 32 | ehthumbs.db 33 | Icon? 34 | Thumbs.db 35 | 36 | # Editor Files # 37 | ################ 38 | *~ 39 | *.swp 40 | 41 | # Gradle Files # 42 | ################ 43 | .gradle 44 | 45 | # Build output directories # 46 | ############################ 47 | target 48 | /build 49 | /bin 50 | */bin 51 | /classes 52 | 53 | # IntelliJ specific files/directories # 54 | ####################################### 55 | out 56 | .idea 57 | !.idea/runConfigurations 58 | *.ipr 59 | *.iws 60 | *.iml 61 | atlassian-ide-plugin.xml 62 | 63 | # Eclipse specific files/directories # 64 | ###################################### 65 | .classpath 66 | .project 67 | .settings 68 | .metadata 69 | 70 | # NetBeans specific files/directories # 71 | ####################################### 72 | .nbattrs 73 | 74 | # newt & Docker 75 | Dockerfile 76 | 77 | # Byte-compiled / optimized / DLL files 78 | __pycache__/ 79 | *.py[cod] 80 | *$py.class 81 | 82 | # Distribution / packaging 83 | .Python 84 | build/ 85 | develop-eggs/ 86 | dist/ 87 | downloads/ 88 | eggs/ 89 | .eggs/ 90 | sdist/ 91 | wheels/ 92 | pip-wheel-metadata/ 93 | *.egg-info/ 94 | .installed.cfg 95 | *.egg 96 | MANIFEST 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | 105 | # Scala 106 | .bsp 107 | .metals 108 | .bloop 109 | .vscode 110 | metals.sbt 111 | build.properties 112 | .idea 113 | out 114 | .vscode 115 | .ammonite -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.unsafe.implicits.global 4 | import cats.effect.{Async, IO} 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.k8s.api.core.v1.* 7 | import munit.FunSuite 8 | import org.http4s.{Request, Status} 9 | import org.typelevel.log4cats.Logger 10 | import org.typelevel.log4cats.slf4j.Slf4jLogger 11 | 12 | import org.http4s.implicits.* 13 | 14 | class RawApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextProvider { 15 | 16 | implicit override lazy val F: Async[IO] = IO.asyncForIO 17 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 18 | 19 | // MinikubeClientProvider will create a namespace with this name, even though it's not used in this test 20 | override lazy val resourceName: String = "raw-api-tests" 21 | 22 | test("list nodes with raw requests") { 23 | kubernetesClient 24 | .use { implicit client => 25 | for { 26 | response <- client.raw 27 | .runRequest( 28 | Request[IO]( 29 | uri = uri"/api" / "v1" / "nodes" 30 | ) 31 | ) 32 | .use { response => 33 | response.bodyText.foldMonoid.compile.lastOrError.map { body => 34 | (response.status, body) 35 | } 36 | } 37 | (status, body) = response 38 | _ = assertEquals( 39 | status, 40 | Status.Ok, 41 | s"non 200 status for get nodes raw request" 42 | ) 43 | nodeList <- F.fromEither( 44 | io.circe.parser.decode[NodeList](body) 45 | ) 46 | _ = assert( 47 | nodeList.kind.contains("NodeList"), 48 | "wrong .kind in the response" 49 | ) 50 | _ = assert( 51 | nodeList.items.nonEmpty, 52 | "empty node list" 53 | ) 54 | } yield () 55 | } 56 | .unsafeRunSync() 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/PodDisruptionBudgetsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.policy.v1.{PodDisruptionBudget, PodDisruptionBudgetList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class PodDisruptionBudgetsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[PodDisruptionBudgetList], 20 | val resourceDecoder: Decoder[PodDisruptionBudget], 21 | encoder: Encoder[PodDisruptionBudget] 22 | ) extends Listable[F, PodDisruptionBudgetList] 23 | with Watchable[F, PodDisruptionBudget] { 24 | val resourceUri: Uri = uri"/apis" / "policy" / "v1beta1" / "poddisruptionbudgets" 25 | 26 | def namespace(namespace: String): NamespacedPodDisruptionBudgetApi[F] = 27 | new NamespacedPodDisruptionBudgetApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedPodDisruptionBudgetApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[PodDisruptionBudget], 38 | val resourceDecoder: Decoder[PodDisruptionBudget], 39 | val listDecoder: Decoder[PodDisruptionBudgetList] 40 | ) extends Creatable[F, PodDisruptionBudget] 41 | with Replaceable[F, PodDisruptionBudget] 42 | with Gettable[F, PodDisruptionBudget] 43 | with Listable[F, PodDisruptionBudgetList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, PodDisruptionBudget] { 47 | val resourceUri: Uri = uri"/apis" / "policy" / "v1beta1" / "namespaces" / namespace / "poddisruptionbudgets" 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/PersistentVolumeClaimsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.core.v1.{PersistentVolumeClaim, PersistentVolumeClaimList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class PersistentVolumeClaimsApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[PersistentVolumeClaimList], 20 | val resourceDecoder: Decoder[PersistentVolumeClaim], 21 | encoder: Encoder[PersistentVolumeClaim] 22 | ) extends Listable[F, PersistentVolumeClaimList] 23 | with Watchable[F, PersistentVolumeClaim] { 24 | val resourceUri: Uri = uri"/api" / "v1" / "persistentvolumeclaims" 25 | 26 | def namespace(namespace: String): NamespacedPersistentVolumeClaimsApi[F] = 27 | new NamespacedPersistentVolumeClaimsApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedPersistentVolumeClaimsApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[PersistentVolumeClaim], 38 | val resourceDecoder: Decoder[PersistentVolumeClaim], 39 | val listDecoder: Decoder[PersistentVolumeClaimList] 40 | ) extends Creatable[F, PersistentVolumeClaim] 41 | with Replaceable[F, PersistentVolumeClaim] 42 | with Gettable[F, PersistentVolumeClaim] 43 | with Listable[F, PersistentVolumeClaimList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, PersistentVolumeClaim] { 47 | val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "persistentvolumeclaims" 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/SecretsApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.core.v1.{Secret, SecretList} 8 | import org.http4s.client.Client 9 | import org.http4s.headers.Authorization 10 | import org.http4s.implicits.* 11 | import org.http4s.{Status, Uri} 12 | 13 | import java.util.Base64 14 | 15 | private[client] class SecretsApi[F[_]]( 16 | val httpClient: Client[F], 17 | val config: KubeConfig[F], 18 | val authorization: Option[F[Authorization]] 19 | )(implicit 20 | val F: Async[F], 21 | val listDecoder: Decoder[SecretList], 22 | val resourceDecoder: Decoder[Secret], 23 | encoder: Encoder[Secret] 24 | ) extends Listable[F, SecretList] 25 | with Watchable[F, Secret] { 26 | val resourceUri = uri"/api" / "v1" / "secrets" 27 | 28 | def namespace(namespace: String) = new NamespacedSecretsApi(httpClient, config, authorization, namespace) 29 | } 30 | 31 | private[client] class NamespacedSecretsApi[F[_]]( 32 | val httpClient: Client[F], 33 | val config: KubeConfig[F], 34 | val authorization: Option[F[Authorization]], 35 | namespace: String 36 | )(implicit 37 | val F: Async[F], 38 | val resourceEncoder: Encoder[Secret], 39 | val resourceDecoder: Decoder[Secret], 40 | val listDecoder: Decoder[SecretList] 41 | ) extends Creatable[F, Secret] 42 | with Replaceable[F, Secret] 43 | with Gettable[F, Secret] 44 | with Listable[F, SecretList] 45 | with Deletable[F] 46 | with GroupDeletable[F] 47 | with Watchable[F, Secret] { 48 | val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "secrets" 49 | 50 | def createEncode(resource: Secret): F[Status] = create(encode(resource)) 51 | 52 | def createOrUpdateEncode(resource: Secret): F[Status] = 53 | createOrUpdate(encode(resource)) 54 | 55 | private def encode(resource: Secret) = 56 | resource.copy(data = resource.data.map(_.map { case (k, v) => 57 | k -> Base64.getEncoder.encodeToString(v.getBytes) 58 | })) 59 | } 60 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/GettableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 7 | import munit.FunSuite 8 | import org.http4s.client.UnexpectedStatus 9 | import scala.language.reflectiveCalls 10 | 11 | trait GettableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] 12 | extends FunSuite 13 | with MinikubeClientProvider[F] { 14 | 15 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Gettable[F, Resource] 16 | def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] 17 | 18 | def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] = 19 | for { 20 | resource <- namespacedApi(namespaceName).get(resourceName) 21 | _ = assertEquals(resource.metadata.flatMap(_.namespace), Some(namespaceName)) 22 | _ = assertEquals(resource.metadata.flatMap(_.name), Some(resourceName)) 23 | } yield resource 24 | 25 | test(s"get a $resourceName") { 26 | usingMinikube { implicit client => 27 | for { 28 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 29 | resourceName <- Applicative[F].pure("some-resource-get") 30 | _ <- createChecked(namespaceName, resourceName) 31 | _ <- getChecked(namespaceName, resourceName) 32 | } yield () 33 | } 34 | } 35 | 36 | test("fail on non existing namespace") { 37 | intercept[UnexpectedStatus] { 38 | usingMinikube(implicit client => getChecked("non-existing", "non-existing")) 39 | } 40 | } 41 | 42 | test(s"fail on non existing $resourceName") { 43 | intercept[UnexpectedStatus] { 44 | usingMinikube { implicit client => 45 | for { 46 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 47 | _ <- getChecked(namespaceName, "non-existing") 48 | } yield () 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.circe.* 7 | import io.k8s.api.autoscaling.v1.{HorizontalPodAutoscaler, HorizontalPodAutoscalerList} 8 | import org.http4s.Uri 9 | import org.http4s.client.Client 10 | import org.http4s.headers.Authorization 11 | import org.http4s.implicits.* 12 | 13 | private[client] class HorizontalPodAutoscalersApi[F[_]]( 14 | val httpClient: Client[F], 15 | val config: KubeConfig[F], 16 | val authorization: Option[F[Authorization]] 17 | )(implicit 18 | val F: Async[F], 19 | val listDecoder: Decoder[HorizontalPodAutoscalerList], 20 | val resourceDecoder: Decoder[HorizontalPodAutoscaler], 21 | encoder: Encoder[HorizontalPodAutoscaler] 22 | ) extends Listable[F, HorizontalPodAutoscalerList] 23 | with Watchable[F, HorizontalPodAutoscaler] { 24 | val resourceUri: Uri = uri"/apis" / "autoscaling" / "v1" / "horizontalpodautoscalers" 25 | 26 | def namespace(namespace: String): NamespacedHorizontalPodAutoscalersApi[F] = 27 | new NamespacedHorizontalPodAutoscalersApi(httpClient, config, authorization, namespace) 28 | } 29 | 30 | private[client] class NamespacedHorizontalPodAutoscalersApi[F[_]]( 31 | val httpClient: Client[F], 32 | val config: KubeConfig[F], 33 | val authorization: Option[F[Authorization]], 34 | namespace: String 35 | )(implicit 36 | val F: Async[F], 37 | val resourceEncoder: Encoder[HorizontalPodAutoscaler], 38 | val resourceDecoder: Decoder[HorizontalPodAutoscaler], 39 | val listDecoder: Decoder[HorizontalPodAutoscalerList] 40 | ) extends Creatable[F, HorizontalPodAutoscaler] 41 | with Replaceable[F, HorizontalPodAutoscaler] 42 | with Gettable[F, HorizontalPodAutoscaler] 43 | with Listable[F, HorizontalPodAutoscalerList] 44 | with Deletable[F] 45 | with GroupDeletable[F] 46 | with Watchable[F, HorizontalPodAutoscaler] { 47 | val resourceUri: Uri = uri"/apis" / "autoscaling" / "v1" / "namespaces" / namespace / "horizontalpodautoscalers" 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServicesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.k8s.api.core.v1.* 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | import org.typelevel.log4cats.Logger 10 | import org.typelevel.log4cats.slf4j.Slf4jLogger 11 | 12 | class ServicesApiTest 13 | extends FunSuite 14 | with CreatableTests[IO, Service] 15 | with GettableTests[IO, Service] 16 | with ListableTests[IO, Service, ServiceList] 17 | with ReplaceableTests[IO, Service] 18 | with DeletableTests[IO, Service, ServiceList] 19 | with WatchableTests[IO, Service] 20 | with ContextProvider { 21 | 22 | implicit override lazy val F: Async[IO] = IO.asyncForIO 23 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 24 | override lazy val resourceName: String = classOf[Service].getSimpleName 25 | 26 | override def api(implicit client: KubernetesClient[IO]): ServicesApi[IO] = client.services 27 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedServicesApi[IO] = 28 | client.services.namespace(namespaceName) 29 | 30 | override def sampleResource(resourceName: String, labels: Map[String, String]): Service = Service( 31 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 32 | spec = Option(ServiceSpec(ports = Option(Seq(ServicePort(2000))))) 33 | ) 34 | 35 | private val labels = Option(Map("test" -> "updated-label")) 36 | override def modifyResource(resource: Service): Service = 37 | resource.copy(metadata = resource.metadata.map(_.copy(labels = labels))) 38 | 39 | override def checkUpdated(updatedResource: Service): Unit = 40 | assertEquals(updatedResource.metadata.flatMap(_.labels), labels) 41 | 42 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 43 | client.services.namespace(namespaceName) 44 | 45 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Service] = 46 | client.services.namespace(namespaceName) 47 | } 48 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ConfigMapsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.core.v1.{ConfigMap, ConfigMapList} 9 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 10 | import munit.FunSuite 11 | 12 | class ConfigMapsApiTest 13 | extends FunSuite 14 | with CreatableTests[IO, ConfigMap] 15 | with GettableTests[IO, ConfigMap] 16 | with ListableTests[IO, ConfigMap, ConfigMapList] 17 | with ReplaceableTests[IO, ConfigMap] 18 | with DeletableTests[IO, ConfigMap, ConfigMapList] 19 | with WatchableTests[IO, ConfigMap] 20 | with ContextProvider { 21 | 22 | implicit override lazy val F: Async[IO] = IO.asyncForIO 23 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 24 | override lazy val resourceName: String = classOf[ConfigMap].getSimpleName 25 | 26 | override def api(implicit client: KubernetesClient[IO]): ConfigMapsApi[IO] = client.configMaps 27 | override def namespacedApi(namespaceName: String)(implicit 28 | client: KubernetesClient[IO] 29 | ): NamespacedConfigMapsApi[IO] = 30 | client.configMaps.namespace(namespaceName) 31 | 32 | override def sampleResource(resourceName: String, labels: Map[String, String]): ConfigMap = ConfigMap( 33 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 34 | data = Option(Map("test" -> "data")) 35 | ) 36 | 37 | private val data = Option(Map("test" -> "updated-data")) 38 | override def modifyResource(resource: ConfigMap): ConfigMap = 39 | resource.copy(metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), data = data) 40 | 41 | override def checkUpdated(updatedResource: ConfigMap): Unit = assertEquals(updatedResource.data, data) 42 | 43 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 44 | client.configMaps.namespace(namespaceName) 45 | 46 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, ConfigMap] = 47 | client.configMaps.namespace(namespaceName) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | create: 5 | tags: 6 | - v* 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | 12 | jobs: 13 | checks: 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | - name: Pull all history with tags for correct versioning 19 | run: git fetch --prune --unshallow 20 | - name: Setup Java 11 21 | uses: actions/setup-java@v3 22 | with: 23 | java-version: 11.0.x 24 | distribution: zulu 25 | - name: Style checks 26 | run: ./mill __.checkStyle + __.docJar 27 | 28 | integration-kubernetes: 29 | runs-on: ubuntu-24.04 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | kubernetes-version: 34 | - v1.32.1 35 | - v1.31.5 36 | - v1.30.9 37 | - v1.29.13 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Setup Minikube and start Kubernetes 42 | uses: medyagh/setup-minikube@v0.0.19 43 | with: 44 | minikube-version: 1.35.0 45 | kubernetes-version: ${{ matrix.kubernetes-version }} 46 | - name: Setup Java 11 47 | uses: actions/setup-java@v3 48 | with: 49 | java-version: 11.0.x 50 | distribution: zulu 51 | - name: Test against Kubernetes ${{ matrix.kubernetes-version }} 52 | run: ./mill __[3.3.4].test 53 | 54 | publish: 55 | needs: 56 | - checks 57 | - integration-kubernetes 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Checkout code 61 | uses: actions/checkout@v3 62 | - name: Pull all history with tags for correct versioning 63 | run: git fetch --prune --unshallow 64 | - name: Setup Java 65 | uses: actions/setup-java@v3 66 | with: 67 | java-version: 11.0.x 68 | distribution: zulu 69 | - name: Publish 70 | if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'release' 71 | run: ./mill mill.scalalib.SonatypeCentralPublishModule/ 72 | env: 73 | MILL_PGP_SECRET_BASE64: ${{ secrets.PGP_SECRET_KEY }} 74 | MILL_SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 75 | MILL_SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 76 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTerminatedTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 7 | import munit.FunSuite 8 | import org.http4s.Status 9 | 10 | trait DeletableTerminatedTests[F[ 11 | _ 12 | ], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] 13 | extends FunSuite 14 | with MinikubeClientProvider[F] { 15 | 16 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): DeletableTerminated[F] 17 | def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] 18 | def listNotContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)( 19 | implicit client: KubernetesClient[F] 20 | ): F[ResourceList] 21 | def deleteTerminated(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = 22 | namespacedApi(namespaceName).deleteTerminated(resourceName) 23 | 24 | test(s"delete $resourceName and block until fully deleted") { 25 | usingMinikube { implicit client => 26 | for { 27 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 28 | resourceName <- Applicative[F].pure("delete-terminated-resource") 29 | _ <- deleteTerminated(namespaceName, resourceName) 30 | _ <- listNotContains(namespaceName, Set(resourceName)) 31 | } yield () 32 | } 33 | } 34 | 35 | test("fail on non existing namespace") { 36 | usingMinikube { implicit client => 37 | for { 38 | status <- deleteTerminated("non-existing", "non-existing") 39 | _ = assertEquals(status, Status.NotFound) 40 | } yield () 41 | } 42 | } 43 | 44 | test(s"fail on non existing $resourceName") { 45 | usingMinikube { implicit client => 46 | for { 47 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 48 | status <- deleteTerminated(namespaceName, "non-existing") 49 | // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound 50 | _ = assert(Set(Status.NotFound, Status.Ok).contains(status)) 51 | } yield () 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/MinikubeClientProvider.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.effect.* 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.Utils.retry 6 | import com.goyeau.kubernetes.client.api.NamespacesApiTest 7 | import com.goyeau.kubernetes.client.{KubeConfig, KubernetesClient} 8 | import fs2.io.file.Path 9 | import munit.Suite 10 | import org.typelevel.log4cats.Logger 11 | import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions 12 | 13 | trait MinikubeClientProvider[F[_]] { 14 | this: Suite => 15 | 16 | implicit def F: Async[F] 17 | implicit def logger: Logger[F] 18 | 19 | def unsafeRunSync[A](f: F[A]): A 20 | 21 | val kubernetesClient: Resource[F, KubernetesClient[F]] = { 22 | val kubeConfig = KubeConfig.fromFile[F]( 23 | Path(s"${System.getProperty("user.home")}/.kube/config"), 24 | sys.env.getOrElse("KUBE_CONTEXT_NAME", "minikube") 25 | ) 26 | KubernetesClient(kubeConfig) 27 | } 28 | 29 | def resourceName: String 30 | 31 | def defaultNamespace: String = resourceName.toLowerCase 32 | 33 | protected val extraNamespace = List.empty[String] 34 | 35 | protected def createNamespace(namespace: String): F[Unit] = kubernetesClient.use { implicit client => 36 | client.namespaces.deleteTerminated(namespace) *> retry( 37 | NamespacesApiTest.createChecked[F](namespace), 38 | actionClue = Some(s"Creating '$namespace' namespace") 39 | ) 40 | }.void 41 | 42 | private def deleteNamespace(namespace: String) = kubernetesClient.use { client => 43 | client.namespaces.delete( 44 | namespace, 45 | DeleteOptions(gracePeriodSeconds = 0L.some, propagationPolicy = "Foreground".some).some 46 | ) 47 | }.void 48 | 49 | protected def createNamespaces(): Unit = { 50 | val ns = defaultNamespace +: extraNamespace 51 | unsafeRunSync( 52 | logger.info(s"Creating namespaces: $ns") *> 53 | ns.traverse_(name => createNamespace(name)) 54 | ) 55 | } 56 | 57 | override def beforeAll(): Unit = 58 | createNamespaces() 59 | 60 | override def afterAll(): Unit = { 61 | val ns = defaultNamespace +: extraNamespace 62 | unsafeRunSync( 63 | logger.info(s"Deleting namespaces: $ns") *> 64 | ns.traverse_(name => deleteNamespace(name)) 65 | ) 66 | } 67 | 68 | def usingMinikube[T](body: KubernetesClient[F] => F[T]): T = 69 | unsafeRunSync(kubernetesClient.use(body)) 70 | } 71 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServiceAccountsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.core.v1.* 9 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 10 | import munit.FunSuite 11 | 12 | class ServiceAccountsApiTest 13 | extends FunSuite 14 | with CreatableTests[IO, ServiceAccount] 15 | with GettableTests[IO, ServiceAccount] 16 | with ListableTests[IO, ServiceAccount, ServiceAccountList] 17 | with ReplaceableTests[IO, ServiceAccount] 18 | with DeletableTests[IO, ServiceAccount, ServiceAccountList] 19 | with WatchableTests[IO, ServiceAccount] 20 | with ContextProvider { 21 | 22 | implicit override lazy val F: Async[IO] = IO.asyncForIO 23 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 24 | override lazy val resourceName: String = classOf[ServiceAccount].getSimpleName 25 | 26 | override def api(implicit client: KubernetesClient[IO]): ServiceAccountsApi[IO] = client.serviceAccounts 27 | override def namespacedApi(namespaceName: String)(implicit 28 | client: KubernetesClient[IO] 29 | ): NamespacedServiceAccountsApi[IO] = 30 | client.serviceAccounts.namespace(namespaceName) 31 | 32 | override def sampleResource(resourceName: String, labels: Map[String, String]): ServiceAccount = ServiceAccount( 33 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))) 34 | ) 35 | 36 | private val labels = Option(Map("test" -> "updated-label")) 37 | override def modifyResource(resource: ServiceAccount): ServiceAccount = 38 | resource.copy(metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name), labels = labels))) 39 | 40 | override def checkUpdated(updatedResource: ServiceAccount): Unit = 41 | assertEquals(updatedResource.metadata.flatMap(_.labels), labels) 42 | 43 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 44 | client.serviceAccounts.namespace(namespaceName) 45 | 46 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, ServiceAccount] = 47 | client.serviceAccounts.namespace(namespaceName) 48 | } 49 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/LeasesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import io.k8s.api.coordination.v1.* 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | import org.typelevel.log4cats.Logger 10 | import org.typelevel.log4cats.slf4j.Slf4jLogger 11 | 12 | class LeasesApiTest 13 | extends FunSuite 14 | with CreatableTests[IO, Lease] 15 | with GettableTests[IO, Lease] 16 | with ListableTests[IO, Lease, LeaseList] 17 | with ReplaceableTests[IO, Lease] 18 | with DeletableTests[IO, Lease, LeaseList] 19 | with WatchableTests[IO, Lease] 20 | with ContextProvider { 21 | 22 | implicit override lazy val F: Async[IO] = IO.asyncForIO 23 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 24 | override lazy val resourceName: String = classOf[Lease].getSimpleName 25 | 26 | override def api(implicit client: KubernetesClient[IO]): LeasesApi[IO] = client.leases 27 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedLeasesApi[IO] = 28 | client.leases.namespace(namespaceName) 29 | 30 | override def sampleResource(resourceName: String, labels: Map[String, String]): Lease = Lease( 31 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 32 | spec = Option(LeaseSpec(holderIdentity = Option("holder1"))) 33 | ) 34 | 35 | override def modifyResource(resource: Lease): Lease = 36 | resource.copy( 37 | metadata = resource.metadata.map(_.copy(name = resource.metadata.flatMap(_.name))), 38 | spec = Option(LeaseSpec(holderIdentity = Option("holder2"))) 39 | ) 40 | override def checkUpdated(updatedResource: Lease): Unit = 41 | assertEquals(updatedResource.spec.flatMap(_.holderIdentity), Option("holder2")) 42 | 43 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 44 | client.leases.namespace(namespaceName) 45 | 46 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Lease] = 47 | client.leases.namespace(namespaceName) 48 | 49 | // update of non existing lease doesn't fail with error, but creates new resource 50 | // override def munitTests(): Seq[Test] = super.munitTests().filterNot(_.name == s"fail on non existing $resourceName") 51 | } 52 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/ExecToken.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | package cache 3 | 4 | import cats.effect.Async 5 | import cats.syntax.all.* 6 | import com.goyeau.kubernetes.client.util.Yamls.* 7 | import fs2.io.IOException 8 | import io.circe.parser.* 9 | import org.http4s.AuthScheme 10 | import org.http4s.Credentials.Token 11 | import org.http4s.headers.Authorization 12 | import org.typelevel.log4cats.Logger 13 | 14 | import java.time.Instant 15 | import scala.sys.process.Process 16 | import scala.util.control.NonFatal 17 | 18 | private[client] object ExecToken { 19 | 20 | def apply[F[_]: Logger](exec: AuthInfoExec)(implicit F: Async[F]): F[AuthorizationWithExpiration] = 21 | F 22 | .blocking { 23 | val env = exec.env.getOrElse(Seq.empty).map(e => e.name -> e.value) 24 | val cmd = Seq.concat( 25 | Seq(exec.command), 26 | exec.args.getOrElse(Seq.empty) 27 | ) 28 | Process(cmd, None, env*).!! 29 | } 30 | .onError { case e: IOException => 31 | Logger[F].error( 32 | s"Failed to execute the credentials plugin: ${exec.command}: ${e.getMessage}.${exec.installHint 33 | .fold("")(hint => s"\n$hint")}" 34 | ) 35 | } 36 | .flatMap { output => 37 | F.fromEither( 38 | decode[ExecCredential](output) 39 | ) 40 | } 41 | .flatMap { execCredential => 42 | execCredential.status.token match { 43 | case Some(token) => 44 | F 45 | .delay(Instant.parse(execCredential.status.expirationTimestamp)) 46 | .adaptError { case NonFatal(error) => 47 | new IllegalArgumentException( 48 | s"Failed to parse `.status.expirationTimestamp`: ${execCredential.status.expirationTimestamp}: ${error.getMessage}", 49 | error 50 | ) 51 | } 52 | .map { expirationTimestamp => 53 | AuthorizationWithExpiration( 54 | expirationTimestamp = expirationTimestamp.some, 55 | authorization = Authorization(Token(AuthScheme.Bearer, token)) 56 | ) 57 | } 58 | case None => 59 | F.raiseError( 60 | new UnsupportedOperationException( 61 | "Missing `.status.token` in the credentials plugin output: client certificate/client key is not supported, token is required" 62 | ) 63 | ) 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/IngressesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.networking.v1.{Ingress, IngressList, IngressRule, IngressSpec} 9 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 10 | import munit.FunSuite 11 | 12 | class IngressesApiTest 13 | extends FunSuite 14 | with CreatableTests[IO, Ingress] 15 | with GettableTests[IO, Ingress] 16 | with ListableTests[IO, Ingress, IngressList] 17 | with ReplaceableTests[IO, Ingress] 18 | with DeletableTests[IO, Ingress, IngressList] 19 | with WatchableTests[IO, Ingress] 20 | with ContextProvider { 21 | 22 | implicit override lazy val F: Async[IO] = IO.asyncForIO 23 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 24 | override lazy val resourceName: String = classOf[Ingress].getSimpleName 25 | 26 | override def api(implicit client: KubernetesClient[IO]): IngressessApi[IO] = client.ingresses 27 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedIngressesApi[IO] = 28 | client.ingresses.namespace(namespaceName) 29 | 30 | override def sampleResource(resourceName: String, labels: Map[String, String]): Ingress = 31 | Ingress( 32 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 33 | spec = Option( 34 | IngressSpec( 35 | rules = Some(Seq(IngressRule(Some("host")))) 36 | ) 37 | ) 38 | ) 39 | 40 | private val updatedHost: Option[IngressSpec] = Option( 41 | IngressSpec( 42 | rules = Some(Seq(IngressRule(Some("host2")))) 43 | ) 44 | ) 45 | 46 | override def modifyResource(resource: Ingress): Ingress = 47 | resource.copy( 48 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 49 | spec = updatedHost 50 | ) 51 | override def checkUpdated(updatedResource: Ingress): Unit = 52 | assertEquals(updatedResource.spec, updatedHost) 53 | 54 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 55 | client.ingresses.namespace(namespaceName) 56 | 57 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Ingress] = 58 | client.ingresses.namespace(namespaceName) 59 | } 60 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourcesApi.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.Async 4 | import com.goyeau.kubernetes.client.KubeConfig 5 | import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} 6 | import com.goyeau.kubernetes.client.operation.* 7 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 8 | import io.circe.* 9 | import org.http4s.Method.* 10 | import org.http4s.client.Client 11 | import org.http4s.headers.Authorization 12 | import org.http4s.implicits.* 13 | import org.http4s.{Request, Status, Uri} 14 | 15 | private[client] class CustomResourcesApi[F[_], A, B]( 16 | val httpClient: Client[F], 17 | val config: KubeConfig[F], 18 | val authorization: Option[F[Authorization]], 19 | val context: CrdContext 20 | )(implicit 21 | val F: Async[F], 22 | val listDecoder: Decoder[CustomResourceList[A, B]], 23 | val resourceDecoder: Decoder[CustomResource[A, B]], 24 | encoder: Encoder[CustomResource[A, B]] 25 | ) extends Listable[F, CustomResourceList[A, B]] 26 | with Watchable[F, CustomResource[A, B]] { 27 | 28 | val resourceUri: Uri = uri"/apis" / context.group / context.version / context.plural 29 | 30 | def namespace(namespace: String): NamespacedCustomResourcesApi[F, A, B] = 31 | new NamespacedCustomResourcesApi(httpClient, config, authorization, context, namespace) 32 | } 33 | 34 | private[client] class NamespacedCustomResourcesApi[F[_], A, B]( 35 | val httpClient: Client[F], 36 | val config: KubeConfig[F], 37 | val authorization: Option[F[Authorization]], 38 | val context: CrdContext, 39 | namespace: String 40 | )(implicit 41 | val F: Async[F], 42 | val resourceEncoder: Encoder[CustomResource[A, B]], 43 | val resourceDecoder: Decoder[CustomResource[A, B]], 44 | val listDecoder: Decoder[CustomResourceList[A, B]] 45 | ) extends Creatable[F, CustomResource[A, B]] 46 | with Replaceable[F, CustomResource[A, B]] 47 | with Gettable[F, CustomResource[A, B]] 48 | with Listable[F, CustomResourceList[A, B]] 49 | with Deletable[F] 50 | with GroupDeletable[F] 51 | with Watchable[F, CustomResource[A, B]] { 52 | 53 | val resourceUri: Uri = uri"/apis" / context.group / context.version / "namespaces" / namespace / context.plural 54 | 55 | def updateStatus(name: String, resource: CustomResource[A, B]): F[Status] = 56 | httpClient.status( 57 | Request[F](PUT, config.server.resolve(resourceUri / name / "status")) 58 | .withEntity(resource) 59 | .withOptionalAuthorization(authorization) 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/JobsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.syntax.all.* 4 | import cats.effect.{Async, IO} 5 | import com.goyeau.kubernetes.client.operation.* 6 | import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} 7 | import org.typelevel.log4cats.Logger 8 | import org.typelevel.log4cats.slf4j.Slf4jLogger 9 | import io.k8s.api.batch.v1.{Job, JobList, JobSpec} 10 | import io.k8s.api.core.v1.* 11 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 12 | import munit.FunSuite 13 | 14 | class JobsApiTest 15 | extends FunSuite 16 | with CreatableTests[IO, Job] 17 | with GettableTests[IO, Job] 18 | with ListableTests[IO, Job, JobList] 19 | with ReplaceableTests[IO, Job] 20 | with DeletableTests[IO, Job, JobList] 21 | with DeletableTerminatedTests[IO, Job, JobList] 22 | with WatchableTests[IO, Job] 23 | with ContextProvider { 24 | 25 | implicit lazy val F: Async[IO] = IO.asyncForIO 26 | implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 27 | lazy val resourceName: String = classOf[Job].getSimpleName 28 | 29 | override def api(implicit client: KubernetesClient[IO]): JobsApi[IO] = client.jobs 30 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedJobsApi[IO] = 31 | client.jobs.namespace(namespaceName) 32 | 33 | override def sampleResource(resourceName: String, labels: Map[String, String]): Job = 34 | Job( 35 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 36 | spec = Option( 37 | JobSpec( 38 | template = PodTemplateSpec( 39 | metadata = Option(ObjectMeta(name = Option(resourceName))), 40 | spec = Option( 41 | TestPodSpec.alpine.copy(restartPolicy = "Never".some) 42 | ) 43 | ) 44 | ) 45 | ) 46 | ) 47 | val labels = Map("app" -> "test") 48 | override def modifyResource(resource: Job): Job = resource.copy( 49 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name), labels = Option(labels))) 50 | ) 51 | override def checkUpdated(updatedResource: Job): Unit = 52 | assert(labels.toSet.subsetOf(updatedResource.metadata.flatMap(_.labels).get.toSet)) 53 | 54 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 55 | client.jobs.namespace(namespaceName) 56 | 57 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Job] = 58 | client.jobs.namespace(namespaceName) 59 | } 60 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.Utils.* 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | import org.http4s.Status 10 | import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions 11 | 12 | trait DeletableTests[F[ 13 | _ 14 | ], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] 15 | extends FunSuite 16 | with MinikubeClientProvider[F] { 17 | 18 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Deletable[F] 19 | def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] 20 | def listNotContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)( 21 | implicit client: KubernetesClient[F] 22 | ): F[ResourceList] 23 | def delete(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = { 24 | val deleteOptions = DeleteOptions(gracePeriodSeconds = Some(0L)) 25 | namespacedApi(namespaceName).delete(resourceName, deleteOptions.some) 26 | } 27 | 28 | test(s"delete a $resourceName") { 29 | usingMinikube { implicit client => 30 | for { 31 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 32 | resourceName <- Applicative[F].pure("delete-resource") 33 | _ <- createChecked(namespaceName, resourceName) 34 | status <- delete(namespaceName, resourceName) 35 | // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound 36 | _ = assert(Set(Status.NotFound, Status.Ok).contains(status)) 37 | _ <- retry( 38 | listNotContains(namespaceName, Set(resourceName)), 39 | actionClue = Some(s"List not contains: $resourceName") 40 | ) 41 | } yield () 42 | } 43 | } 44 | 45 | test("fail on non existing namespace") { 46 | usingMinikube { implicit client => 47 | for { 48 | status <- delete("non-existing", "non-existing") 49 | _ = assertEquals(status, Status.NotFound) 50 | } yield () 51 | } 52 | } 53 | 54 | test(s"fail on non existing $resourceName") { 55 | usingMinikube { implicit client => 56 | for { 57 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 58 | status <- delete(namespaceName, "non-existing") 59 | // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound 60 | _ = assert(Set(Status.NotFound, Status.Ok).contains(status)) 61 | } yield () 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationCache.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | package cache 3 | 4 | import cats.effect.Async 5 | import cats.syntax.all.* 6 | import org.http4s.headers.Authorization 7 | import org.typelevel.log4cats.Logger 8 | import scala.compat.java8.DurationConverters.* 9 | 10 | import scala.concurrent.duration.* 11 | 12 | private[client] trait AuthorizationCache[F[_]] { 13 | 14 | def get: F[Authorization] 15 | 16 | } 17 | 18 | object AuthorizationCache { 19 | 20 | def apply[F[_]: Logger]( 21 | retrieve: F[AuthorizationWithExpiration], 22 | refreshBeforeExpiration: FiniteDuration = 0.seconds 23 | )(implicit F: Async[F]): F[AuthorizationCache[F]] = 24 | F.ref(Option.empty[AuthorizationWithExpiration]).map { cache => 25 | new AuthorizationCache[F] { 26 | 27 | override def get: F[Authorization] = { 28 | val getAndCacheToken: F[Option[AuthorizationWithExpiration]] = 29 | retrieve.attempt 30 | .flatMap { 31 | case Right(token) => 32 | cache.set(token.some).as(token.some) 33 | case Left(error) => 34 | Logger[F].warn(s"failed to retrieve the authorization token: ${error.getMessage}").as(none) 35 | } 36 | 37 | cache.get 38 | .flatMap { 39 | case Some(cached) => 40 | F.realTimeInstant 41 | .flatMap { now => 42 | val minExpiry = now.plus(refreshBeforeExpiration.toJava) 43 | val shouldRenew = cached.expirationTimestamp.exists(_.isBefore(minExpiry)) 44 | if (shouldRenew) 45 | getAndCacheToken.flatMap { 46 | case Some(token) => token.pure[F] 47 | case None => 48 | val expired = cached.expirationTimestamp.exists(_.isBefore(now)) 49 | Logger[F] 50 | .debug(s"using the cached token (expired: $expired)") >> 51 | F.raiseError[AuthorizationWithExpiration]( 52 | new IllegalStateException( 53 | s"failed to retrieve a new authorization token, cached token has expired" 54 | ) 55 | ) 56 | } 57 | else 58 | cached.pure[F] 59 | } 60 | case None => 61 | getAndCacheToken.flatMap[AuthorizationWithExpiration] { 62 | case Some(token) => token.pure[F] 63 | case None => F.raiseError(new IllegalStateException(s"no authorization token")) 64 | } 65 | } 66 | .map(_.authorization) 67 | } 68 | 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ReplicaSetsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.{Async, IO} 4 | import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.apps.v1.* 9 | import io.k8s.api.core.v1.* 10 | import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} 11 | import munit.FunSuite 12 | 13 | class ReplicaSetsApiTest 14 | extends FunSuite 15 | with CreatableTests[IO, ReplicaSet] 16 | with GettableTests[IO, ReplicaSet] 17 | with ListableTests[IO, ReplicaSet, ReplicaSetList] 18 | with ReplaceableTests[IO, ReplicaSet] 19 | with DeletableTests[IO, ReplicaSet, ReplicaSetList] 20 | with DeletableTerminatedTests[IO, ReplicaSet, ReplicaSetList] 21 | with WatchableTests[IO, ReplicaSet] 22 | with ContextProvider { 23 | 24 | implicit override lazy val F: Async[IO] = IO.asyncForIO 25 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 26 | override lazy val resourceName: String = classOf[ReplicaSet].getSimpleName 27 | 28 | override def api(implicit client: KubernetesClient[IO]): ReplicaSetsApi[IO] = client.replicaSets 29 | override def namespacedApi(namespaceName: String)(implicit 30 | client: KubernetesClient[IO] 31 | ): NamespacedReplicaSetsApi[IO] = 32 | client.replicaSets.namespace(namespaceName) 33 | 34 | override def sampleResource(resourceName: String, labels: Map[String, String]): ReplicaSet = { 35 | val label = Option(Map("app" -> "test")) 36 | ReplicaSet( 37 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 38 | spec = Option( 39 | ReplicaSetSpec( 40 | selector = LabelSelector(matchLabels = label), 41 | template = Option( 42 | PodTemplateSpec( 43 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = label)), 44 | spec = Option(TestPodSpec.alpine) 45 | ) 46 | ) 47 | ) 48 | ) 49 | ) 50 | } 51 | 52 | private val replicas = Option(5) 53 | override def modifyResource(resource: ReplicaSet): ReplicaSet = resource.copy( 54 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 55 | spec = resource.spec.map(_.copy(replicas = replicas)) 56 | ) 57 | override def checkUpdated(updatedResource: ReplicaSet): Unit = 58 | assertEquals(updatedResource.spec.flatMap(_.replicas), replicas) 59 | 60 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 61 | client.replicaSets.namespace(namespaceName) 62 | 63 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, ReplicaSet] = 64 | client.replicaSets.namespace(namespaceName) 65 | } 66 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CronJobsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.syntax.all.* 4 | import cats.effect.{Async, IO} 5 | import com.goyeau.kubernetes.client.operation.* 6 | import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} 7 | import org.typelevel.log4cats.Logger 8 | import org.typelevel.log4cats.slf4j.Slf4jLogger 9 | import io.k8s.api.batch.v1.{CronJob, CronJobList, CronJobSpec, JobSpec, JobTemplateSpec} 10 | import io.k8s.api.core.v1.* 11 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 12 | import munit.FunSuite 13 | 14 | class CronJobsApiTest 15 | extends FunSuite 16 | with CreatableTests[IO, CronJob] 17 | with GettableTests[IO, CronJob] 18 | with ListableTests[IO, CronJob, CronJobList] 19 | with ReplaceableTests[IO, CronJob] 20 | with DeletableTests[IO, CronJob, CronJobList] 21 | with DeletableTerminatedTests[IO, CronJob, CronJobList] 22 | with WatchableTests[IO, CronJob] 23 | with ContextProvider { 24 | 25 | implicit override lazy val F: Async[IO] = IO.asyncForIO 26 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 27 | override lazy val resourceName: String = classOf[CronJob].getSimpleName 28 | 29 | override def api(implicit client: KubernetesClient[IO]): CronJobsApi[IO] = client.cronJobs 30 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedCronJobsApi[IO] = 31 | client.cronJobs.namespace(namespaceName) 32 | 33 | override def sampleResource(resourceName: String, labels: Map[String, String]): CronJob = 34 | CronJob( 35 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 36 | spec = Option( 37 | CronJobSpec( 38 | schedule = "1 * * * *", 39 | jobTemplate = JobTemplateSpec( 40 | spec = Option( 41 | JobSpec( 42 | template = PodTemplateSpec( 43 | metadata = Option(ObjectMeta(name = Option(resourceName))), 44 | spec = TestPodSpec.alpine.copy(restartPolicy = "Never".some).some 45 | ) 46 | ) 47 | ) 48 | ) 49 | ) 50 | ) 51 | ) 52 | 53 | private val schedule = "2 * * * *" 54 | override def modifyResource(resource: CronJob): CronJob = resource.copy( 55 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 56 | spec = resource.spec.map(_.copy(schedule = schedule)) 57 | ) 58 | override def checkUpdated(updatedResource: CronJob): Unit = 59 | assertEquals(updatedResource.spec.map(_.schedule), Some(schedule)) 60 | 61 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 62 | client.cronJobs.namespace(namespaceName) 63 | 64 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, CronJob] = 65 | client.cronJobs.namespace(namespaceName) 66 | } 67 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.autoscaling.v1.{ 9 | CrossVersionObjectReference, 10 | HorizontalPodAutoscaler, 11 | HorizontalPodAutoscalerList, 12 | HorizontalPodAutoscalerSpec 13 | } 14 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 15 | import munit.FunSuite 16 | 17 | class HorizontalPodAutoscalersApiTest 18 | extends FunSuite 19 | with CreatableTests[IO, HorizontalPodAutoscaler] 20 | with GettableTests[IO, HorizontalPodAutoscaler] 21 | with ListableTests[IO, HorizontalPodAutoscaler, HorizontalPodAutoscalerList] 22 | with ReplaceableTests[IO, HorizontalPodAutoscaler] 23 | with DeletableTests[IO, HorizontalPodAutoscaler, HorizontalPodAutoscalerList] 24 | with WatchableTests[IO, HorizontalPodAutoscaler] 25 | with ContextProvider { 26 | 27 | implicit override lazy val F: Async[IO] = IO.asyncForIO 28 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 29 | override lazy val resourceName: String = classOf[HorizontalPodAutoscaler].getSimpleName 30 | 31 | override def api(implicit client: KubernetesClient[IO]): HorizontalPodAutoscalersApi[IO] = 32 | client.horizontalPodAutoscalers 33 | override def namespacedApi(namespaceName: String)(implicit 34 | client: KubernetesClient[IO] 35 | ): NamespacedHorizontalPodAutoscalersApi[IO] = 36 | client.horizontalPodAutoscalers.namespace(namespaceName) 37 | 38 | override def sampleResource(resourceName: String, labels: Map[String, String]): HorizontalPodAutoscaler = 39 | HorizontalPodAutoscaler( 40 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 41 | spec = Option( 42 | HorizontalPodAutoscalerSpec(scaleTargetRef = CrossVersionObjectReference("kind", "name"), maxReplicas = 2) 43 | ) 44 | ) 45 | 46 | val maxReplicas = 3 47 | override def modifyResource(resource: HorizontalPodAutoscaler): HorizontalPodAutoscaler = 48 | resource.copy( 49 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 50 | spec = resource.spec.map(_.copy(maxReplicas = maxReplicas)) 51 | ) 52 | 53 | override def checkUpdated(updatedResource: HorizontalPodAutoscaler): Unit = 54 | assertEquals(updatedResource.spec.get.maxReplicas, maxReplicas) 55 | 56 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 57 | client.horizontalPodAutoscalers.namespace(namespaceName) 58 | 59 | override def watchApi( 60 | namespaceName: String 61 | )(implicit client: KubernetesClient[IO]): Watchable[IO, HorizontalPodAutoscaler] = 62 | client.horizontalPodAutoscalers.namespace(namespaceName) 63 | } 64 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/DeploymentsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.{Async, IO} 4 | import com.goyeau.kubernetes.client.operation.* 5 | import com.goyeau.kubernetes.client.{IntValue, KubernetesClient, StringValue, TestPodSpec} 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.apps.v1.* 9 | import io.k8s.api.core.v1.* 10 | import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} 11 | import munit.FunSuite 12 | 13 | class DeploymentsApiTest 14 | extends FunSuite 15 | with CreatableTests[IO, Deployment] 16 | with GettableTests[IO, Deployment] 17 | with ListableTests[IO, Deployment, DeploymentList] 18 | with ReplaceableTests[IO, Deployment] 19 | with DeletableTests[IO, Deployment, DeploymentList] 20 | with DeletableTerminatedTests[IO, Deployment, DeploymentList] 21 | with WatchableTests[IO, Deployment] 22 | with ContextProvider { 23 | 24 | implicit override lazy val F: Async[IO] = IO.asyncForIO 25 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 26 | override lazy val resourceName: String = classOf[Deployment].getSimpleName 27 | 28 | override def api(implicit client: KubernetesClient[IO]): DeploymentsApi[IO] = client.deployments 29 | override def namespacedApi(namespaceName: String)(implicit 30 | client: KubernetesClient[IO] 31 | ): NamespacedDeploymentsApi[IO] = 32 | client.deployments.namespace(namespaceName) 33 | 34 | override def sampleResource(resourceName: String, labels: Map[String, String]): Deployment = { 35 | val label = Option(Map("app" -> "test")) 36 | Deployment( 37 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 38 | spec = Option( 39 | DeploymentSpec( 40 | selector = LabelSelector(matchLabels = label), 41 | template = PodTemplateSpec( 42 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = label)), 43 | spec = Option(TestPodSpec.alpine) 44 | ) 45 | ) 46 | ) 47 | ) 48 | } 49 | 50 | private val strategy = Option( 51 | DeploymentStrategy( 52 | `type` = Option("RollingUpdate"), 53 | rollingUpdate = 54 | Option(RollingUpdateDeployment(maxSurge = Option(StringValue("25%")), maxUnavailable = Option(IntValue(10)))) 55 | ) 56 | ) 57 | override def modifyResource(resource: Deployment): Deployment = resource.copy( 58 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 59 | spec = resource.spec.map(_.copy(strategy = strategy)) 60 | ) 61 | override def checkUpdated(updatedResource: Deployment): Unit = 62 | assertEquals(updatedResource.spec.flatMap(_.strategy), strategy) 63 | 64 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 65 | client.deployments.namespace(namespaceName) 66 | 67 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Deployment] = 68 | client.deployments.namespace(namespaceName) 69 | } 70 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/StatefulSetsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.{Async, IO} 4 | import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.apps.v1.* 9 | import io.k8s.api.core.v1.* 10 | import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} 11 | import munit.FunSuite 12 | 13 | class StatefulSetsApiTest 14 | extends FunSuite 15 | with CreatableTests[IO, StatefulSet] 16 | with GettableTests[IO, StatefulSet] 17 | with ListableTests[IO, StatefulSet, StatefulSetList] 18 | with ReplaceableTests[IO, StatefulSet] 19 | with DeletableTests[IO, StatefulSet, StatefulSetList] 20 | with DeletableTerminatedTests[IO, StatefulSet, StatefulSetList] 21 | with WatchableTests[IO, StatefulSet] 22 | with ContextProvider { 23 | 24 | implicit override lazy val F: Async[IO] = IO.asyncForIO 25 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 26 | override lazy val resourceName: String = classOf[StatefulSet].getSimpleName 27 | 28 | override def api(implicit client: KubernetesClient[IO]): StatefulSetsApi[IO] = client.statefulSets 29 | override def namespacedApi(namespaceName: String)(implicit 30 | client: KubernetesClient[IO] 31 | ): NamespacedStatefulSetsApi[IO] = 32 | client.statefulSets.namespace(namespaceName) 33 | 34 | override def sampleResource(resourceName: String, labels: Map[String, String]): StatefulSet = { 35 | val label = Option(Map("app" -> "test")) 36 | StatefulSet( 37 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 38 | spec = Option( 39 | StatefulSetSpec( 40 | serviceName = "service-name", 41 | selector = LabelSelector(matchLabels = label), 42 | template = PodTemplateSpec( 43 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = label)), 44 | spec = Option(TestPodSpec.alpine) 45 | ) 46 | ) 47 | ) 48 | ) 49 | } 50 | 51 | private val updateStrategy = Option( 52 | StatefulSetUpdateStrategy( 53 | `type` = Option("RollingUpdate"), 54 | rollingUpdate = Option(RollingUpdateStatefulSetStrategy(partition = Option(10))) 55 | ) 56 | ) 57 | override def modifyResource(resource: StatefulSet): StatefulSet = 58 | resource.copy( 59 | metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), 60 | spec = resource.spec.map(_.copy(updateStrategy = updateStrategy)) 61 | ) 62 | override def checkUpdated(updatedResource: StatefulSet): Unit = 63 | assertEquals(updatedResource.spec.flatMap(_.updateStrategy), updateStrategy) 64 | 65 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 66 | client.statefulSets.namespace(namespaceName) 67 | 68 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, StatefulSet] = 69 | client.statefulSets.namespace(namespaceName) 70 | } 71 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/operation/Creatable.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import scala.language.reflectiveCalls 4 | import cats.implicits.* 5 | import cats.effect.Async 6 | import com.goyeau.kubernetes.client.KubeConfig 7 | import com.goyeau.kubernetes.client.util.CirceEntityCodec.* 8 | import io.circe.* 9 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 10 | import org.http4s.* 11 | import org.http4s.client.Client 12 | import org.http4s.client.UnexpectedStatus 13 | import org.http4s.headers.{`Content-Type`, Authorization} 14 | import org.http4s.Method.* 15 | 16 | private[client] trait Creatable[F[_], Resource <: { def metadata: Option[ObjectMeta] }] { 17 | protected def httpClient: Client[F] 18 | implicit protected val F: Async[F] 19 | protected def config: KubeConfig[F] 20 | protected def resourceUri: Uri 21 | protected def authorization: Option[F[Authorization]] 22 | implicit protected def resourceEncoder: Encoder[Resource] 23 | implicit protected def resourceDecoder: Decoder[Resource] 24 | 25 | def create(resource: Resource): F[Status] = 26 | httpClient.status(buildRequest(resource)) 27 | 28 | def createWithResource(resource: Resource): F[Resource] = 29 | httpClient.expect[Resource](buildRequest(resource)) 30 | 31 | private def buildRequest(resource: Resource) = 32 | Request[F](POST, config.server.resolve(resourceUri)) 33 | .withEntity(resource) 34 | .withOptionalAuthorization(authorization) 35 | 36 | def createOrUpdate(resource: Resource): F[Status] = { 37 | val fullResourceUri = config.server.resolve(resourceUri) / resource.metadata.get.name.get 38 | def update = httpClient.status(buildRequest(resource, fullResourceUri)) 39 | 40 | httpClient 41 | .status(Request[F](GET, fullResourceUri).withOptionalAuthorization(authorization)) 42 | .flatMap { 43 | case status if status.isSuccess => update 44 | case Status.NotFound => 45 | create(resource).flatMap { 46 | case Status.Conflict => update 47 | case status => F.pure(status) 48 | } 49 | case status => F.pure(status) 50 | } 51 | } 52 | 53 | def createOrUpdateWithResource(resource: Resource): F[Resource] = { 54 | val fullResourceUri = config.server.resolve(resourceUri) / resource.metadata.get.name.get 55 | def updateWithResource = httpClient.expect[Resource](buildRequest(resource, fullResourceUri)) 56 | 57 | httpClient 58 | .expectOptionF[Resource]( 59 | Request[F](GET, fullResourceUri).withOptionalAuthorization(authorization) 60 | ) 61 | .flatMap { 62 | case Some(_) => updateWithResource 63 | case None => 64 | createWithResource(resource).recoverWith { 65 | case UnexpectedStatus(status, _, _) if status === Status.Conflict => updateWithResource 66 | } 67 | } 68 | } 69 | 70 | private def buildRequest(resource: Resource, fullResourceUri: Uri) = 71 | Request[F](PATCH, fullResourceUri) 72 | .withEntity(resource) 73 | .putHeaders(`Content-Type`(MediaType.application.`merge-patch+json`)) 74 | .withOptionalAuthorization(authorization) 75 | } 76 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/PersistentVolumeClaimsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.syntax.all.* 4 | import cats.effect.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.operation.* 7 | import io.k8s.api.core.v1.{ 8 | PersistentVolumeClaim, 9 | PersistentVolumeClaimList, 10 | PersistentVolumeClaimSpec, 11 | VolumeResourceRequirements 12 | } 13 | import io.k8s.apimachinery.pkg.api.resource.Quantity 14 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 15 | import munit.FunSuite 16 | import org.typelevel.log4cats.Logger 17 | import org.typelevel.log4cats.slf4j.Slf4jLogger 18 | 19 | class PersistentVolumeClaimsApiTest 20 | extends FunSuite 21 | with CreatableTests[IO, PersistentVolumeClaim] 22 | with GettableTests[IO, PersistentVolumeClaim] 23 | with ListableTests[IO, PersistentVolumeClaim, PersistentVolumeClaimList] 24 | with ReplaceableTests[IO, PersistentVolumeClaim] 25 | with DeletableTests[IO, PersistentVolumeClaim, PersistentVolumeClaimList] 26 | with WatchableTests[IO, PersistentVolumeClaim] 27 | with ContextProvider { 28 | 29 | implicit override lazy val F: Async[IO] = IO.asyncForIO 30 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 31 | override lazy val resourceName: String = classOf[PersistentVolumeClaim].getSimpleName 32 | 33 | override def api(implicit client: KubernetesClient[IO]): PersistentVolumeClaimsApi[IO] = client.persistentVolumeClaims 34 | override def namespacedApi(namespaceName: String)(implicit 35 | client: KubernetesClient[IO] 36 | ): NamespacedPersistentVolumeClaimsApi[IO] = 37 | client.persistentVolumeClaims.namespace(namespaceName) 38 | 39 | override def sampleResource(resourceName: String, labels: Map[String, String]): PersistentVolumeClaim = 40 | PersistentVolumeClaim( 41 | metadata = ObjectMeta(name = resourceName.some, labels = labels.some).some, 42 | spec = PersistentVolumeClaimSpec( 43 | accessModes = Seq("ReadWriteOnce").some, 44 | resources = VolumeResourceRequirements( 45 | requests = Map("storage" -> Quantity("1Mi")).some 46 | ).some, 47 | storageClassName = "local-path".some 48 | ).some 49 | ) 50 | 51 | override def modifyResource(resource: PersistentVolumeClaim): PersistentVolumeClaim = 52 | resource.copy( 53 | metadata = resource.metadata.map( 54 | _.copy( 55 | labels = resource.metadata.flatMap(_.labels).getOrElse(Map.empty).updated("key", "value").some 56 | ) 57 | ) 58 | ) 59 | 60 | override def checkUpdated(updatedResource: PersistentVolumeClaim): Unit = 61 | assertEquals(updatedResource.metadata.flatMap(_.labels).flatMap(_.get("key")), "value".some) 62 | 63 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 64 | client.persistentVolumeClaims.namespace(namespaceName) 65 | 66 | override def watchApi(namespaceName: String)(implicit 67 | client: KubernetesClient[IO] 68 | ): Watchable[IO, PersistentVolumeClaim] = 69 | client.persistentVolumeClaims.namespace(namespaceName) 70 | 71 | } 72 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ReplaceableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.Utils.retry 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | import org.http4s.Status 10 | 11 | trait ReplaceableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] 12 | extends FunSuite 13 | with MinikubeClientProvider[F] { 14 | 15 | def namespacedApi(namespaceName: String)(implicit 16 | client: KubernetesClient[F] 17 | ): Replaceable[F, Resource] 18 | def createChecked(namespaceName: String, resourceName: String)(implicit 19 | client: KubernetesClient[F] 20 | ): F[Resource] 21 | def getChecked(namespaceName: String, resourceName: String)(implicit 22 | client: KubernetesClient[F] 23 | ): F[Resource] 24 | def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): Resource 25 | def modifyResource(resource: Resource): Resource 26 | def checkUpdated(updatedResource: Resource): Unit 27 | 28 | def replace(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = 29 | for { 30 | resource <- getChecked(namespaceName, resourceName) 31 | status <- namespacedApi(namespaceName).replace(modifyResource(resource)) 32 | _ = assertEquals(status, Status.Ok) 33 | } yield () 34 | 35 | def replaceWithResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = 36 | for { 37 | resource <- getChecked(namespaceName, resourceName) 38 | replacedResource <- namespacedApi(namespaceName).replaceWithResource(modifyResource(resource)) 39 | } yield replacedResource 40 | 41 | test(s"replace a $resourceName") { 42 | usingMinikube { implicit client => 43 | for { 44 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 45 | resourceName <- Applicative[F].pure("some-resource") 46 | _ <- createChecked(namespaceName, resourceName) 47 | _ <- retry(replace(namespaceName, resourceName), actionClue = Some("Replacing resource")) 48 | replaced <- getChecked(namespaceName, resourceName) 49 | _ = checkUpdated(replaced) 50 | } yield () 51 | } 52 | } 53 | 54 | test(s"replace a $resourceName with resource") { 55 | usingMinikube { implicit client => 56 | for { 57 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 58 | resourceName <- Applicative[F].pure("some-with-resource") 59 | _ <- createChecked(namespaceName, resourceName) 60 | replacedResource <- retry( 61 | replaceWithResource(namespaceName, resourceName), 62 | actionClue = Some("Replacing resource with resource") 63 | ) 64 | _ = checkUpdated(replacedResource) 65 | retrievedResource <- getChecked(namespaceName, resourceName) 66 | _ = checkUpdated(retrievedResource) 67 | } yield () 68 | } 69 | } 70 | 71 | test("fail on non existing namespace") { 72 | usingMinikube { implicit client => 73 | for { 74 | status <- namespacedApi("non-existing").replace(sampleResource("non-existing")) 75 | _ = assertEquals(status, Status.NotFound) 76 | } yield () 77 | } 78 | } 79 | 80 | // Returns Created status since Kubernetes 1.23.x, earlier versions return NotFound 81 | test(s"fail on non existing $resourceName") { 82 | usingMinikube { implicit client => 83 | for { 84 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 85 | status <- namespacedApi(namespaceName).replace(sampleResource("non-existing")) 86 | _ = assert(Set(Status.NotFound, Status.Created).contains(status)) 87 | } yield () 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ListableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.api.NamespacesApiTest 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | 10 | import scala.language.{higherKinds, reflectiveCalls} 11 | 12 | trait ListableTests[F[ 13 | _ 14 | ], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] 15 | extends FunSuite 16 | with MinikubeClientProvider[F] { 17 | 18 | val resourceIsNamespaced = true 19 | val namespaceResourceNames = 20 | (0 to 1).map(i => (s"${resourceName.toLowerCase}-$i-list", s"list-all-${resourceName.toLowerCase}-$i")).toSet 21 | 22 | override protected val extraNamespace = namespaceResourceNames.map(_._1).toList 23 | 24 | def api(implicit client: KubernetesClient[F]): Listable[F, ResourceList] 25 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Listable[F, ResourceList] 26 | def createChecked(namespaceName: String, resourceName: String, labels: Map[String, String] = Map.empty)(implicit 27 | client: KubernetesClient[F] 28 | ): F[Resource] 29 | 30 | def listContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)(implicit 31 | client: KubernetesClient[F] 32 | ): F[ResourceList] = 33 | for { 34 | resourceList <- namespacedApi(namespaceName).list(labels) 35 | _ = assert(resourceNames.subsetOf(resourceList.items.flatMap(_.metadata.flatMap(_.name)).toSet)) 36 | } yield resourceList 37 | 38 | def listAllContains(resourceNames: Set[String])(implicit 39 | client: KubernetesClient[F] 40 | ): F[ResourceList] = 41 | for { 42 | resourceList <- api.list() 43 | _ = assert(resourceNames.subsetOf(resourceList.items.flatMap(_.metadata.flatMap(_.name)).toSet)) 44 | } yield resourceList 45 | 46 | def listNotContains( 47 | namespaceName: String, 48 | resourceNames: Set[String], 49 | labels: Map[String, String] = Map.empty 50 | )(implicit 51 | client: KubernetesClient[F] 52 | ): F[ResourceList] = 53 | for { 54 | resourceList <- namespacedApi(namespaceName).list(labels) 55 | names = resourceList.items.flatMap(_.metadata.flatMap(_.name)) 56 | _ = assert( 57 | names.forall(!resourceNames.contains(_)), 58 | s"Actual names: $names, not expected names: $resourceNames, in namespace: $namespaceName, with labels: $labels" 59 | ) 60 | } yield resourceList 61 | 62 | test(s"list ${resourceName}s") { 63 | usingMinikube { implicit client => 64 | for { 65 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 66 | resourceName <- Applicative[F].pure("list-resource") 67 | _ <- listNotContains(namespaceName, Set(resourceName)) 68 | _ <- createChecked(namespaceName, resourceName) 69 | _ <- listContains(namespaceName, Set(resourceName)) 70 | } yield () 71 | } 72 | } 73 | 74 | test(s"list ${resourceName}s with a label") { 75 | usingMinikube { implicit client => 76 | for { 77 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 78 | noLabelResourceName <- Applicative[F].pure("no-label-resource") 79 | _ <- createChecked(namespaceName, noLabelResourceName) 80 | withLabelResourceName <- Applicative[F].pure("label-resource") 81 | labels = Map("test" -> "1") 82 | _ <- createChecked(namespaceName, withLabelResourceName, labels) 83 | _ <- listNotContains(namespaceName, Set(noLabelResourceName), labels) 84 | _ <- listContains(namespaceName, Set(withLabelResourceName), labels) 85 | } yield () 86 | } 87 | } 88 | 89 | test(s"list ${resourceName}s in all namespaces") { 90 | usingMinikube { implicit client => 91 | assume(resourceIsNamespaced) 92 | for { 93 | _ <- namespaceResourceNames.toList.traverse { case (namespaceName, resourceName) => 94 | client.namespaces.deleteTerminated(namespaceName) *> NamespacesApiTest.createChecked[F]( 95 | namespaceName 96 | ) *> createChecked( 97 | namespaceName, 98 | resourceName 99 | ) 100 | } 101 | _ <- listAllContains(namespaceResourceNames.map(_._2)) 102 | _ <- namespaceResourceNames.toList.traverse { case (namespaceName, _) => 103 | client.namespaces.delete(namespaceName) 104 | } 105 | } yield () 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client 2 | 3 | import cats.syntax.all.* 4 | import cats.data.OptionT 5 | import cats.effect.* 6 | import com.goyeau.kubernetes.client.api.* 7 | import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} 8 | import com.goyeau.kubernetes.client.util.SslContexts 9 | import com.goyeau.kubernetes.client.util.cache.{AuthorizationParse, ExecToken} 10 | import io.circe.{Decoder, Encoder} 11 | import org.http4s.client.Client 12 | import org.http4s.headers.Authorization 13 | import org.http4s.jdkhttpclient.{JdkHttpClient, JdkWSClient, WSClient} 14 | import org.typelevel.log4cats.Logger 15 | 16 | import java.net.http.HttpClient 17 | 18 | class KubernetesClient[F[_]: Async: Logger]( 19 | httpClient: Client[F], 20 | wsClient: WSClient[F], 21 | config: KubeConfig[F], 22 | authorization: Option[F[Authorization]] 23 | ) { 24 | lazy val namespaces: NamespacesApi[F] = new NamespacesApi(httpClient, config, authorization) 25 | lazy val pods: PodsApi[F] = new PodsApi( 26 | httpClient, 27 | wsClient, 28 | config, 29 | authorization 30 | ) 31 | lazy val jobs: JobsApi[F] = new JobsApi(httpClient, config, authorization) 32 | lazy val cronJobs: CronJobsApi[F] = new CronJobsApi(httpClient, config, authorization) 33 | lazy val deployments: DeploymentsApi[F] = new DeploymentsApi(httpClient, config, authorization) 34 | lazy val statefulSets: StatefulSetsApi[F] = new StatefulSetsApi(httpClient, config, authorization) 35 | lazy val replicaSets: ReplicaSetsApi[F] = new ReplicaSetsApi(httpClient, config, authorization) 36 | lazy val services: ServicesApi[F] = new ServicesApi(httpClient, config, authorization) 37 | lazy val serviceAccounts: ServiceAccountsApi[F] = new ServiceAccountsApi(httpClient, config, authorization) 38 | lazy val configMaps: ConfigMapsApi[F] = new ConfigMapsApi(httpClient, config, authorization) 39 | lazy val secrets: SecretsApi[F] = new SecretsApi(httpClient, config, authorization) 40 | lazy val horizontalPodAutoscalers: HorizontalPodAutoscalersApi[F] = new HorizontalPodAutoscalersApi( 41 | httpClient, 42 | config, 43 | authorization 44 | ) 45 | lazy val podDisruptionBudgets: PodDisruptionBudgetsApi[F] = new PodDisruptionBudgetsApi( 46 | httpClient, 47 | config, 48 | authorization 49 | ) 50 | lazy val customResourceDefinitions: CustomResourceDefinitionsApi[F] = new CustomResourceDefinitionsApi( 51 | httpClient, 52 | config, 53 | authorization 54 | ) 55 | lazy val ingresses: IngressessApi[F] = new IngressessApi(httpClient, config, authorization) 56 | lazy val leases: LeasesApi[F] = new LeasesApi(httpClient, config, authorization) 57 | lazy val nodes: NodesApi[F] = new NodesApi(httpClient, config, authorization) 58 | lazy val persistentVolumeClaims: PersistentVolumeClaimsApi[F] = 59 | new PersistentVolumeClaimsApi(httpClient, config, authorization) 60 | lazy val raw: RawApi[F] = new RawApi[F](httpClient, wsClient, config, authorization) 61 | 62 | def customResources[A, B](context: CrdContext)(implicit 63 | listDecoder: Decoder[CustomResourceList[A, B]], 64 | encoder: Encoder[CustomResource[A, B]], 65 | decoder: Decoder[CustomResource[A, B]] 66 | ) = new CustomResourcesApi[F, A, B](httpClient, config, authorization, context) 67 | } 68 | 69 | object KubernetesClient { 70 | def apply[F[_]: Async: Logger](config: KubeConfig[F]): Resource[F, KubernetesClient[F]] = 71 | for { 72 | client <- Resource.eval { 73 | Sync[F].delay(HttpClient.newBuilder().sslContext(SslContexts.fromConfig(config)).build()) 74 | } 75 | httpClient <- JdkHttpClient[F](client) 76 | wsClient <- JdkWSClient[F](client) 77 | authorization <- Resource.eval { 78 | OptionT 79 | .fromOption(config.authorization) 80 | // if the authorization is provided directly, we try to parse it as a JWT 81 | // in order to get the expiration time 82 | .map(AuthorizationParse(_)) 83 | .orElse { 84 | OptionT 85 | .fromOption(config.authInfoExec) 86 | // if the authorization is provided via the auth plugin, we execute the plugin 87 | // and get the expiration time along the token itself from the output of the plugin 88 | .map(ExecToken(_)) 89 | } 90 | .semiflatMap { authorization => 91 | config.authorizationCache 92 | // if authorizationCache is provided, we "wrap" the authorization using it 93 | .mapApply(authorization) 94 | // otherwise, we use the authorization as is and ignore the expiration time 95 | .getOrElse( 96 | authorization.map(_.authorization).pure 97 | ) 98 | } 99 | .value 100 | } 101 | } yield new KubernetesClient( 102 | httpClient, 103 | wsClient, 104 | config, 105 | authorization 106 | ) 107 | 108 | def apply[F[_]: Async: Logger](config: F[KubeConfig[F]]): Resource[F, KubernetesClient[F]] = 109 | Resource.eval(config).flatMap(apply(_)) 110 | } 111 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/SslContexts.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | import java.io.{ByteArrayInputStream, File, FileInputStream, InputStreamReader} 3 | import java.security.cert.{CertificateFactory, X509Certificate} 4 | import java.security.{KeyStore, SecureRandom, Security} 5 | import java.util.Base64 6 | 7 | import com.goyeau.kubernetes.client.KubeConfig 8 | import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} 9 | import org.bouncycastle.jce.provider.BouncyCastleProvider 10 | import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter 11 | import org.bouncycastle.openssl.{PEMKeyPair, PEMParser} 12 | import scala.jdk.CollectionConverters.* 13 | 14 | object SslContexts { 15 | private val TrustStoreSystemProperty = "javax.net.ssl.trustStore" 16 | private val TrustStorePasswordSystemProperty = "javax.net.ssl.trustStorePassword" 17 | private val KeyStoreSystemProperty = "javax.net.ssl.keyStore" 18 | private val KeyStorePasswordSystemProperty = "javax.net.ssl.keyStorePassword" 19 | 20 | def fromConfig[F[_]](config: KubeConfig[F]): SSLContext = { 21 | val sslContext = SSLContext.getInstance("TLS") 22 | sslContext.init(keyManagers(config), trustManagers(config), new SecureRandom) 23 | sslContext 24 | } 25 | 26 | @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) 27 | private def keyManagers[F[_]](config: KubeConfig[F]) = { 28 | // Client certificate 29 | val certDataStream = config.clientCertData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) 30 | val certFileStream = config.clientCertFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) 31 | 32 | // Client key 33 | val keyDataStream = config.clientKeyData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) 34 | val keyFileStream = config.clientKeyFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) 35 | 36 | val _ = for { 37 | keyStream <- keyDataStream.orElse(keyFileStream) 38 | certStream <- certDataStream.orElse(certFileStream) 39 | } yield { 40 | Security.addProvider(new BouncyCastleProvider()) 41 | val pemKeyPair = 42 | new PEMParser(new InputStreamReader(keyStream)).readObject().asInstanceOf[PEMKeyPair] 43 | val privateKey = new JcaPEMKeyConverter().setProvider("BC").getPrivateKey(pemKeyPair.getPrivateKeyInfo) 44 | 45 | val certificateFactory = CertificateFactory.getInstance("X509") 46 | val certificate = certificateFactory.generateCertificate(certStream).asInstanceOf[X509Certificate] 47 | 48 | defaultKeyStore.setKeyEntry( 49 | certificate.getSubjectX500Principal.getName, 50 | privateKey, 51 | config.clientKeyPass.fold(Array.empty[Char])(_.toCharArray), 52 | Array(certificate) 53 | ) 54 | } 55 | 56 | val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) 57 | keyManagerFactory.init(defaultKeyStore, Array.empty) 58 | keyManagerFactory.getKeyManagers 59 | } 60 | 61 | private lazy val defaultKeyStore = { 62 | val propertyKeyStoreFile = 63 | Option(System.getProperty(KeyStoreSystemProperty, "")).filter(_.nonEmpty).map(new File(_)) 64 | 65 | val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) 66 | keyStore.load( 67 | propertyKeyStoreFile.map(new FileInputStream(_)).orNull, 68 | System.getProperty(KeyStorePasswordSystemProperty, "").toCharArray 69 | ) 70 | keyStore 71 | } 72 | 73 | private def trustManagers[F[_]](config: KubeConfig[F]) = { 74 | val certDataStream = config.caCertData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) 75 | val certFileStream = config.caCertFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) 76 | 77 | certDataStream.orElse(certFileStream).foreach { certStream => 78 | val certificateFactory = CertificateFactory.getInstance("X509") 79 | val certificates = certificateFactory.generateCertificates(certStream).asScala 80 | certificates 81 | .map(_.asInstanceOf[X509Certificate]) // scalafix:ok 82 | .zipWithIndex 83 | .foreach { case (certificate, i) => 84 | val alias = s"${certificate.getSubjectX500Principal.getName}-$i" 85 | defaultTrustStore.setCertificateEntry(alias, certificate) 86 | } 87 | } 88 | 89 | val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) 90 | trustManagerFactory.init(defaultTrustStore) 91 | trustManagerFactory.getTrustManagers 92 | } 93 | 94 | private lazy val defaultTrustStore = { 95 | val securityDirectory = s"${System.getProperty("java.home")}/lib/security" 96 | 97 | val propertyTrustStoreFile = 98 | Option(System.getProperty(TrustStoreSystemProperty, "")).filter(_.nonEmpty).map(new File(_)) 99 | val jssecacertsFile = Option(new File(s"$securityDirectory/jssecacerts")).filter(f => f.exists && f.isFile) 100 | val cacertsFile = new File(s"$securityDirectory/cacerts") 101 | 102 | val keyStore = KeyStore.getInstance(KeyStore.getDefaultType) 103 | keyStore.load( 104 | new FileInputStream(propertyTrustStoreFile.orElse(jssecacertsFile).getOrElse(cacertsFile)), 105 | System.getProperty(TrustStorePasswordSystemProperty, "changeit").toCharArray 106 | ) 107 | keyStore 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/SecretsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.{Async, IO} 4 | import com.goyeau.kubernetes.client.KubernetesClient 5 | import com.goyeau.kubernetes.client.operation.* 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import io.k8s.api.core.v1.{Secret, SecretList} 9 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 10 | import java.util.Base64 11 | import munit.FunSuite 12 | import org.http4s.Status 13 | import scala.collection.compat.* 14 | 15 | class SecretsApiTest 16 | extends FunSuite 17 | with CreatableTests[IO, Secret] 18 | with GettableTests[IO, Secret] 19 | with ListableTests[IO, Secret, SecretList] 20 | with ReplaceableTests[IO, Secret] 21 | with DeletableTests[IO, Secret, SecretList] 22 | with WatchableTests[IO, Secret] 23 | with ContextProvider { 24 | 25 | implicit override lazy val F: Async[IO] = IO.asyncForIO 26 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 27 | override lazy val resourceName: String = classOf[Secret].getSimpleName 28 | 29 | override def api(implicit client: KubernetesClient[IO]): SecretsApi[IO] = client.secrets 30 | override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): NamespacedSecretsApi[IO] = 31 | client.secrets.namespace(namespaceName) 32 | 33 | override def sampleResource(resourceName: String, labels: Map[String, String]): Secret = 34 | Secret( 35 | metadata = Option(ObjectMeta(name = Option(resourceName), labels = Option(labels))), 36 | data = Option(Map("test" -> "ZGF0YQ==")) 37 | ) 38 | 39 | private val data = Option(Map("test" -> "dXBkYXRlZC1kYXRh")) 40 | override def modifyResource(resource: Secret): Secret = 41 | resource.copy(metadata = Option(ObjectMeta(name = resource.metadata.flatMap(_.name))), data = data) 42 | override def checkUpdated(updatedResource: Secret): Unit = assertEquals(updatedResource.data, data) 43 | 44 | def createEncodeChecked(namespaceName: String, secretName: String)(implicit 45 | client: KubernetesClient[IO] 46 | ): IO[Secret] = 47 | for { 48 | _ <- NamespacesApiTest.createChecked[IO](namespaceName) 49 | data = Map("test" -> "data") 50 | status <- 51 | client.secrets 52 | .namespace(namespaceName) 53 | .createEncode( 54 | Secret( 55 | metadata = Option(ObjectMeta(name = Option(secretName))), 56 | data = Option(Map("test" -> "data")) 57 | ) 58 | ) 59 | _ = assertEquals(status, Status.Created) 60 | secret <- getChecked(namespaceName, secretName) 61 | _ = assertEquals(secret.data.get.values.head, Base64.getEncoder.encodeToString(data.values.head.getBytes)) 62 | } yield secret 63 | 64 | test("createEncode should create a secret") { 65 | usingMinikube { implicit client => 66 | val namespaceName = resourceName.toLowerCase + "-create-encode" 67 | createEncodeChecked(namespaceName, "some-secret").guarantee(client.namespaces.delete(namespaceName).void) 68 | } 69 | } 70 | 71 | test("createOrUpdateEncode should create a secret") { 72 | usingMinikube { implicit client => 73 | val namespaceName = resourceName.toLowerCase + "-create-update-encode" 74 | (for { 75 | _ <- NamespacesApiTest.createChecked(namespaceName) 76 | 77 | secretName = "some-secret" 78 | status <- 79 | client.secrets 80 | .namespace(namespaceName) 81 | .createOrUpdateEncode( 82 | Secret( 83 | metadata = Option(ObjectMeta(name = Option(secretName))), 84 | data = Option(Map("test" -> "data")) 85 | ) 86 | ) 87 | _ = assertEquals(status, Status.Created) 88 | _ <- getChecked(namespaceName, secretName) 89 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 90 | } 91 | } 92 | 93 | test("update a secret already created") { 94 | usingMinikube { implicit client => 95 | val namespaceName = resourceName.toLowerCase + "-update-encode" 96 | (for { 97 | secretName <- IO.pure("some-secret") 98 | secret <- createEncodeChecked(namespaceName, secretName) 99 | 100 | data = Option(Map("test" -> "updated-data")) 101 | status <- 102 | client.secrets 103 | .namespace(namespaceName) 104 | .createOrUpdateEncode( 105 | secret.copy( 106 | metadata = Option(ObjectMeta(name = secret.metadata.flatMap(_.name))), 107 | data = data 108 | ) 109 | ) 110 | _ = assertEquals(status, Status.Ok) 111 | updatedSecret <- getChecked(namespaceName, secretName) 112 | _ = assertEquals( 113 | updatedSecret.data, 114 | data.map( 115 | _.view.mapValues(v => Base64.getEncoder.encodeToString(v.getBytes)).toMap 116 | ) 117 | ) 118 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 119 | } 120 | } 121 | 122 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 123 | client.secrets.namespace(namespaceName) 124 | 125 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, Secret] = 126 | client.secrets.namespace(namespaceName) 127 | } 128 | -------------------------------------------------------------------------------- /kubernetes-client/src/com/goyeau/kubernetes/client/util/Yamls.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.util 2 | 3 | import cats.effect.Sync 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubeConfig 6 | import fs2.io.file.{Files, Path} 7 | import io.circe.generic.semiauto.* 8 | import io.circe.yaml.parser.* 9 | import io.circe.{Codec, Decoder, Encoder} 10 | import org.http4s.Uri 11 | import org.typelevel.log4cats.Logger 12 | 13 | case class Config( 14 | apiVersion: String, 15 | clusters: Seq[NamedCluster], 16 | contexts: Seq[NamedContext], 17 | `current-context`: String, 18 | users: Seq[NamedAuthInfo] 19 | ) 20 | 21 | case class NamedCluster(name: String, cluster: Cluster) 22 | case class Cluster( 23 | server: String, 24 | `certificate-authority`: Option[String] = None, 25 | `certificate-authority-data`: Option[String] = None 26 | ) 27 | 28 | case class NamedContext(name: String, context: Context) 29 | case class Context(cluster: String, user: String, namespace: Option[String] = None) 30 | 31 | case class NamedAuthInfo(name: String, user: AuthInfo) 32 | case class AuthInfo( 33 | `client-certificate`: Option[String] = None, 34 | `client-certificate-data`: Option[String] = None, 35 | `client-key`: Option[String] = None, 36 | `client-key-data`: Option[String] = None, 37 | exec: Option[AuthInfoExec] = None 38 | ) 39 | 40 | case class AuthInfoExecEnv( 41 | name: String, 42 | value: String 43 | ) 44 | 45 | case class AuthInfoExec( 46 | apiVersion: String, 47 | command: String, 48 | env: Option[Seq[AuthInfoExecEnv]], 49 | args: Option[Seq[String]], 50 | installHint: Option[String], 51 | provideClusterInfo: Option[Boolean], 52 | interactiveMode: Option[String] 53 | ) 54 | case class ExecCredential( 55 | kind: String, 56 | apiVersion: String, 57 | status: ExecCredentialStatus 58 | ) 59 | case class ExecCredentialStatus( 60 | expirationTimestamp: String, 61 | token: Option[String] 62 | ) 63 | 64 | private[client] object Yamls { 65 | 66 | def fromKubeConfigFile[F[_]: Sync: Logger: Files](kubeconfig: Path, contextMaybe: Option[String]): F[KubeConfig[F]] = 67 | for { 68 | configString <- Text.readFile(kubeconfig) 69 | configJson <- Sync[F].fromEither(parse(configString)) 70 | config <- Sync[F].fromEither(configJson.as[Config]) 71 | contextName = contextMaybe.getOrElse(config.`current-context`) 72 | namedContext <- 73 | config.contexts 74 | .find(_.name == contextName) 75 | .liftTo[F](new IllegalArgumentException(s"Can't find context named $contextName in $kubeconfig")) 76 | _ <- Logger[F].debug(s"KubeConfig with context ${namedContext.name}") 77 | context = namedContext.context 78 | 79 | namedCluster <- 80 | config.clusters 81 | .find(_.name == context.cluster) 82 | .liftTo[F](new IllegalArgumentException(s"Can't find cluster named ${context.cluster} in $kubeconfig")) 83 | cluster = namedCluster.cluster 84 | 85 | namedAuthInfo <- 86 | config.users 87 | .find(_.name == context.user) 88 | .liftTo[F](new IllegalArgumentException(s"Can't find user named ${context.user} in $kubeconfig")) 89 | user = namedAuthInfo.user 90 | 91 | server <- Sync[F].fromEither(Uri.fromString(cluster.server)) 92 | config <- KubeConfig.of[F]( 93 | server = server, 94 | caCertData = cluster.`certificate-authority-data`, 95 | caCertFile = cluster.`certificate-authority`.map(Path(_)), 96 | clientCertData = user.`client-certificate-data`, 97 | clientCertFile = user.`client-certificate`.map(Path(_)), 98 | clientKeyData = user.`client-key-data`, 99 | clientKeyFile = user.`client-key`.map(Path(_)), 100 | authInfoExec = user.exec 101 | ) 102 | } yield config 103 | 104 | implicit lazy val configDecoder: Decoder[Config] = deriveDecoder 105 | implicit lazy val configEncoder: Encoder.AsObject[Config] = deriveEncoder 106 | 107 | implicit lazy val clusterDecoder: Decoder[Cluster] = deriveDecoder 108 | implicit lazy val clusterEncoder: Encoder.AsObject[Cluster] = deriveEncoder 109 | implicit lazy val namedClusterDecoder: Decoder[NamedCluster] = deriveDecoder 110 | implicit lazy val namedClusterEncoder: Encoder.AsObject[NamedCluster] = deriveEncoder 111 | 112 | implicit lazy val contextDecoder: Decoder[Context] = deriveDecoder 113 | implicit lazy val contextEncoder: Encoder.AsObject[Context] = deriveEncoder 114 | implicit lazy val namedContextDecoder: Decoder[NamedContext] = deriveDecoder 115 | implicit lazy val namedContextEncoder: Encoder.AsObject[NamedContext] = deriveEncoder 116 | 117 | implicit lazy val authInfoDecoder: Decoder[AuthInfo] = deriveDecoder 118 | implicit lazy val authInfoEncoder: Encoder.AsObject[AuthInfo] = deriveEncoder 119 | implicit lazy val authInfoExecEnvCodec: Codec[AuthInfoExecEnv] = deriveCodec 120 | implicit lazy val authInfoExecCodec: Codec[AuthInfoExec] = deriveCodec 121 | implicit lazy val namedAuthInfoDecoder: Decoder[NamedAuthInfo] = deriveDecoder 122 | implicit lazy val namedAuthInfoEncoder: Encoder.AsObject[NamedAuthInfo] = deriveEncoder 123 | implicit lazy val execCredentialStatusCodec: Codec[ExecCredentialStatus] = deriveCodec 124 | implicit lazy val execCredentialCodec: Codec[ExecCredential] = deriveCodec 125 | } 126 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/CreatableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Applicative 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.Utils.retry 7 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 8 | import munit.FunSuite 9 | import org.http4s.Status 10 | 11 | trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] 12 | extends FunSuite 13 | with MinikubeClientProvider[F] { 14 | 15 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Creatable[F, Resource] 16 | def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] 17 | def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): Resource 18 | def modifyResource(resource: Resource): Resource 19 | def checkUpdated(updatedResource: Resource): Unit 20 | 21 | def createChecked(namespaceName: String, resourceName: String)(implicit 22 | client: KubernetesClient[F] 23 | ): F[Resource] = createChecked(namespaceName, resourceName, Map.empty) 24 | 25 | def createChecked(namespaceName: String, resourceName: String, labels: Map[String, String])(implicit 26 | client: KubernetesClient[F] 27 | ): F[Resource] = { 28 | val resource = sampleResource(resourceName, labels) 29 | for { 30 | status <- namespacedApi(namespaceName).create(resource) 31 | _ <- logger.info(s"Created '$resourceName' in $namespaceName namespace: $status") 32 | _ <- F.delay(assertEquals(status.isSuccess, true, s"$status should be successful")) 33 | resource <- retry( 34 | getChecked(namespaceName, resourceName), 35 | actionClue = Some(s"Getting after create '$resourceName' in $namespaceName namespace"), 36 | maxRetries = 2 37 | ) 38 | } yield resource 39 | } 40 | 41 | def createWithResourceChecked(namespaceName: String, resourceName: String, labels: Map[String, String] = Map.empty)( 42 | implicit client: KubernetesClient[F] 43 | ): F[Resource] = { 44 | val resource = sampleResource(resourceName, labels) 45 | for { 46 | _ <- namespacedApi(namespaceName).createWithResource(resource) 47 | retrievedResource <- getChecked(namespaceName, resourceName) 48 | } yield retrievedResource 49 | } 50 | 51 | test(s"create a $resourceName") { 52 | usingMinikube { implicit client => 53 | createChecked(resourceName.toLowerCase, "create-resource") 54 | } 55 | } 56 | 57 | test(s"create a $resourceName with resource") { 58 | usingMinikube { implicit client => 59 | createWithResourceChecked(resourceName.toLowerCase, "create-with-resource") 60 | } 61 | } 62 | 63 | test(s"create a $resourceName") { 64 | usingMinikube { implicit client => 65 | for { 66 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 67 | resourceName = "create-update-resource" 68 | status <- namespacedApi(namespaceName).createOrUpdate(sampleResource(resourceName)) 69 | _ = assert(Set(Status.Created, Status.Ok).contains(status), status.sanitizedReason) 70 | _ <- getChecked(namespaceName, resourceName) 71 | } yield () 72 | } 73 | } 74 | 75 | test(s"create a $resourceName with resource") { 76 | usingMinikube { implicit client => 77 | for { 78 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 79 | resourceName = "create-update-with-resource" 80 | _ <- namespacedApi(namespaceName).createOrUpdateWithResource(sampleResource(resourceName)) 81 | _ <- getChecked(namespaceName, resourceName) 82 | } yield () 83 | } 84 | } 85 | 86 | def createOrUpdate(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Unit] = 87 | for { 88 | resource <- getChecked(namespaceName, resourceName) 89 | status <- namespacedApi(namespaceName).createOrUpdate(modifyResource(resource)) 90 | _ = assertEquals(status, Status.Ok) 91 | } yield () 92 | 93 | test(s"update a $resourceName already created") { 94 | usingMinikube { implicit client => 95 | for { 96 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 97 | resourceName <- Applicative[F].pure("update-resource") 98 | _ <- createChecked(namespaceName, resourceName) 99 | _ <- retry(createOrUpdate(namespaceName, resourceName), actionClue = Some("Updating resource")) 100 | updatedResource <- getChecked(namespaceName, resourceName) 101 | _ = checkUpdated(updatedResource) 102 | } yield () 103 | } 104 | } 105 | 106 | def createOrUpdateWithResource(namespaceName: String, resourceName: String)(implicit 107 | client: KubernetesClient[F] 108 | ) = 109 | for { 110 | retrievedResource <- getChecked(namespaceName, resourceName) 111 | updatedResource <- namespacedApi(namespaceName).createOrUpdateWithResource(modifyResource(retrievedResource)) 112 | } yield updatedResource 113 | 114 | test(s"update a $resourceName already created with resource") { 115 | usingMinikube { implicit client => 116 | for { 117 | namespaceName <- Applicative[F].pure(resourceName.toLowerCase) 118 | resourceName <- Applicative[F].pure("update-with-resource") 119 | _ <- createChecked(namespaceName, resourceName) 120 | updatedResource <- retry(createOrUpdateWithResource(namespaceName, resourceName)) 121 | _ = checkUpdated(updatedResource) 122 | retrievedResource <- getChecked(namespaceName, resourceName) 123 | _ = checkUpdated(retrievedResource) 124 | } yield () 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourcesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.* 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest.* 7 | import com.goyeau.kubernetes.client.api.CustomResourcesApiTest.{CronTabResource, CronTabResourceList} 8 | import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} 9 | import com.goyeau.kubernetes.client.operation.* 10 | import org.typelevel.log4cats.Logger 11 | import org.typelevel.log4cats.slf4j.Slf4jLogger 12 | import io.circe.* 13 | import io.circe.generic.semiauto.* 14 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 15 | import munit.FunSuite 16 | import org.http4s.Status 17 | 18 | case class CronTab(cronSpec: String, image: String, replicas: Int) 19 | object CronTab { 20 | implicit lazy val encoder: Encoder.AsObject[CronTab] = deriveEncoder 21 | implicit lazy val decoder: Decoder[CronTab] = deriveDecoder 22 | } 23 | case class CronTabStatus(name: String) 24 | object CronTabStatus { 25 | implicit lazy val encoder: Encoder.AsObject[CronTabStatus] = deriveEncoder 26 | implicit lazy val decoder: Decoder[CronTabStatus] = deriveDecoder 27 | } 28 | 29 | object CustomResourcesApiTest { 30 | type CronTabResource = CustomResource[CronTab, CronTabStatus] 31 | type CronTabResourceList = CustomResourceList[CronTab, CronTabStatus] 32 | } 33 | 34 | class CustomResourcesApiTest 35 | extends FunSuite 36 | with CreatableTests[IO, CronTabResource] 37 | with GettableTests[IO, CronTabResource] 38 | with ListableTests[IO, CronTabResource, CronTabResourceList] 39 | with ReplaceableTests[IO, CronTabResource] 40 | with DeletableTests[IO, CronTabResource, CronTabResourceList] 41 | with WatchableTests[IO, CronTabResource] 42 | with ContextProvider { 43 | 44 | implicit override lazy val F: Async[IO] = IO.asyncForIO 45 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 46 | override lazy val resourceName: String = classOf[CronTab].getSimpleName 47 | val kind = classOf[CronTab].getSimpleName 48 | val context = CrdContext(group, "v1", plural(resourceName)) 49 | val cronSpec = "* * * * * *" 50 | val crLabels = Map("it-tests" -> "true") 51 | 52 | override def api(implicit client: KubernetesClient[IO]): CustomResourcesApi[IO, CronTab, CronTabStatus] = 53 | client.customResources[CronTab, CronTabStatus](context) 54 | 55 | override def namespacedApi( 56 | namespaceName: String 57 | )(implicit 58 | client: KubernetesClient[IO] 59 | ): NamespacedCustomResourcesApi[IO, CronTab, CronTabStatus] = 60 | client.customResources[CronTab, CronTabStatus](context).namespace(namespaceName) 61 | 62 | override def sampleResource(resourceName: String, labels: Map[String, String]): CronTabResource = 63 | CustomResource( 64 | s"$group/v1", 65 | kind, 66 | Some(ObjectMeta(name = Option(resourceName), labels = Option(labels ++ crLabels))), 67 | CronTab( 68 | "", 69 | "image", 70 | 1 71 | ), 72 | CronTabStatus("created").some 73 | ) 74 | 75 | override def modifyResource(resource: CronTabResource): CronTabResource = 76 | resource.copy(spec = resource.spec.copy(cronSpec = cronSpec)) 77 | override def checkUpdated(updatedResource: CronTabResource): Unit = 78 | assertEquals(updatedResource.spec.cronSpec, cronSpec) 79 | 80 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 81 | client.customResources[CronTab, CronTabStatus](context).namespace(namespaceName) 82 | 83 | override def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, CronTabResource] = 84 | client.customResources[CronTab, CronTabStatus](context).namespace(namespaceName) 85 | 86 | test("update custom resource status") { 87 | usingMinikube { implicit client => 88 | val name = s"${resourceName.toLowerCase}-status" 89 | val resource = sampleResource(name, Map.empty) 90 | val namespaceName = resourceName.toLowerCase 91 | 92 | for { 93 | _ <- CustomResourceDefinitionsApiTest.getChecked(resourceName) 94 | status <- namespacedApi(namespaceName).create(resource) 95 | _ = assertEquals(status, Status.Created, status.sanitizedReason) 96 | 97 | created <- getChecked(namespaceName, name) 98 | updateStatus <- namespacedApi(namespaceName).updateStatus( 99 | name, 100 | created.copy(status = CronTabStatus("updated").some) 101 | ) 102 | _ = assertEquals(updateStatus, Status.Ok, updateStatus.sanitizedReason) 103 | updated <- getChecked(namespaceName, name) 104 | _ = assertEquals(updated.status, CronTabStatus("updated").some) 105 | } yield () 106 | } 107 | } 108 | 109 | override def beforeAll(): Unit = { 110 | createNamespaces() 111 | 112 | usingMinikube(implicit client => 113 | client.customResourceDefinitions.deleteTerminated(resourceName) *> CustomResourceDefinitionsApiTest 114 | .getChecked( 115 | resourceName 116 | ) 117 | .recoverWith { case _ => 118 | logger.info(s"CRD '$resourceName' is not there, creating it.") *> 119 | CustomResourceDefinitionsApiTest 120 | .createChecked(resourceName, crLabels) 121 | } 122 | .void 123 | ) 124 | } 125 | 126 | override def afterAll(): Unit = { 127 | usingMinikube { implicit client => 128 | val namespaces = extraNamespace :+ defaultNamespace 129 | for { 130 | deleteStatus <- namespaces.traverse(ns => namespacedApi(ns).deleteAll(crLabels)) 131 | _ = deleteStatus.foreach(s => assertEquals(s, Status.Ok)) 132 | _ <- logger.info(s"CRDs with label $crLabels were deleted in $namespaces namespace(s).") 133 | } yield () 134 | } 135 | super.afterAll() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/cache/AuthorizationCacheTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.cache 2 | 3 | import cats.syntax.all.* 4 | import cats.effect.* 5 | import com.goyeau.kubernetes.client.util.cache.{AuthorizationCache, AuthorizationWithExpiration} 6 | import org.typelevel.log4cats.Logger 7 | import org.typelevel.log4cats.slf4j.Slf4jLogger 8 | import munit.FunSuite 9 | import org.http4s.{AuthScheme, Credentials} 10 | import org.http4s.headers.Authorization 11 | import cats.effect.unsafe.implicits.global 12 | import java.time.Instant 13 | import scala.concurrent.duration.* 14 | 15 | class AuthorizationCacheTest extends FunSuite { 16 | 17 | implicit lazy val F: Async[IO] = IO.asyncForIO 18 | implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 19 | 20 | private def mkAuthorization( 21 | expirationTimestamp: IO[Option[Instant]] = none.pure, 22 | token: IO[String] = s"test-token".pure 23 | ): IO[AuthorizationWithExpiration] = 24 | token.flatMap { token => 25 | expirationTimestamp.map { expirationTimestamp => 26 | AuthorizationWithExpiration( 27 | expirationTimestamp = expirationTimestamp, 28 | authorization = Authorization(Credentials.Token(AuthScheme.Bearer, token)) 29 | ) 30 | } 31 | } 32 | 33 | test(s"retrieve the token initially") { 34 | val io = for { 35 | auth <- mkAuthorization() 36 | cache <- AuthorizationCache[IO](retrieve = auth.pure) 37 | obtained <- cache.get 38 | } yield assertEquals(obtained, auth.authorization) 39 | io.unsafeRunSync() 40 | } 41 | 42 | test(s"fail when cannot retrieve the token initially") { 43 | val io = for { 44 | cache <- AuthorizationCache[IO](retrieve = IO.raiseError(new RuntimeException("test failure"))) 45 | obtained <- cache.get.attempt 46 | } yield assert(obtained.isLeft) 47 | io.unsafeRunSync() 48 | } 49 | 50 | test(s"retrieve the token once when no expiration") { 51 | val io = for { 52 | counter <- IO.ref(1) 53 | auth = mkAuthorization(token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i")) 54 | cache <- AuthorizationCache[IO](retrieve = auth) 55 | obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) 56 | } yield obtained.foreach { case (obtained, _) => 57 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-1"))) 58 | } 59 | io.unsafeRunSync() 60 | } 61 | 62 | test(s"retrieve the token when it's expired") { 63 | val io = for { 64 | counter <- IO.ref(1) 65 | auth = mkAuthorization( 66 | expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), 67 | token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") 68 | ) 69 | cache <- AuthorizationCache[IO](retrieve = auth) 70 | obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) 71 | } yield obtained.foreach { case (obtained, i) => 72 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) 73 | } 74 | io.unsafeRunSync() 75 | } 76 | 77 | test(s"retrieve the token when it's going to expire within refreshBeforeExpiration") { 78 | val io = for { 79 | counter <- IO.ref(1) 80 | auth = mkAuthorization( 81 | expirationTimestamp = IO.realTimeInstant.map(_.plusSeconds(40).some), 82 | token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") 83 | ) 84 | cache <- AuthorizationCache[IO](retrieve = auth, refreshBeforeExpiration = 1.minute) 85 | obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) 86 | } yield obtained.foreach { case (obtained, i) => 87 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) 88 | } 89 | io.unsafeRunSync() 90 | } 91 | 92 | test(s"fail if cannot retrieve the token when it's expired") { 93 | val io = for { 94 | counter <- IO.ref(1) 95 | shouldFail <- IO.ref(false) 96 | auth = mkAuthorization( 97 | expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), 98 | token = shouldFail.get.flatMap { shouldFail => 99 | if (shouldFail) 100 | IO.raiseError(new RuntimeException("test failure")) 101 | else 102 | counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") 103 | } 104 | ) 105 | cache <- AuthorizationCache[IO](retrieve = auth) 106 | obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) 107 | _ <- shouldFail.set(true) 108 | obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure)) 109 | } yield { 110 | obtained.foreach { case (obtained, i) => 111 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) 112 | } 113 | obtainedFailed.foreach { case (obtained, _) => 114 | println(obtained) 115 | assert(obtained.isLeft) 116 | } 117 | } 118 | io.unsafeRunSync() 119 | } 120 | 121 | test(s"fail if cannot retrieve the token when it's expired, then recover") { 122 | val io = for { 123 | counter <- IO.ref(1) 124 | shouldFail <- IO.ref(false) 125 | auth = mkAuthorization( 126 | expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), 127 | token = shouldFail.get.flatMap { shouldFail => 128 | if (shouldFail) 129 | IO.raiseError(new RuntimeException("test failure")) 130 | else 131 | counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") 132 | } 133 | ) 134 | cache <- AuthorizationCache[IO](retrieve = auth) 135 | obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) 136 | _ <- shouldFail.set(true) 137 | obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure)) 138 | _ <- shouldFail.set(false) 139 | obtainedAgain <- (6 to 10).toList.traverse(i => cache.get.attempt.product(i.pure)) 140 | } yield { 141 | obtained.foreach { case (obtained, i) => 142 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) 143 | } 144 | obtainedFailed.foreach { case (obtained, _) => 145 | println(obtained) 146 | assert(obtained.isLeft) 147 | } 148 | obtainedAgain.foreach { case (obtained, i) => 149 | assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i")).asRight) 150 | } 151 | } 152 | io.unsafeRunSync() 153 | } 154 | 155 | } 156 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NamespacesApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.effect.{Async, IO, Sync} 4 | import cats.implicits.* 5 | import com.goyeau.kubernetes.client.KubernetesClient 6 | import com.goyeau.kubernetes.client.Utils.* 7 | import com.goyeau.kubernetes.client.operation.MinikubeClientProvider 8 | import org.typelevel.log4cats.Logger 9 | import org.typelevel.log4cats.slf4j.Slf4jLogger 10 | import io.k8s.api.core.v1.{Namespace, NamespaceList} 11 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 12 | import org.http4s.Status 13 | import org.http4s.client.UnexpectedStatus 14 | import munit.Assertions.* 15 | import munit.FunSuite 16 | 17 | class NamespacesApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextProvider { 18 | import NamespacesApiTest.* 19 | 20 | implicit lazy val F: Async[IO] = IO.asyncForIO 21 | implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 22 | lazy val resourceName: String = classOf[Namespace].getSimpleName 23 | 24 | test("create a namespace") { 25 | usingMinikube { implicit client => 26 | val namespaceName = s"${resourceName.toLowerCase}-ns-create" 27 | createChecked[IO](namespaceName).guarantee(client.namespaces.delete(namespaceName).void) 28 | } 29 | } 30 | 31 | test("create a namespace") { 32 | usingMinikube { implicit client => 33 | val namespaceName = resourceName.toLowerCase + "-create-update" 34 | (for { 35 | status <- 36 | client.namespaces.createOrUpdate(Namespace(metadata = Option(ObjectMeta(name = Option(namespaceName))))) 37 | _ = assertEquals(status, Status.Created) 38 | _ <- getChecked(namespaceName) 39 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 40 | } 41 | } 42 | 43 | test("update a namespace already created") { 44 | usingMinikube { implicit client => 45 | val namespaceName = resourceName.toLowerCase + "-update" 46 | (for { 47 | namespace <- createChecked(namespaceName) 48 | 49 | labels = Option(Map("some-label" -> "some-value")) 50 | status <- client.namespaces.createOrUpdate( 51 | namespace.copy(metadata = namespace.metadata.map(_.copy(labels = labels, resourceVersion = None))) 52 | ) 53 | _ = assertEquals(status, Status.Ok) 54 | updatedNamespace <- getChecked(namespaceName) 55 | _ = assert(updatedNamespace.metadata.flatMap(_.labels).exists(l => labels.get.toSet.subsetOf(l.toSet))) 56 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 57 | } 58 | } 59 | 60 | test("list namespaces") { 61 | usingMinikube { implicit client => 62 | val namespaceName = resourceName.toLowerCase + "-list" 63 | (for { 64 | _ <- createChecked(namespaceName) 65 | _ <- listChecked(Seq(namespaceName)) 66 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 67 | } 68 | } 69 | 70 | test("get a namespace") { 71 | usingMinikube { implicit client => 72 | val namespaceName = resourceName.toLowerCase + "-get" 73 | (for { 74 | _ <- createChecked(namespaceName) 75 | _ <- getChecked(namespaceName) 76 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 77 | } 78 | } 79 | 80 | test("get a namespace fail on non existing namespace") { 81 | intercept[UnexpectedStatus] { 82 | usingMinikube(implicit client => getChecked[IO]("non-existing")) 83 | } 84 | } 85 | 86 | test("delete a namespace") { 87 | usingMinikube { implicit client => 88 | for { 89 | namespaceName <- IO.pure(resourceName.toLowerCase + "-delete") 90 | _ <- createChecked(namespaceName) 91 | _ <- client.namespaces.delete(namespaceName) 92 | _ <- retry( 93 | for { 94 | namespaces <- client.namespaces.list() 95 | _ = assert(!namespaces.items.flatMap(_.metadata).flatMap(_.name).contains(namespaceName)) 96 | } yield (), 97 | actionClue = Some(s"Namespace deletion: $namespaceName") 98 | ) 99 | } yield () 100 | } 101 | } 102 | 103 | test("delete a namespace should fail on non existing namespace") { 104 | usingMinikube { implicit client => 105 | for { 106 | status <- client.namespaces.delete("non-existing") 107 | _ = assertEquals(status, Status.NotFound) 108 | } yield () 109 | } 110 | } 111 | 112 | test("delete namespace and block until fully deleted") { 113 | usingMinikube { implicit client => 114 | for { 115 | namespaceName <- IO.pure(resourceName.toLowerCase + "-delete-terminated") 116 | _ <- createChecked(namespaceName) 117 | _ <- client.namespaces.deleteTerminated(namespaceName) 118 | namespaces <- client.namespaces.list() 119 | _ = assert(!namespaces.items.flatMap(_.metadata).flatMap(_.name).contains(namespaceName)) 120 | } yield () 121 | } 122 | } 123 | 124 | test("deleteTerminated a namespace should fail on non existing namespace") { 125 | usingMinikube { implicit client => 126 | for { 127 | status <- client.namespaces.deleteTerminated("non-existing") 128 | _ = assertEquals(status, Status.NotFound) 129 | } yield () 130 | } 131 | } 132 | 133 | test("replace a namespace") { 134 | usingMinikube { implicit client => 135 | val namespaceName = resourceName.toLowerCase + "-replace" 136 | (for { 137 | _ <- createChecked(namespaceName) 138 | labels = Option(Map("some-label" -> "some-value")) 139 | status <- client.namespaces.replace( 140 | Namespace(metadata = Option(ObjectMeta(name = Option(namespaceName), labels = labels))) 141 | ) 142 | _ = assertEquals(status, Status.Ok) 143 | replacedNamespace <- getChecked(namespaceName) 144 | _ = assert(replacedNamespace.metadata.flatMap(_.labels).exists(l => labels.get.toSet.subsetOf(l.toSet))) 145 | } yield ()).guarantee(client.namespaces.delete(namespaceName).void) 146 | } 147 | } 148 | 149 | test("replace a namespace should fail on non existing namespace") { 150 | usingMinikube { implicit client => 151 | for { 152 | status <- client.namespaces.replace(Namespace(metadata = Option(ObjectMeta(name = Option("non-existing"))))) 153 | _ = assertEquals(status, Status.NotFound) 154 | } yield () 155 | } 156 | } 157 | } 158 | 159 | object NamespacesApiTest { 160 | 161 | def createChecked[F[_]: Async: Logger]( 162 | namespaceName: String 163 | )(implicit client: KubernetesClient[F]): F[Namespace] = 164 | for { 165 | status <- client.namespaces.create(Namespace(metadata = Option(ObjectMeta(name = Option(namespaceName))))) 166 | _ = assertEquals(status, Status.Created, s"Namespace '$namespaceName' creation failed.") 167 | namespace <- retry(getChecked(namespaceName)) 168 | serviceAccountName = "default" 169 | _ <- retry( 170 | for { 171 | serviceAccount <- client.serviceAccounts.namespace(namespaceName).get(serviceAccountName) 172 | _ = assertEquals(serviceAccount.metadata.flatMap(_.name), Some(serviceAccountName)) 173 | } yield (), 174 | actionClue = Some(s"Namespace creation: $namespaceName") 175 | ) 176 | } yield namespace 177 | 178 | def listChecked[F[_]: Sync](namespaceNames: Seq[String])(implicit client: KubernetesClient[F]): F[NamespaceList] = 179 | for { 180 | namespaces <- client.namespaces.list() 181 | _ = assert(namespaceNames.toSet.subsetOf(namespaces.items.flatMap(_.metadata).flatMap(_.name).toSet)) 182 | } yield namespaces 183 | 184 | def getChecked[F[_]: Sync](namespaceName: String)(implicit client: KubernetesClient[F]): F[Namespace] = 185 | for { 186 | namespace <- client.namespaces.get(namespaceName) 187 | _ = assertEquals(namespace.metadata.flatMap(_.name), Some(namespaceName)) 188 | } yield namespace 189 | } 190 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApiTest.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.api 2 | 3 | import cats.Applicative 4 | import cats.effect.* 5 | import cats.implicits.* 6 | import com.goyeau.kubernetes.client.KubernetesClient 7 | import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest.* 8 | import com.goyeau.kubernetes.client.operation.* 9 | import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.* 10 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 11 | import munit.FunSuite 12 | import munit.Assertions.* 13 | import org.http4s.Status 14 | import org.typelevel.log4cats.Logger 15 | import org.typelevel.log4cats.slf4j.Slf4jLogger 16 | 17 | class CustomResourceDefinitionsApiTest 18 | extends FunSuite 19 | with CreatableTests[IO, CustomResourceDefinition] 20 | with GettableTests[IO, CustomResourceDefinition] 21 | with ListableTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] 22 | with ReplaceableTests[IO, CustomResourceDefinition] 23 | with DeletableTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] 24 | with DeletableTerminatedTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] 25 | with WatchableTests[IO, CustomResourceDefinition] 26 | with ContextProvider { 27 | 28 | implicit override lazy val F: Async[IO] = IO.asyncForIO 29 | implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 30 | override lazy val resourceName: String = classOf[CustomResourceDefinition].getSimpleName 31 | override val resourceIsNamespaced = false 32 | override val watchIsNamespaced: Boolean = resourceIsNamespaced 33 | 34 | override def api(implicit client: KubernetesClient[IO]): CustomResourceDefinitionsApi[IO] = 35 | client.customResourceDefinitions 36 | override def delete(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[Status] = 37 | namespacedApi(namespaceName).delete(crdName(resourceName)) 38 | override def deleteResource(namespaceName: String, resourceName: String)(implicit 39 | client: KubernetesClient[IO] 40 | ): IO[Status] = 41 | namespacedApi(namespaceName).delete(crdName(resourceName)) 42 | override def deleteTerminated(namespaceName: String, resourceName: String)(implicit 43 | client: KubernetesClient[IO] 44 | ): IO[Status] = 45 | namespacedApi(namespaceName).deleteTerminated(crdName(resourceName)) 46 | 47 | override def listContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String])(implicit 48 | client: KubernetesClient[IO] 49 | ): IO[CustomResourceDefinitionList] = CustomResourceDefinitionsApiTest.listContains(resourceNames) 50 | 51 | override def getChecked(namespaceName: String, resourceName: String)(implicit 52 | client: KubernetesClient[IO] 53 | ): IO[CustomResourceDefinition] = 54 | CustomResourceDefinitionsApiTest.getChecked(resourceName) 55 | 56 | def sampleResource(resourceName: String, labels: Map[String, String]): CustomResourceDefinition = 57 | CustomResourceDefinitionsApiTest.crd(resourceName, labels ++ CustomResourceDefinitionsApiTest.crdLabel) 58 | 59 | def modifyResource(resource: CustomResourceDefinition): CustomResourceDefinition = 60 | resource.copy(spec = resource.spec.copy(versions = Seq(versions.copy(served = false)))) 61 | 62 | def checkUpdated(updatedResource: CustomResourceDefinition): Unit = 63 | assertEquals(updatedResource.spec.versions.headOption, versions.copy(served = false).some) 64 | 65 | override def afterAll(): Unit = { 66 | usingMinikube { client => 67 | for { 68 | status <- client.customResourceDefinitions.deleteAll(crdLabel) 69 | _ = assertEquals(status, Status.Ok, status.sanitizedReason) 70 | _ <- logger.info(s"All CRD with label '$crdLabel' are deleted.") 71 | } yield () 72 | } 73 | super.afterAll() 74 | } 75 | 76 | override def namespacedApi(namespaceName: String)(implicit 77 | client: KubernetesClient[IO] 78 | ): CustomResourceDefinitionsApi[IO] = 79 | client.customResourceDefinitions 80 | 81 | override def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] = 82 | client.customResourceDefinitions 83 | 84 | override def watchApi( 85 | namespaceName: String 86 | )(implicit client: KubernetesClient[IO]): Watchable[IO, CustomResourceDefinition] = 87 | client.customResourceDefinitions 88 | } 89 | 90 | object CustomResourceDefinitionsApiTest { 91 | val versions: CustomResourceDefinitionVersion = 92 | CustomResourceDefinitionVersion( 93 | name = "v1", 94 | served = true, 95 | storage = true, 96 | schema = CustomResourceValidation( 97 | JSONSchemaProps( 98 | `type` = "object".some, 99 | properties = Map( 100 | "spec" -> JSONSchemaProps( 101 | `type` = "object".some, 102 | properties = Map( 103 | "cronSpec" -> JSONSchemaProps(`type` = "string".some), 104 | "image" -> JSONSchemaProps(`type` = "string".some), 105 | "replicas" -> JSONSchemaProps(`type` = "integer".some) 106 | ).some 107 | ), 108 | "status" -> JSONSchemaProps( 109 | `type` = "object".some, 110 | properties = Map( 111 | "name" -> JSONSchemaProps(`type` = "string".some) 112 | ).some 113 | ) 114 | ).some 115 | ).some 116 | ).some, 117 | subresources = CustomResourceSubresources(status = CustomResourceSubresourceStatus().some).some 118 | ) 119 | val crdLabel = Map("test" -> "kubernetes-client") 120 | val group = "kubernetes.client.goyeau.com" 121 | 122 | def plural(resourceName: String): String = s"${resourceName.toLowerCase}s" 123 | 124 | def crdName(resourceName: String): String = s"${plural(resourceName)}.$group" 125 | 126 | def crd(resourceName: String, labels: Map[String, String]): CustomResourceDefinition = CustomResourceDefinition( 127 | spec = CustomResourceDefinitionSpec( 128 | group = group, 129 | scope = "Namespaced", 130 | names = CustomResourceDefinitionNames( 131 | plural(resourceName), 132 | resourceName 133 | ), 134 | versions = Seq(versions) 135 | ), 136 | apiVersion = "apiextensions.k8s.io/v1".some, 137 | metadata = ObjectMeta( 138 | name = crdName(resourceName).some, 139 | labels = labels.some 140 | ).some 141 | ) 142 | 143 | def createChecked[F[_]: Async]( 144 | resourceName: String, 145 | labels: Map[String, String] 146 | )(implicit client: KubernetesClient[F]): F[CustomResourceDefinition] = 147 | for { 148 | status <- client.customResourceDefinitions.create(crd(resourceName, labels)) 149 | _ = assertEquals(status, Status.Created, status.sanitizedReason) 150 | crd <- getChecked(resourceName) 151 | _ <- Sync[F].delay(println(s"CRD '$resourceName' created, labels: $labels")) 152 | } yield crd 153 | 154 | def getChecked[F[_]: Async]( 155 | resourceName: String 156 | )(implicit client: KubernetesClient[F]): F[CustomResourceDefinition] = 157 | for { 158 | crdName <- Applicative[F].pure(crdName(resourceName)) 159 | resource <- client.customResourceDefinitions.get(crdName) 160 | _ = assertEquals(resource.metadata.flatMap(_.name), Some(crdName)) 161 | } yield resource 162 | 163 | def listContains(resourceNames: Set[String])(implicit 164 | client: KubernetesClient[IO] 165 | ): IO[CustomResourceDefinitionList] = 166 | for { 167 | resourceList <- client.customResourceDefinitions.list() 168 | _ = assert(resourceNames.map(crdName).subsetOf(resourceList.items.flatMap(_.metadata.flatMap(_.name)).toSet)) 169 | } yield resourceList 170 | 171 | def listNotContains(resourceNames: Set[String], labels: Map[String, String])(implicit 172 | client: KubernetesClient[IO] 173 | ): IO[CustomResourceDefinitionList] = 174 | for { 175 | resourceList <- client.customResourceDefinitions.list(labels) 176 | _ = assert(resourceList.items.flatMap(_.metadata.flatMap(_.name)).forall(!resourceNames.map(crdName).contains(_))) 177 | } yield resourceList 178 | } 179 | -------------------------------------------------------------------------------- /kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala: -------------------------------------------------------------------------------- 1 | package com.goyeau.kubernetes.client.operation 2 | 3 | import cats.Parallel 4 | import cats.effect.Ref 5 | import cats.implicits.* 6 | import com.goyeau.kubernetes.client.Utils.retry 7 | import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest 8 | import com.goyeau.kubernetes.client.{EventType, KubernetesClient, WatchEvent} 9 | import fs2.concurrent.SignallingRef 10 | import fs2.{Pipe, Stream} 11 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 12 | import munit.FunSuite 13 | import org.http4s.Status 14 | 15 | import scala.concurrent.duration.* 16 | import scala.language.reflectiveCalls 17 | import org.http4s.client.UnexpectedStatus 18 | 19 | trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] 20 | extends FunSuite 21 | with MinikubeClientProvider[F] { 22 | implicit def parallel: Parallel[F] 23 | 24 | val watchIsNamespaced = true 25 | 26 | override protected val extraNamespace = List("anothernamespace-" + defaultNamespace) 27 | 28 | def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Creatable[F, Resource] 29 | 30 | def sampleResource(resourceName: String, labels: Map[String, String]): Resource 31 | 32 | def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] 33 | 34 | def modifyResource(resource: Resource): Resource 35 | 36 | def deleteApi(namespaceName: String)(implicit client: KubernetesClient[F]): Deletable[F] 37 | 38 | def watchApi(namespaceName: String)(implicit client: KubernetesClient[F]): Watchable[F, Resource] 39 | 40 | def api(implicit client: KubernetesClient[F]): Watchable[F, Resource] 41 | 42 | def deleteResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = 43 | deleteApi(namespaceName).delete(resourceName) 44 | 45 | private def update(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = 46 | for { 47 | resource <- getChecked(namespaceName, resourceName) 48 | status <- createOrUpdate(namespaceName, modifyResource(resource)) 49 | _ = assertEquals(status, Status.Ok) 50 | } yield () 51 | 52 | private def createOrUpdate(namespaceName: String, resource: Resource)(implicit 53 | client: KubernetesClient[F] 54 | ): F[Status] = 55 | namespacedApi(namespaceName).createOrUpdate(resource) 56 | 57 | private def sendEvents(namespace: String, resourceName: String)(implicit client: KubernetesClient[F]) = 58 | for { 59 | _ <- retry( 60 | createIfMissing(namespace, resourceName), 61 | maxRetries = 30, 62 | actionClue = Some(s"Creating $resourceName in $namespace ns") 63 | ) 64 | _ <- retry(update(namespace, resourceName), actionClue = Some(s"Updating $resourceName")) 65 | status <- deleteResource(namespace, resourceName) 66 | _ = assertEquals(status, Status.Ok, status.sanitizedReason) 67 | } yield () 68 | 69 | private def createIfMissing(namespace: String, resourceName: String)(implicit client: KubernetesClient[F]) = 70 | getChecked(namespace, resourceName).as(()).recoverWith { 71 | case err: UnexpectedStatus if err.status == Status.NotFound => 72 | for { 73 | ns <- client.namespaces.get(namespace) 74 | _ <- logger.info( 75 | s"creating in namespace: ${ns.metadata.flatMap(_.name).getOrElse("n/a/")}, status: ${ns.status.flatMap(_.phase)}" 76 | ) 77 | status <- namespacedApi(namespace).create(sampleResource(resourceName, Map.empty)) 78 | _ = assertEquals(status.isSuccess, true, s"${status.sanitizedReason} should be success") 79 | } yield () 80 | } 81 | 82 | private def watchEvents( 83 | expected: Map[String, Set[EventType]], 84 | resourceName: String, 85 | watchingNamespace: Option[String], 86 | resourceVersion: Option[String] = None 87 | )(implicit 88 | client: KubernetesClient[F] 89 | ) = { 90 | def isExpectedResource(we: WatchEvent[Resource]): Boolean = 91 | we.`object`.metadata.exists(_.name.exists { name => 92 | name == resourceName || name == CustomResourceDefinitionsApiTest.crdName(resourceName) 93 | }) 94 | def processEvent( 95 | received: Ref[F, Map[String, Set[EventType]]], 96 | signal: SignallingRef[F, Boolean] 97 | ): Pipe[F, Either[String, WatchEvent[Resource]], Unit] = 98 | _.flatMap { 99 | case Right(we) if isExpectedResource(we) => 100 | Stream.eval { 101 | for { 102 | _ <- received.update(events => 103 | we.`object`.metadata.flatMap(_.namespace) match { 104 | case Some(namespace) => 105 | val updated = events.get(namespace) match { 106 | case Some(namespaceEvents) => namespaceEvents + we.`type` 107 | case _ => Set(we.`type`) 108 | } 109 | events.updated(namespace, updated) 110 | case _ => 111 | val crdNamespace = "customresourcedefinition" 112 | events.updated(crdNamespace, events.getOrElse(crdNamespace, Set.empty) + we.`type`) 113 | } 114 | ) 115 | allReceived <- received.get.map(_ == expected) 116 | _ <- F.whenA(allReceived)(signal.set(true)) 117 | } yield () 118 | } 119 | case _ => Stream.eval(F.unit) 120 | } 121 | 122 | val watchEvents = for { 123 | signal <- SignallingRef[F, Boolean](false) 124 | receivedEvents <- Ref.of(Map.empty[String, Set[EventType]]) 125 | watchStream = watchingNamespace 126 | .map(watchApi) 127 | .getOrElse(api) 128 | .watch(resourceVersion = resourceVersion) 129 | .through(processEvent(receivedEvents, signal)) 130 | .evalMap(_ => receivedEvents.get) 131 | .interruptWhen(signal) 132 | _ <- watchStream.interruptAfter(60.seconds).compile.drain 133 | events <- receivedEvents.get 134 | } yield events 135 | 136 | for { 137 | result <- watchEvents 138 | _ = assertEquals(result, expected) 139 | } yield () 140 | } 141 | 142 | private def sendToAnotherNamespace(name: String)(implicit client: KubernetesClient[F]) = 143 | F.whenA(watchIsNamespaced)( 144 | extraNamespace.map(sendEvents(_, name)).sequence 145 | ) 146 | 147 | test(s"watch $resourceName events in all namespaces") { 148 | usingMinikube { implicit client => 149 | val name = s"${resourceName.toLowerCase}-watch-all" 150 | val expectedEvents = Set[EventType](EventType.ADDED, EventType.MODIFIED, EventType.DELETED) 151 | val expected = 152 | if (watchIsNamespaced) 153 | (defaultNamespace +: extraNamespace).map(_ -> expectedEvents).toMap 154 | else 155 | Map(defaultNamespace -> expectedEvents) 156 | 157 | ( 158 | watchEvents(expected, name, None), 159 | F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) 160 | ).parTupled 161 | } 162 | } 163 | 164 | test(s"watch $resourceName events in the single namespace") { 165 | usingMinikube { implicit client => 166 | assume(watchIsNamespaced) 167 | val name = s"${resourceName.toLowerCase}-watch-single" 168 | val expected = Set[EventType](EventType.ADDED, EventType.MODIFIED, EventType.DELETED) 169 | 170 | ( 171 | watchEvents(Map(defaultNamespace -> expected), name, Some(defaultNamespace)), 172 | F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) 173 | ).parTupled 174 | } 175 | } 176 | 177 | test(s"watch $resourceName events from a given resourceVersion") { 178 | usingMinikube { implicit client => 179 | val name = s"${resourceName.toLowerCase}-watch-resource-version" 180 | val expected = Set[EventType](EventType.MODIFIED, EventType.DELETED) 181 | 182 | for { 183 | _ <- retry( 184 | createIfMissing(defaultNamespace, name), 185 | actionClue = Some(s"createIfMissing $defaultNamespace/$name") 186 | ) 187 | resource <- getChecked(defaultNamespace, name) 188 | resourceVersion = resource.metadata.flatMap(_.resourceVersion).get 189 | _ <- ( 190 | watchEvents(Map(defaultNamespace -> expected), name, Some(defaultNamespace), Some(resourceVersion)), 191 | F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) 192 | ).parTupled 193 | } yield () 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Client for Scala 2 | 3 | [![kubernetes-client Scala version support](https://index.scala-lang.org/joan38/kubernetes-client/kubernetes-client/latest-by-scala-version.svg)](https://index.scala-lang.org/joan38/kubernetes-client/kubernetes-client) 4 | 5 | A pure functional client for Kubernetes. 6 | 7 | ## Installation 8 | [Mill](https://www.lihaoyi.com/mill): 9 | ```scala 10 | ivy"com.goyeau::kubernetes-client:" 11 | ``` 12 | or 13 | 14 | [SBT](https://www.scala-sbt.org): 15 | ```scala 16 | "com.goyeau" %% "kubernetes-client" % "" 17 | ``` 18 | 19 | ## Usage 20 | 21 | ### Client configuration example 22 | 23 | #### Standard configuration "chain" 24 | 25 | ```scala 26 | import cats.effect.IO 27 | import com.goyeau.kubernetes.client.* 28 | import org.typelevel.log4cats.Logger 29 | import org.typelevel.log4cats.slf4j.Slf4jLogger 30 | 31 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 32 | 33 | val kubernetesClient = 34 | KubernetesClient[IO]( 35 | KubeConfig.standard[IO] 36 | ) 37 | ``` 38 | 39 | The `standard` configuration mimics the way `ClientBuilder.standard` from the official Java k8s client works: 40 | 41 | * if KUBECONFIG env variable is set, and the file exists - it will be used; the 'current-context' specified in the file 42 | will be used 43 | * otherwise, if ~/.kube/config file exists - it will be used; the 'current-context' specified in the file will be used 44 | * otherwise, if cluster configuration is found - use it 45 | 46 | Cluster configuration is defined by: 47 | 48 | - `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` certificate file 49 | - `/var/run/secrets/kubernetes.io/serviceaccount/token` token file 50 | - `KUBERNETES_SERVICE_HOST` env variable (https protocol is assumed) 51 | - `KUBERNETES_SERVICE_PORT` env variable 52 | 53 | #### Manually providing the configuration 54 | 55 | ```scala 56 | import cats.effect.IO 57 | import com.goyeau.kubernetes.client.* 58 | import org.typelevel.log4cats.Logger 59 | import org.typelevel.log4cats.slf4j.Slf4jLogger 60 | import java.io.File 61 | import org.http4s.AuthScheme 62 | import org.http4s.Credentials.Token 63 | import org.http4s.headers.Authorization 64 | import org.http4s.implicits.* 65 | import scala.concurrent.ExecutionContext 66 | import scala.io.Source 67 | 68 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 69 | 70 | val kubernetesClient = 71 | KubernetesClient[IO]( 72 | KubeConfig.of[IO]( 73 | server = uri"https://k8s.goyeau.com", 74 | authorization = Option(IO.pure(Authorization(Token(AuthScheme.Bearer, Source.fromFile("/var/run/secrets/kubernetes.io/serviceaccount/token").mkString)))), 75 | caCertFile = Option(new File("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")) 76 | ) 77 | ) 78 | ``` 79 | 80 | ```scala 81 | import cats.effect.IO 82 | import com.goyeau.kubernetes.client.* 83 | import org.typelevel.log4cats.Logger 84 | import org.typelevel.log4cats.slf4j.Slf4jLogger 85 | import java.io.File 86 | import scala.concurrent.ExecutionContext 87 | 88 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 89 | 90 | val kubernetesClient = 91 | KubernetesClient[IO](KubeConfig.fromFile[IO](new File(s"${System.getProperty("user.home")}/.kube/config"))) 92 | ``` 93 | 94 | #### Authorization caching 95 | 96 | It is possible (and recommended) to configure the kubernetes client to cache the authorization (and renew it, when/if it 97 | expires). 98 | 99 | ```scala 100 | import cats.effect.IO 101 | import com.goyeau.kubernetes.client.* 102 | import org.typelevel.log4cats.Logger 103 | import org.typelevel.log4cats.slf4j.Slf4jLogger 104 | import scala.concurrent.duration._ 105 | 106 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 107 | 108 | val kubernetesClient = 109 | KubernetesClient[IO]( 110 | KubeConfig.standard[IO].map(_.withDefaultAuthorizationCache(5.minutes)) 111 | ) 112 | ``` 113 | 114 | When authorization cache is configured, the client attempts to derive the expiration time of the token: 115 | 116 | * if it's a raw authorization header (provided directly, or from the token file inside the cluster), we attempt to 117 | decode it as a JWT and take the `exp` field from it; 118 | * if authorization is provided by the auth plugin in the kube config file – the auth plugin provides the expiration 119 | alongside the token. 120 | 121 | The cache works this way: 122 | 123 | The first time the token is "requested" by the client, it will unconditionally delegate to the underlying 124 | F[Authorization], and will cache the token. 125 | 126 | * if the underlying F[Authorization] "throws", the cache throws as well. 127 | 128 | When the token is requested subsequently: 129 | 130 | * if the expiration time is not present (the token was not a JWT, the auth plugin did not specify expiration, etc) the 131 | cached authorization will be re-used forever 132 | * if the expiration time is present, but it's far enough into the future (later than now + 133 | refreshTokenBeforeExpiration), the cached authorization will be re-used 134 | * if the expiration time is present, and it's soon enough (sooner than now + refreshTokenBeforeExpiration), the 135 | underlying F[Authorization] will be evaluated 136 | * if it's successful, the new authorization is cached 137 | * if not, but the cached token is still valid, the cached token is re-used otherwise, it will raise an error. 138 | 139 | If the cache is not configured (which is by default), the authorization will never be updated and might expire 140 | eventually. 141 | 142 | ### Requests 143 | 144 | ```scala 145 | import cats.effect.IO 146 | import com.goyeau.kubernetes.client.* 147 | import org.typelevel.log4cats.Logger 148 | import org.typelevel.log4cats.slf4j.Slf4jLogger 149 | import io.k8s.api.apps.v1.* 150 | import io.k8s.api.core.v1.* 151 | import io.k8s.apimachinery.pkg.api.resource.Quantity 152 | import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta 153 | import java.io.File 154 | import scala.concurrent.ExecutionContext 155 | 156 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO] 157 | 158 | val kubernetesClient = 159 | KubernetesClient(KubeConfig.fromFile[IO](new File(s"${System.getProperty("user.home")}/.kube/config"))) 160 | 161 | val deployment = Deployment( 162 | metadata = Option(ObjectMeta(name = Option("web-backend"), namespace = Option("my-namespace"))), 163 | spec = Option( 164 | DeploymentSpec( 165 | selector = null, 166 | strategy = Option( 167 | DeploymentStrategy( 168 | `type` = Option("RollingUpdate"), 169 | rollingUpdate = Option(RollingUpdateDeployment(Option(StringValue("10%")), Option(StringValue("50%")))) 170 | ) 171 | ), 172 | template = PodTemplateSpec( 173 | metadata = Option( 174 | ObjectMeta( 175 | labels = Option(Map("app" -> "web", "tier" -> "frontend", "environment" -> "myenv")) 176 | ) 177 | ), 178 | spec = Option( 179 | PodSpec( 180 | containers = Seq( 181 | Container( 182 | name = "nginx", 183 | image = Option("nginx"), 184 | resources = Option( 185 | ResourceRequirements( 186 | Option(Map("cpu" -> Quantity("100m"), "memory" -> Quantity("128Mi"))), 187 | Option(Map("cpu" -> Quantity("80m"), "memory" -> Quantity("64Mi"))) 188 | ) 189 | ), 190 | volumeMounts = Option(Seq(VolumeMount(name = "nginx-config", mountPath = "/etc/nginx/conf.d"))), 191 | ports = Option(Seq(ContainerPort(name = Option("http"), containerPort = 8080))) 192 | ) 193 | ), 194 | volumes = Option( 195 | Seq( 196 | Volume( 197 | name = "nginx-config", 198 | configMap = Option(ConfigMapVolumeSource(name = Option("nginx-config"))) 199 | ) 200 | ) 201 | ) 202 | ) 203 | ) 204 | ) 205 | ) 206 | ) 207 | ) 208 | 209 | kubernetesClient.use { client => 210 | client.deployments.namespace("my-namespace").create(deployment) 211 | } 212 | ``` 213 | 214 | ### Raw requests 215 | 216 | In case a particular K8S API endpoint is not explicitly supported by this library, there is an escape hatch 217 | that you can use in order to run a raw request or open a raw WS connection. 218 | 219 | Here's an example of how you can get a list of nodes using a raw request: 220 | 221 | ```scala 222 | import cats.effect.* 223 | import org.http4s.implicits.* 224 | import com.goyeau.kubernetes.client.* 225 | import org.http4s.* 226 | 227 | val kubernetesClient: KubernetesClient[IO] = ??? 228 | 229 | val response: IO[(Status, String)] = 230 | kubernetesClient 231 | .raw.runRequest( 232 | Request[IO]( 233 | uri = uri"/api" / "v1" / "nodes" 234 | ) 235 | ) 236 | .use { response => 237 | response.bodyText.foldMonoid.compile.lastOrError.map { body => 238 | (response.status, body) 239 | } 240 | } 241 | ``` 242 | 243 | Similarly, you can open a WS connection (`org.http4s.jdkhttpclient.WSConnectionHighLevel`): 244 | 245 | ```scala 246 | import cats.effect.* 247 | import org.http4s.implicits.* 248 | import com.goyeau.kubernetes.client.* 249 | import org.http4s.* 250 | import org.http4s.jdkhttpclient.* 251 | 252 | val connection: Resource[IO, WSConnectionHighLevel[IO]] = 253 | kubernetesClient.raw.connectWS( 254 | WSRequest( 255 | uri = (uri"/api" / "v1" / "my-custom-thing") +? ("watch" -> "true") 256 | ) 257 | ) 258 | ``` 259 | 260 | ## Development 261 | 262 | ### Pre-requisites 263 | 264 | - Java 11 or higher 265 | - Docker 266 | 267 | ### IntelliJ 268 | 269 | Generate a BSP configuration: 270 | 271 | ```shell 272 | ./mill mill.bsp.BSP/install 273 | ``` 274 | 275 | ### Compiling 276 | 277 | ```shell 278 | ./mill kubernetes-client[2.13.15].compile 279 | ``` 280 | 281 | ### Running the tests 282 | 283 | All tests: 284 | 285 | ```shell 286 | ./mill kubernetes-client[2.13.15].test 287 | ``` 288 | 289 | A specific test: 290 | 291 | ```shell 292 | ./mill kubernetes-client[2.13.15].test.testOnly 'com.goyeau.kubernetes.client.api.PodsApiTest' 293 | ``` 294 | 295 | [minikube](https://minikube.sigs.k8s.io/docs/) has to be installed and running. 296 | 297 | ### Before opening a PR: 298 | 299 | Check and fix formatting: 300 | 301 | ```shell 302 | ./mill __.style 303 | ``` 304 | 305 | 306 | 307 | 308 | ## Related projects 309 | 310 | * [Skuber](https://github.com/doriordan/skuber) 311 | * [Kubernetes Client for Java](https://github.com/kubernetes-client/java) 312 | 313 | 314 | ## Why Kubernetes Client for Scala? 315 | 316 | You might wonder why using this library instead of Skuber for example? Kubernetes Client is a pure functional based on 317 | Cats and Http4s. 318 | Another benefit of Kubernetes Client is that (like the [Kubernetes Client for Java](https://github.com/kubernetes-client/java/#update-the-generated-code)) 319 | it is generating all the payload case classes by just ingesting the swagger api provided by Kubernetes' main repo. That 320 | means this project will always remain up to date with the latest Kubernetes API. 321 | 322 | ## Adopters/Projects 323 | 324 | * [Kerberos-Operator2](https://github.com/novakov-alexey/krb-operator2) 325 | --------------------------------------------------------------------------------