├── 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 | [](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 |
--------------------------------------------------------------------------------