diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba14acdf..4cc4e6ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,116 +23,58 @@ jobs: java-version: 11.0.x distribution: zulu - name: Style checks - run: ./mill all __.checkStyle __.docJar + run: ./mill __.checkStyle + __.docJar - integration-kubernetes-v1-25: + integration-kubernetes: runs-on: ubuntu-latest strategy: fail-fast: false + matrix: + client: [ ember, jdk ] + platform: [ jvm, js, native ] + k8s: [ v1.28.4, v1.27.8, v1.26.11, v1.25.3, v1.24.7, v1.23.13, v1.22.15, v1.21.14 ] + scala: [ 3.3.1, 2.13.12 ] # 2.12.17 + exclude: + - platform: js + client: jdk + - platform: native + client: jdk + - k8s: v1.27.8 + scala: 2.13.12 + - k8s: v1.26.11 + scala: 2.13.12 + - k8s: v1.25.3 + scala: 2.13.12 + - k8s: v1.24.7 + scala: 2.13.12 + - k8s: v1.23.13 + scala: 2.13.12 + - k8s: v1.22.15 + scala: 2.13.12 + - k8s: v1.21.14 + name: k8s ${{ matrix.k8s }} - ${{ matrix.scala }} / ${{ matrix.platform }} / ${{ matrix.client }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Setup Minikube and start Kubernetes - uses: medyagh/setup-minikube@v0.0.8 + uses: medyagh/setup-minikube@v0.0.14 with: - minikube-version: 1.28.0 - kubernetes-version: v1.25.3 + minikube-version: 1.32.0 + kubernetes-version: ${{ matrix.k8s }} - name: Setup Java 11 uses: actions/setup-java@v3 with: java-version: 11.0.x distribution: zulu - - name: Test against Kubernetes v1.25 - run: ./mill __[3.3.1].test - - integration-kubernetes-v1-24: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup Minikube and start Kubernetes - uses: medyagh/setup-minikube@v0.0.8 - with: - minikube-version: 1.28.0 - kubernetes-version: v1.24.7 - - name: Setup Java 11 - uses: actions/setup-java@v3 - with: - java-version: 11.0.x - distribution: zulu - - name: Test against Kubernetes v1.24 - run: ./mill __[3.3.1].test - - integration-kubernetes-v1-23: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup Minikube and start Kubernetes - uses: medyagh/setup-minikube@v0.0.8 - with: - minikube-version: 1.28.0 - kubernetes-version: v1.23.13 - - name: Setup Java 11 - uses: actions/setup-java@v3 - with: - java-version: 11.0.x - distribution: zulu - - name: Test against Kubernetes v1.23 - run: ./mill __[3.3.1].test - - integration-kubernetes-v1-22: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup Minikube and start Kubernetes - uses: medyagh/setup-minikube@v0.0.8 - with: - minikube-version: 1.28.0 - kubernetes-version: v1.22.15 - - name: Setup Java 11 - uses: actions/setup-java@v3 - with: - java-version: 11.0.x - distribution: zulu - - name: Test against Kubernetes v1.22 - run: ./mill __[3.3.1].test - - integration-kubernetes-v1-21: - runs-on: ubuntu-latest - strategy: - fail-fast: false - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup Minikube and start Kubernetes - uses: medyagh/setup-minikube@v0.0.8 - with: - minikube-version: 1.28.0 - kubernetes-version: v1.21.14 - - name: Setup Java 11 - uses: actions/setup-java@v3 - with: - java-version: 11.0.x - distribution: zulu - - name: Test against Kubernetes v1.21 - run: ./mill __[3.3.1].test + - name: Test against Kubernetes ${{ matrix.k8s }} ${{ matrix.scala }} ${{ matrix.platform }} ${{ matrix.client }} + env: + KUBE_CLIENT_IMPLEMENTATION: ${{ matrix.client }} + run: ./mill _[${{ matrix.scala }}].${{ matrix.platform }}.test publish: needs: - checks - - integration-kubernetes-v1-25 - - integration-kubernetes-v1-24 - - integration-kubernetes-v1-23 - - integration-kubernetes-v1-22 - - integration-kubernetes-v1-21 + - integration-kubernetes runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.mill-version b/.mill-version index 9e031e82..e5cbde33 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -0.10.15 \ No newline at end of file +0.11.6 diff --git a/.scalafmt.conf b/.scalafmt.conf index f66860b9..b219210e 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -11,3 +11,13 @@ rewrite.redundantBraces.stringInterpolation = true rewrite.imports.sort = original rewrite.scala3.convertToNewSyntax = true rewrite.scala3.removeOptionalBraces = oldSyntaxToo +fileOverride { + "glob:project/**.*" { + runner.dialect = "scala213" + rewrite.scala3.convertToNewSyntax = false + } + "glob:**.sc" { + runner.dialect = "scala213" + rewrite.scala3.convertToNewSyntax = false + } +} \ No newline at end of file diff --git a/README.md b/README.md index 6c3a628f..db736d4b 100644 --- a/README.md +++ b/README.md @@ -299,12 +299,9 @@ A specific test: Check and fix formatting: ```shell -./mill __.style +./mill __.checkStyle + __.docJar ``` - - - ## Related projects * [Skuber](https://github.com/doriordan/skuber) diff --git a/build.sc b/build.sc index c87ead84..3ef9d6d8 100644 --- a/build.sc +++ b/build.sc @@ -4,53 +4,93 @@ import $ivy.`org.typelevel::scalac-options:0.1.4` import $file.project.Dependencies import Dependencies.Dependencies._ -import $file.project.SwaggerModelGenerator -import SwaggerModelGenerator.SwaggerModelGenerator +import $file.project.{SwaggerModelGenerator => SwaggerModelGeneratorFile} +import SwaggerModelGeneratorFile.SwaggerModelGenerator import com.goyeau.mill.git.{GitVersionModule, GitVersionedPublishModule} import com.goyeau.mill.scalafix.StyleModule import mill._ -import mill.scalalib.TestModule.Munit import mill.scalalib._ +import mill.scalajslib._ +import mill.scalanativelib._ +import mill.scalajslib.api.ModuleKind import mill.scalalib.api.ZincWorkerUtil.isScala3 import mill.scalalib.publish.{Developer, License, PomSettings, VersionControl} import org.typelevel.scalacoptions.ScalacOptions.{advancedOption, fatalWarningOptions, release, source3} import org.typelevel.scalacoptions.{ScalaVersion, ScalacOptions} +import coursier.maven.MavenRepository + +object `kubernetes-client` extends Cross[KubernetesClientModule]("3.3.1", "2.13.12" /* "2.12.17" */ ) +trait KubernetesClientModule extends Cross.Module[String] { + trait Shared + extends CrossScalaModule + with CrossValue + with PlatformScalaModule + with StyleModule + with GitVersionedPublishModule + with SwaggerModelGenerator { + + lazy val jvmVersion = "11" + + override def repositoriesTask = T.task { + super.repositoriesTask() ++ Seq( + coursier.Repositories.sonatype("snapshots"), + coursier.Repositories.sonatypeS01("snapshots") + ) + } + + override def javacOptions = super.javacOptions() ++ Seq("-source", jvmVersion, "-target", jvmVersion) + override def scalacOptions = super.scalacOptions() ++ ScalacOptions.tokensForVersion( + scalaVersion() match { + case "3.3.1" => ScalaVersion.V3_3_1 + case "2.13.12" => ScalaVersion.V2_13_9 + case "2.12.17" => ScalaVersion.V2_12_13 + }, + ScalacOptions.default + release(jvmVersion) + source3 + + advancedOption("max-inlines", List("50"), _.isAtLeast(ScalaVersion.V3_0_0)) // ++ fatalWarningOptions + ) + + override def ivyDeps = + super.ivyDeps() ++ fs2 ++ http4s.core ++ circe ++ circeYaml ++ bouncycastle ++ collectionCompat ++ log4cats.core // ++ java8compat + + override def scalacPluginIvyDeps = super.scalacPluginIvyDeps() ++ + (if (isScala3(scalaVersion())) Agg.empty else Agg(ivy"org.typelevel:::kind-projector:0.13.2")) + + override def publishVersion = GitVersionModule.version(withSnapshotSuffix = true) + def pomSettings = PomSettings( + description = "A Kubernetes client for Scala", + organization = "com.goyeau", + url = "https://github.com/joan38/kubernetes-client", + licenses = Seq(License.`Apache-2.0`), + versionControl = VersionControl.github("joan38", "kubernetes-client"), + developers = Seq(Developer("joan38", "Joan Goyeau", "https://github.com/joan38")) + ) -object `kubernetes-client` extends Cross[KubernetesClientModule]("3.3.1", "2.13.10", "2.12.17") -class KubernetesClientModule(val crossScalaVersion: String) - extends CrossScalaModule - with StyleModule - with GitVersionedPublishModule - with SwaggerModelGenerator { - lazy val jvmVersion = "11" - override def javacOptions = super.javacOptions() ++ Seq("-source", jvmVersion, "-target", jvmVersion) - override def scalacOptions = super.scalacOptions() ++ ScalacOptions.tokensForVersion( - scalaVersion() match { - case "3.3.1" => ScalaVersion.V3_3_1 - case "2.13.10" => ScalaVersion.V2_13_9 - case "2.12.17" => ScalaVersion.V2_12_13 - }, - ScalacOptions.default + release(jvmVersion) + source3 + - advancedOption("max-inlines", List("50"), _.isAtLeast(ScalaVersion.V3_0_0)) // ++ fatalWarningOptions - ) - - override def ivyDeps = - super.ivyDeps() ++ http4s ++ circe ++ circeYaml ++ bouncycastle ++ collectionCompat ++ logging ++ java8compat - override def scalacPluginIvyDeps = super.scalacPluginIvyDeps() ++ - (if (isScala3(scalaVersion())) Agg.empty else Agg(ivy"org.typelevel:::kind-projector:0.13.2")) - - object test extends Tests with Munit { - override def forkArgs = super.forkArgs() :+ "-Djdk.tls.client.protocols=TLSv1.2" - override def ivyDeps = super.ivyDeps() ++ tests ++ logback } - override def publishVersion = GitVersionModule.version(withSnapshotSuffix = true) - def pomSettings = PomSettings( - description = "A Kubernetes client for Scala", - organization = "com.goyeau", - url = "https://github.com/joan38/kubernetes-client", - licenses = Seq(License.`Apache-2.0`), - versionControl = VersionControl.github("joan38", "kubernetes-client"), - developers = Seq(Developer("joan38", "Joan Goyeau", "https://github.com/joan38")) - ) + trait SharedTestModule extends ScalaModule with TestModule.Munit { + override def forkArgs = T(super.forkArgs() :+ "-Djdk.tls.client.protocols=TLSv1.2") + override def ivyDeps = super.ivyDeps() ++ tests + } + + object jvm extends Shared { + override def ivyDeps = super.ivyDeps() ++ fs2 ++ http4s.jdkClient ++ http4s.emberClient + object test extends ScalaTests with SharedTestModule { + override def ivyDeps = super.ivyDeps() ++ tests ++ log4cats.logback + } + } + + object js extends Shared with ScalaJSModule { + def scalaJSVersion = "1.14.0" + override def ivyDeps = super.ivyDeps() ++ fs2 ++ http4s.emberClient + object test extends ScalaJSTests with SharedTestModule { + override def ivyDeps = super.ivyDeps() ++ tests ++ log4cats.jsConsole + override def moduleKind = ModuleKind.CommonJSModule + } + } + + object native extends Shared with ScalaNativeModule { + def scalaNativeVersion = "0.4.16" + override def ivyDeps = super.ivyDeps() ++ http4s.emberClient + object test extends ScalaNativeTests with SharedTestModule + } } diff --git a/kubernetes-client/src-js/com/goyeau/kubernetes/client/PlatformSpecific.scala b/kubernetes-client/src-js/com/goyeau/kubernetes/client/PlatformSpecific.scala new file mode 100644 index 00000000..38b19dec --- /dev/null +++ b/kubernetes-client/src-js/com/goyeau/kubernetes/client/PlatformSpecific.scala @@ -0,0 +1,5 @@ +package com.goyeau.kubernetes.client + +trait PlatformSpecific {} + +private object PlatformSpecific {} diff --git a/kubernetes-client/src-js/com/goyeau/kubernetes/client/TlsContexts.scala b/kubernetes-client/src-js/com/goyeau/kubernetes/client/TlsContexts.scala new file mode 100644 index 00000000..ecc90a80 --- /dev/null +++ b/kubernetes-client/src-js/com/goyeau/kubernetes/client/TlsContexts.scala @@ -0,0 +1,138 @@ +package com.goyeau.kubernetes.client + +import cats.effect.syntax.all.* +import cats.syntax.all.* +import cats.effect.* +import cats.data.OptionT +import cats.effect.std.Env +import fs2.io.net.tls.{SecureContext, TLSContext} +import fs2.io.net.Network +import fs2.io.file.{Files, Path} +import fs2.Chunk +import fs2.io.net.tls.SecureContext.SecureVersion + +import scala.concurrent.duration.* + +private[client] object TlsContexts { + + def fromConfig[F[_]: Sync: Network: Files: Env](config: KubeConfig[F]): Resource[F, Option[TLSContext[F]]] = + OptionT(mkSecureContext(config)).map(Network[F].tlsContext.fromSecureContext(_)).value.toResource + + // https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options + private val ENV_TLS_CIPHER_PREFERENCES = "TLS_CIPHER_PREFERENCES" + private val ENV_TLS_MIN_VERSION = "TLS_MIN_VERSION" + private val ENV_TLS_MAX_VERSION = "TLS_MAX_VERSION" + private val ENV_TLS_CLIENT_CERT_ENGINE = "TLS_CLIENT_CERT_ENGINE" + private val ENV_TLS_DH_PARAMS = "TLS_DH_PARAMS" + private val ENV_TLS_ECDH_CURVE = "TLS_ECDH_CURVE" + private val ENV_TLS_PRIVATE_KEY_ENGINE = "TLS_PRIVATE_KEY_ENGINE" + private val ENV_TLS_TLS_HONOR_CIPHER_ORDER = "TLS_HONOR_CIPHER_ORDER" + private val ENV_TLS_PRIVATE_KEY_IDENTIFIER = "TLS_PRIVATE_KEY_IDENTIFIER" + private val ENV_TLS_SECURE_OPTIONS = "TLS_SECURE_OPTIONS" + private val ENV_TLS_SESSION_ID_CONTEXT = "TLS_SESSION_ID_CONTEXT" + private val ENV_TLS_SESSION_TIMEOUT = "TLS_SESSION_TIMEOUT" + private val ENV_TLS_SIGALGS = "TLS_SIGALGS" + + private def mkSecureContext[F[_]: Sync: Files: Env](config: KubeConfig[F]): F[Option[SecureContext]] = + for { + // ca + caDataBytes <- decodeBase64[F](config.caCertData) + caFileBytes <- readFile(config.caCertFile) + // Client certificate + certDataBytes <- decodeBase64(config.clientCertData) + certFileBytes <- readFile(config.clientCertFile) + // Client key + keyDataBytes <- decodeBase64(config.clientKeyData) + keyFileBytes <- readFile(config.clientKeyFile) + // --- + keyBytes = keyDataBytes.orElse(keyFileBytes) + certBytes = certDataBytes.orElse(certFileBytes) + caBytes = caDataBytes.orElse(caFileBytes) + cipherPreferences <- Env[F].get(ENV_TLS_CIPHER_PREFERENCES) + minVersion <- Env[F].get(ENV_TLS_MIN_VERSION).flatMap(parseSecureVersion(_, ENV_TLS_MIN_VERSION)) + maxVersion <- Env[F].get(ENV_TLS_MAX_VERSION).flatMap(parseSecureVersion(_, ENV_TLS_MAX_VERSION)) + clientCertEngine <- Env[F].get(ENV_TLS_CLIENT_CERT_ENGINE) + dhParam <- Env[F].get(ENV_TLS_DH_PARAMS) + ecdhCurve <- Env[F].get(ENV_TLS_ECDH_CURVE) + honorCipherOrder <- Env[F].get(ENV_TLS_TLS_HONOR_CIPHER_ORDER).map(_.map(_.toLowerCase == "yes")) + privateKeyEngine <- Env[F].get(ENV_TLS_PRIVATE_KEY_ENGINE) + privateKeyIdentifier <- Env[F].get(ENV_TLS_PRIVATE_KEY_IDENTIFIER) + secureOptions <- Env[F] + .get(ENV_TLS_SECURE_OPTIONS) + .flatMap(s => Sync[F].delay(s.map(_.toLong))) + .adaptError { error => + new IllegalArgumentException( + s"failed to parse the value specified in $ENV_TLS_SECURE_OPTIONS (64 bit): ${error.getMessage}" + ) + } + sessionIdContext <- Env[F].get(ENV_TLS_SESSION_ID_CONTEXT) + sessionTimeout <- Env[F] + .get(ENV_TLS_SESSION_TIMEOUT) + .flatMap(t => Sync[F].delay(t.map(_.toLong.seconds))) + .adaptError { error => + new IllegalArgumentException( + s"failed to parse the value specified in $ENV_TLS_SESSION_TIMEOUT (seconds): ${error.getMessage}" + ) + } + sigalgs <- Env[F].get(ENV_TLS_SIGALGS) + } yield + if (keyBytes.nonEmpty || certBytes.nonEmpty || caBytes.nonEmpty) + SecureContext( + ca = caBytes.map(caBytes => Seq(caBytes.asLeft[String])), + cert = certBytes.map(certBytes => Seq(certBytes.asLeft[String])), + ciphers = cipherPreferences, + clientCertEngine = clientCertEngine, + // crl: Option[Seq[Either[Chunk[Byte], String]]] = None, + dhparam = dhParam.map(_.asRight), + ecdhCurve = ecdhCurve, + honorCipherOrder = honorCipherOrder, + key = keyBytes.map { keyBytes => + Seq(SecureContext.Key(keyBytes.asLeft, config.clientKeyPass)) + }, + maxVersion = maxVersion, + minVersion = minVersion, + passphrase = config.clientKeyPass, + // pfx: Option[Seq[Pfx]] = None, + privateKeyEngine = privateKeyEngine, + privateKeyIdentifier = privateKeyIdentifier, + secureOptions = secureOptions, + sessionIdContext = sessionIdContext, + sessionTimeout = sessionTimeout, + sigalgs = sigalgs + // ticketKeys: Option[Chunk[Byte]] = None + ).some + else + none + + private val secureVersions: Map[String, SecureVersion] = Map( + "TLSv1" -> SecureVersion.TLSv1, + "TLSv1.1" -> SecureVersion.`TLSv1.1`, + "TLSv1.2" -> SecureVersion.`TLSv1.2`, + "TLSv1.3" -> SecureVersion.`TLSv1.3` + ) + + private def parseSecureVersion[F[_]: Sync](s: Option[String], envVar: String): F[Option[SecureVersion]] = + s.traverse { s => + Sync[F].fromOption( + secureVersions.get(s), + new IllegalArgumentException( + s"invalid TLS version specified in $envVar: $s, valid values: ${secureVersions.keys.mkString(", ")}" + ) + ) + } + + private def decodeBase64[F[_]: Sync](data: Option[String]): F[Option[Chunk[Byte]]] = + data.traverse { data => + fs2.Stream + .emit(data) + .covary[F] + .through(fs2.text.base64.decode) + .chunkAll + .compile + .lastOrError + } + + private def readFile[F[_]: Sync: Files](path: Option[Path]): F[Option[Chunk[Byte]]] = + path.traverse(path => Files[F].readAll(path).chunkAll.compile.lastOrError) + +} diff --git a/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/PlatformSpecific.scala b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/PlatformSpecific.scala new file mode 100644 index 00000000..8f6380b7 --- /dev/null +++ b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/PlatformSpecific.scala @@ -0,0 +1,52 @@ +package com.goyeau.kubernetes.client + +import cats.syntax.all.* +import cats.effect.* +import cats.effect.std.Env +import fs2.io.net.Network +import fs2.io.file.Files +import fs2.io.process.Processes +import org.typelevel.log4cats.Logger +import org.http4s.jdkhttpclient.{JdkHttpClient, JdkWSClient} +import java.net.http.HttpClient + +trait PlatformSpecific { + self: KubernetesClient.type => + + def jdk[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F], + adaptClients: Clients[F] => Resource[F, Clients[F]] + ): Resource[F, KubernetesClient[F]] = + create(config, PlatformSpecific.jdkClients(_), adaptClients) + + def jdk[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F] + ): Resource[F, KubernetesClient[F]] = + create(config, PlatformSpecific.jdkClients(_), noAdapt[F]) + + def jdk[F[_]: Async: Files: Logger: Network: Processes: Env]( + config: F[KubeConfig[F]], + adaptClients: Clients[F] => Resource[F, Clients[F]] + ): Resource[F, KubernetesClient[F]] = + Resource.eval(config).flatMap(create(_, PlatformSpecific.jdkClients(_), adaptClients)) + + def jdk[F[_]: Async: Files: Logger: Network: Processes: Env]( + config: F[KubeConfig[F]] + ): Resource[F, KubernetesClient[F]] = + Resource.eval(config).flatMap(create(_, PlatformSpecific.jdkClients(_), noAdapt[F])) + +} + +private object PlatformSpecific { + + def jdkClients[F[_]: Async](config: KubeConfig[F]): Resource[F, Clients[F]] = + Resource.eval { + for { + sslContext <- SslContexts.fromConfig(config) + client <- Async[F].delay(HttpClient.newBuilder().sslContext(sslContext).build()) + httpClient <- Async[F].delay(JdkHttpClient[F](client)) + wsClient <- Async[F].delay(JdkWSClient[F](client)) + } yield Clients(httpClient, wsClient) + } + +} diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/SslContexts.scala b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/SslContexts.scala similarity index 67% rename from kubernetes-client/src/com/goyeau/kubernetes/client/util/SslContexts.scala rename to kubernetes-client/src-jvm/com/goyeau/kubernetes/client/SslContexts.scala index 2dec6f40..ea8ea47a 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/SslContexts.scala +++ b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/SslContexts.scala @@ -1,30 +1,35 @@ -package com.goyeau.kubernetes.client.util +package com.goyeau.kubernetes.client + +import cats.effect.Sync + import java.io.{ByteArrayInputStream, File, FileInputStream, InputStreamReader} import java.security.cert.{CertificateFactory, X509Certificate} import java.security.{KeyStore, SecureRandom, Security} import java.util.Base64 -import com.goyeau.kubernetes.client.KubeConfig -import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} +import javax.net.ssl.{KeyManager, KeyManagerFactory, SSLContext, TrustManager, TrustManagerFactory} import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter import org.bouncycastle.openssl.{PEMKeyPair, PEMParser} +import org.bouncycastle.cert.X509CertificateHolder + import scala.jdk.CollectionConverters.* -object SslContexts { +private[client] object SslContexts { private val TrustStoreSystemProperty = "javax.net.ssl.trustStore" private val TrustStorePasswordSystemProperty = "javax.net.ssl.trustStorePassword" private val KeyStoreSystemProperty = "javax.net.ssl.keyStore" private val KeyStorePasswordSystemProperty = "javax.net.ssl.keyStorePassword" - def fromConfig[F[_]](config: KubeConfig[F]): SSLContext = { - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(keyManagers(config), trustManagers(config), new SecureRandom) - sslContext - } + def fromConfig[F[_]: Sync](config: KubeConfig[F]): F[SSLContext] = + Sync[F].blocking { + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagers(config), trustManagers(config), new SecureRandom) + sslContext + } @SuppressWarnings(Array("scalafix:DisableSyntax.asInstanceOf")) - private def keyManagers[F[_]](config: KubeConfig[F]) = { + private def keyManagers[F[_]](config: KubeConfig[F]): Array[KeyManager] = { // Client certificate val certDataStream = config.clientCertData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) val certFileStream = config.clientCertFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) @@ -33,25 +38,31 @@ object SslContexts { val keyDataStream = config.clientKeyData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) val keyFileStream = config.clientKeyFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) - for { + val _ = for { keyStream <- keyDataStream.orElse(keyFileStream) certStream <- certDataStream.orElse(certFileStream) - } yield { - Security.addProvider(new BouncyCastleProvider()) - val pemKeyPair = - new PEMParser(new InputStreamReader(keyStream)).readObject().asInstanceOf[PEMKeyPair] - val privateKey = new JcaPEMKeyConverter().setProvider("BC").getPrivateKey(pemKeyPair.getPrivateKeyInfo) - - val certificateFactory = CertificateFactory.getInstance("X509") - val certificate = certificateFactory.generateCertificate(certStream).asInstanceOf[X509Certificate] - - defaultKeyStore.setKeyEntry( - certificate.getSubjectX500Principal.getName, - privateKey, - config.clientKeyPass.fold(Array.empty[Char])(_.toCharArray), - Array(certificate) - ) - } + _ = Security.addProvider(new BouncyCastleProvider()) + pemKeyPair = new PEMParser(new InputStreamReader(keyStream)).readObject() match { + case kp: PEMKeyPair => kp + case _: X509CertificateHolder => + throw new IllegalArgumentException( + s"failed to parse the private key, it looks like you might be specifying the client certificate instead of the private key" + ) + case other => + throw new IllegalArgumentException( + s"failed to parse the private key: ${other.getClass.getSimpleName} is not a PEM key-pair" + ) + } + privateKey = new JcaPEMKeyConverter().setProvider("BC").getPrivateKey(pemKeyPair.getPrivateKeyInfo) + + certificateFactory = CertificateFactory.getInstance("X509") + certificate = certificateFactory.generateCertificate(certStream).asInstanceOf[X509Certificate] + } yield defaultKeyStore.setKeyEntry( + certificate.getSubjectX500Principal.getName, + privateKey, + config.clientKeyPass.fold(Array.empty[Char])(_.toCharArray), + Array(certificate) + ) val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) keyManagerFactory.init(defaultKeyStore, Array.empty) @@ -70,7 +81,7 @@ object SslContexts { keyStore } - private def trustManagers[F[_]](config: KubeConfig[F]) = { + private def trustManagers[F[_]](config: KubeConfig[F]): Array[TrustManager] = { val certDataStream = config.caCertData.map(data => new ByteArrayInputStream(Base64.getDecoder.decode(data))) val certFileStream = config.caCertFile.map(_.toNioPath.toFile).map(new FileInputStream(_)) diff --git a/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/TlsContexts.scala b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/TlsContexts.scala new file mode 100644 index 00000000..ac0208dd --- /dev/null +++ b/kubernetes-client/src-jvm/com/goyeau/kubernetes/client/TlsContexts.scala @@ -0,0 +1,22 @@ +package com.goyeau.kubernetes.client + +import cats.effect.syntax.all.* +import cats.syntax.all.* +import cats.effect.* +import cats.data.OptionT +import fs2.io.net.tls.TLSContext +import javax.net.ssl.SSLContext +import fs2.io.net.Network + +private[client] object TlsContexts { + + def fromConfig[F[_]: Sync: Network](config: KubeConfig[F]): Resource[F, Option[TLSContext[F]]] = + OptionT(mkSecureContext(config)).map(Network[F].tlsContext.fromSSLContext(_)).value.toResource + + private def mkSecureContext[F[_]: Sync](config: KubeConfig[F]): F[Option[SSLContext]] = + if (config.tlsConfigured) + SslContexts.fromConfig(config).map(_.some) + else + none.pure[F] + +} diff --git a/kubernetes-client/src-native/com/goyeau/kubernetes/client/PlatformSpecific.scala b/kubernetes-client/src-native/com/goyeau/kubernetes/client/PlatformSpecific.scala new file mode 100644 index 00000000..38b19dec --- /dev/null +++ b/kubernetes-client/src-native/com/goyeau/kubernetes/client/PlatformSpecific.scala @@ -0,0 +1,5 @@ +package com.goyeau.kubernetes.client + +trait PlatformSpecific {} + +private object PlatformSpecific {} diff --git a/kubernetes-client/src-native/com/goyeau/kubernetes/client/TlsContexts.scala b/kubernetes-client/src-native/com/goyeau/kubernetes/client/TlsContexts.scala new file mode 100644 index 00000000..c81e256b --- /dev/null +++ b/kubernetes-client/src-native/com/goyeau/kubernetes/client/TlsContexts.scala @@ -0,0 +1,145 @@ +package com.goyeau.kubernetes.client + +import cats.syntax.all.* +import cats.effect.syntax.all.* +import cats.effect.* +import cats.effect.std.Env +import scodec.bits.ByteVector +import fs2.io.net.tls.{CertChainAndKey, S2nConfig, TLSContext} +import fs2.io.net.Network +import fs2.io.file.{Files, Path} + +private[client] object TlsContexts { + + def fromConfig[F[_]: Sync: Network: Files: Env](config: KubeConfig[F]): Resource[F, Option[TLSContext[F]]] = + mkSecureContext(config).map(_.map(Network[F].tlsContext.fromS2nConfig(_))) + + // https://github.com/aws/s2n-tls/blob/main/docs/USAGE-GUIDE.md#security-policies + private val ENV_TLS_CIPHER_PREFERENCES = "TLS_CIPHER_PREFERENCES" + + private val ENV_TLS_WIPED_TRUST_STORE = "TLS_WIPED_TRUST_STORE" + private val ENV_TLS_SEND_BUFFER_SIZE = "TLS_SEND_BUFFER_SIZE" + private val ENV_TLS_DISABLE_X509_VERIFICATION = "TLS_DISABLE_X509_VERIFICATION" + private val ENV_TLS_MAX_CERT_CHAIN_DEPTH = "TLS_MAX_CERT_CHAIN_DEPTH" + private val ENV_TLS_DH_PARAMS = "TLS_DH_PARAMS" + + private def mkSecureContext[F[_]: Sync: Files: Env](config: KubeConfig[F]): Resource[F, Option[S2nConfig]] = + for { + // ca + caDataBytes <- decodeBase64String(config.caCertData).toResource + caFileBytes <- readFileString(config.caCertFile).toResource + // Client certificate + certDataBytes <- decodeBase64ByteVector(config.clientCertData).toResource + certFileBytes <- readFileByteVector(config.clientCertFile).toResource + // Client key + keyDataBytes <- decodeBase64ByteVector(config.clientKeyData).toResource + keyFileBytes <- readFileByteVector(config.clientKeyFile).toResource + // --- + keyBytes = keyDataBytes.orElse(keyFileBytes) + certBytes = certDataBytes.orElse(certFileBytes) + caBytes = caDataBytes.orElse(caFileBytes) + cipherPreferences <- Env[F].get(ENV_TLS_CIPHER_PREFERENCES).toResource + wipedTrustStore <- Env[F].get(ENV_TLS_WIPED_TRUST_STORE).map(_.filter(_.toLowerCase == "yes")).toResource + sendBufferSize <- Env[F] + .get(ENV_TLS_SEND_BUFFER_SIZE) + .flatMap(s => Sync[F].delay(s.map(_.toInt))) + .adaptError { error => + new IllegalArgumentException( + s"failed to parse the value specified in $ENV_TLS_SEND_BUFFER_SIZE (32 bit): ${error.getMessage}" + ) + } + .toResource + disabledX509Verification <- Env[F] + .get(ENV_TLS_DISABLE_X509_VERIFICATION) + .map(_.filter(_.toLowerCase == "yes")) + .toResource + maxCertChainDepth <- Env[F] + .get(ENV_TLS_MAX_CERT_CHAIN_DEPTH) + .flatMap(s => Sync[F].delay(s.map(_.toShort))) + .adaptError { error => + new IllegalArgumentException( + s"failed to parse the value specified in $ENV_TLS_MAX_CERT_CHAIN_DEPTH (16 bit): ${error.getMessage}" + ) + } + .toResource + dhParams <- Env[F].get(ENV_TLS_DH_PARAMS).toResource + _ <- Sync[F] + .raiseWhen(config.clientKeyPass.nonEmpty)(new IllegalArgumentException("client key password is not supported")) + .toResource + builder = + if (keyBytes.nonEmpty || certBytes.nonEmpty || caBytes.nonEmpty) { + // scalafix:off DisableSyntax.var + var builder = + S2nConfig.builder + .withMaxCertChainDepth(5) + // scalafix:on DisableSyntax.var + + builder = cipherPreferences.fold(builder)(builder.withCipherPreferences) + builder = wipedTrustStore.fold(builder)(_ => builder.withWipedTrustStore) + builder = sendBufferSize.fold(builder)(builder.withSendBufferSize) + builder = disabledX509Verification + .fold(builder)(_ => builder.withDisabledX509Verification) + builder = maxCertChainDepth.fold(builder)(builder.withMaxCertChainDepth) + builder = dhParams.fold(builder)(builder.withDHParams) + + builder = (certBytes, keyBytes).tupled.fold(builder) { case (certBytes, keyBytes) => + builder.withCertChainAndKeysToStore( + List( + CertChainAndKey( + chainPem = certBytes, + privateKeyPem = keyBytes + ) + ) + ) + } + + builder = caBytes.fold(builder) { caBytes => + builder.withPemsToTrustStore( + List( + caBytes + ) + ) + } + + builder.some + } else + none + result <- builder match { + case Some(builder) => builder.build[F].map(_.some) + case None => none.pure[F].toResource + } + } yield result + + private def decodeString[F[_]: Sync](s: fs2.Stream[F, Byte]): F[String] = + s.through(fs2.text.utf8.decode).compile.string + + private def decodeByteVector[F[_]: Sync](s: fs2.Stream[F, Byte]): F[ByteVector] = + s.compile.to(ByteVector) + + private def decodeBase64[F[_]: Sync](data: String): fs2.Stream[F, Byte] = + fs2.Stream + .emit(data) + .covary[F] + .through(fs2.text.base64.decode) + + private def decodeBase64String[F[_]: Sync](data: Option[String]): F[Option[String]] = + data.traverse { data => + decodeString( + decodeBase64(data) + ) + } + + private def decodeBase64ByteVector[F[_]: Sync](data: Option[String]): F[Option[ByteVector]] = + data.traverse { data => + decodeByteVector( + decodeBase64(data) + ) + } + + private def readFileString[F[_]: Sync: Files](path: Option[Path]): F[Option[String]] = + path.traverse(path => decodeString(Files[F].readAll(path))) + + private def readFileByteVector[F[_]: Sync: Files](path: Option[Path]): F[Option[ByteVector]] = + path.traverse(path => decodeByteVector(Files[F].readAll(path))) + +} diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/Clients.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/Clients.scala new file mode 100644 index 00000000..561af278 --- /dev/null +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/Clients.scala @@ -0,0 +1,6 @@ +package com.goyeau.kubernetes.client + +import org.http4s.client.Client +import org.http4s.client.websocket.WSClient + +case class Clients[F[_]](httpClient: Client[F], wsClient: WSClient[F]) diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/IntOrString.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/IntOrString.scala index 24bdd1b0..8b312d78 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/IntOrString.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/IntOrString.scala @@ -3,7 +3,7 @@ package com.goyeau.kubernetes.client import io.circe.{Decoder, Encoder, Json} import cats.implicits.* -trait IntOrString +sealed trait IntOrString case class IntValue(value: Int) extends IntOrString case class StringValue(value: String) extends IntOrString diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/KubeConfig.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/KubeConfig.scala index ff8dc45c..b4e3c62c 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/KubeConfig.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/KubeConfig.scala @@ -1,8 +1,9 @@ package com.goyeau.kubernetes.client -import cats.ApplicativeThrow +import cats.* import cats.data.{NonEmptyList, OptionT} -import cats.effect.Async +import cats.effect.{Clock, Concurrent} +import cats.effect.std.Env import cats.syntax.all.* import com.comcast.ip4s.{IpAddress, Port} import com.goyeau.kubernetes.client.util.cache.{AuthorizationCache, AuthorizationWithExpiration} @@ -14,37 +15,59 @@ import org.typelevel.log4cats.Logger import scala.concurrent.duration.* -case class KubeConfig[F[_]] private ( - server: Uri, - authorization: Option[F[Authorization]], - caCertData: Option[String], - caCertFile: Option[Path], - clientCertData: Option[String], - clientCertFile: Option[Path], - clientKeyData: Option[String], - clientKeyFile: Option[Path], - clientKeyPass: Option[String], - authInfoExec: Option[AuthInfoExec], - authorizationCache: Option[F[AuthorizationWithExpiration] => F[F[Authorization]]] +class KubeConfig[F[_]: Files] private ( + val server: Uri, + val authorization: Option[F[Authorization]], + val caCertData: Option[String], + val caCertFile: Option[Path], + val clientCertData: Option[String], + val clientCertFile: Option[Path], + val clientKeyData: Option[String], + val clientKeyFile: Option[Path], + val clientKeyPass: Option[String], + val authInfoExec: Option[AuthInfoExec], + val authorizationCache: Option[F[AuthorizationWithExpiration] => F[F[Authorization]]] ) { + def tlsConfigured: Boolean = + caCertData.nonEmpty || + caCertFile.nonEmpty || + clientCertData.nonEmpty || + clientCertFile.nonEmpty || + clientKeyData.nonEmpty || + clientKeyFile.nonEmpty + + private def withAuthorizationCache( + newAuthorizationCache: Option[F[AuthorizationWithExpiration] => F[F[Authorization]]] + ): KubeConfig[F] = new KubeConfig[F]( + server = this.server, + authorization = this.authorization, + caCertData = this.caCertData, + caCertFile = this.caCertFile, + clientCertData = this.clientCertData, + clientCertFile = this.clientCertFile, + clientKeyData = this.clientKeyData, + clientKeyFile = this.clientKeyFile, + clientKeyPass = this.clientKeyPass, + authInfoExec = this.authInfoExec, + authorizationCache = newAuthorizationCache + ) + def withAuthorizationCache( authorizationCache: F[AuthorizationWithExpiration] => F[F[Authorization]] ): KubeConfig[F] = - this.copy(authorizationCache = authorizationCache.some) + this.withAuthorizationCache(authorizationCache.some) def withoutAuthorizationCache: KubeConfig[F] = - this.copy(authorizationCache = none) + this.withAuthorizationCache(none) def withDefaultAuthorizationCache( refreshTokenBeforeExpiration: FiniteDuration = 5.minutes - )(implicit F: Async[F], L: Logger[F]): KubeConfig[F] = { + )(implicit F: Concurrent[F], C: Clock[F], L: Logger[F]): KubeConfig[F] = { val addCache: F[AuthorizationWithExpiration] => F[F[Authorization]] = retrieve => AuthorizationCache(retrieve, refreshTokenBeforeExpiration).map(_.get) - this.copy( - authorizationCache = addCache.some - ) + this.withAuthorizationCache(addCache.some) } } @@ -77,7 +100,7 @@ object KubeConfig { * - KUBERNETES_SERVICE_HOST env variable (https protocol is assumed) * - KUBERNETES_SERVICE_PORT env variable */ - def standard[F[_]: Logger](implicit F: Async[F]): F[KubeConfig[F]] = + def standard[F[_]: Concurrent: Logger: Files: Env]: F[KubeConfig[F]] = findFromEnv .orElse(findConfigInHomeDir(none)) .orElse(findClusterConfig) @@ -85,20 +108,20 @@ object KubeConfig { /** Use the file specified in KUBECONFIG env variable, if exists. Uses the 'current-context' specified in the file. */ - def fromEnv[F[_]: Logger](implicit F: Async[F]): F[KubeConfig[F]] = + def fromEnv[F[_]: Concurrent: Logger: Files: Env]: F[KubeConfig[F]] = findFromEnv .getOrRaise(KubeConfigNotFoundError) /** Uses the configuration from ~/.kube/config, if exists. Uses the 'current-context' specified in the file. */ - def inHomeDir[F[_]: Logger](implicit F: Async[F]): F[KubeConfig[F]] = + def inHomeDir[F[_]: Concurrent: Logger: Files: Env]: F[KubeConfig[F]] = findConfigInHomeDir(none) .getOrRaise(KubeConfigNotFoundError) /** Uses the configuration from ~/.kube/config, if exists. Uses the provided contextName (will fail if the context * does not exist). */ - def inHomeDir[F[_]: Logger](contextName: String)(implicit F: Async[F]): F[KubeConfig[F]] = + def inHomeDir[F[_]: Concurrent: Logger: Files: Env](contextName: String): F[KubeConfig[F]] = findConfigInHomeDir(contextName.some) .getOrRaise(KubeConfigNotFoundError) @@ -110,29 +133,22 @@ object KubeConfig { * - KUBERNETES_SERVICE_HOST env variable (https protocol is assumed), * - KUBERNETES_SERVICE_PORT env variable. */ - def cluster[F[_]: Logger](implicit F: Async[F]): F[KubeConfig[F]] = + def cluster[F[_]: Concurrent: Logger: Files: Env]: F[KubeConfig[F]] = findClusterConfig .getOrRaise(KubeConfigNotFoundError) /** Read the configuration from the specified file. Uses the provided contextName (will fail if the context does not * exist). */ - def fromFile[F[_]: Async: Logger](kubeconfig: Path): F[KubeConfig[F]] = + def fromFile[F[_]: Concurrent: Logger: Files: Env](kubeconfig: Path): F[KubeConfig[F]] = Yamls.fromKubeConfigFile(kubeconfig, None) /** Read the configuration from the specified file. Uses the 'current-context' specified in the file. */ - def fromFile[F[_]: Async: Logger](kubeconfig: Path, contextName: String): F[KubeConfig[F]] = + def fromFile[F[_]: Concurrent: Logger: Files: Env](kubeconfig: Path, contextName: String): F[KubeConfig[F]] = Yamls.fromKubeConfigFile(kubeconfig, Option(contextName)) - @deprecated(message = "Use fromFile instead", since = "0.4.1") - def apply[F[_]: Async: Logger](kubeconfig: Path): F[KubeConfig[F]] = fromFile(kubeconfig) - - @deprecated(message = "Use fromFile instead", since = "0.4.1") - def apply[F[_]: Async: Logger](kubeconfig: Path, contextName: String): F[KubeConfig[F]] = - fromFile(kubeconfig, contextName) - - def of[F[_]: ApplicativeThrow]( + def of[F[_]: ApplicativeThrow: Files]( server: Uri, authorization: Option[F[Authorization]] = None, caCertData: Option[String] = None, @@ -163,7 +179,7 @@ object KubeConfig { }.parSequence .leftMap(errors => new IllegalArgumentException(errors.toList.mkString("; "))) .as { - KubeConfig( + new KubeConfig( server = server, authorization = authorization, caCertData = caCertData, @@ -181,9 +197,9 @@ object KubeConfig { ApplicativeThrow[F].fromEither(configOrError) } - private def findFromEnv[F[_]: Logger](implicit F: Async[F]): OptionT[F, KubeConfig[F]] = + private def findFromEnv[F[_]: Concurrent: Logger: Files: Env]: OptionT[F, KubeConfig[F]] = envPath[F](EnvKubeConfig) - .flatMapF(checkExists(_)) + .flatMap(checkExists(_)) .flatTapNone { Logger[F].debug(s"$EnvKubeConfig is not defined, or path does not exist") } @@ -192,29 +208,30 @@ object KubeConfig { } .semiflatMap(fromFile(_)) - private def findConfigInHomeDir[F[_]: Logger]( + private def findConfigInHomeDir[F[_]: Concurrent: Logger: Files: Env]( contextName: Option[String] - )(implicit F: Async[F]): OptionT[F, KubeConfig[F]] = + ): OptionT[F, KubeConfig[F]] = findHomeDir .map(homeDir => homeDir.resolve(KubeConfigDir).resolve(KubeConfigFile)) - .flatMapF(checkExists(_)) - .flatTapNone { - Logger[F].debug(s"~/$KubeConfigDir/$KubeConfigFile does not exist") - } + .flatMap(homeDir => + checkExists(homeDir).flatTapNone { + Logger[F].debug(s"$homeDir/$KubeConfigDir/$KubeConfigFile does not exist") + } + ) .semiflatTap { path => Logger[F].debug(s"using configuration specified in $path") } .semiflatMap(path => contextName.fold(fromFile(path))(fromFile(path, _))) - private def findClusterConfig[F[_]: Logger](implicit F: Async[F]): OptionT[F, KubeConfig[F]] = + private def findClusterConfig[F[_]: Concurrent: Logger: Files: Env]: OptionT[F, KubeConfig[F]] = ( path(ServiceAccountTokenPath) - .flatMapF(checkExists(_)) + .flatMap(checkExists(_)) .flatTapNone { Logger[F].debug(s"$ServiceAccountTokenPath does not exist") }, path(ServiceAccountCAPath) - .flatMapF(checkExists(_)) + .flatMap(checkExists(_)) .flatTapNone { Logger[F].debug(s"$ServiceAccountCAPath does not exist") }, @@ -247,14 +264,20 @@ object KubeConfig { ) } - private def findHomeDir[F[_]: Logger](implicit F: Async[F]): OptionT[F, Path] = + private def findHomeDir[F[_]: Concurrent: Logger: Files: Env]: OptionT[F, Path] = OptionT.liftF( - Logger[F].debug(s"finding the home directory") - ) >> + Logger[F].debug(s"finding home directory") + ) *> envPath(EnvHome) // if HOME env var is set, use it - .flatMapF(checkExists(_)) + .semiflatTap(homeDir => Logger[F].debug(s"$EnvHome is defined: $homeDir")) .flatTapNone( - Logger[F].debug(s"$EnvHome is not defined, or path does not exist") + Logger[F].debug(s"$EnvHome is not defined") + ) + .flatMap(homeDir => + checkExists(homeDir) + .flatTapNone( + Logger[F].debug(s"path specified in $EnvHome does not exist") + ) ) .orElse { // otherwise, if it's a windows machine @@ -264,14 +287,14 @@ object KubeConfig { // if HOMEDRIVE and HOMEPATH env vars are set and the path exists, use it (env(EnvHomeDrive), envPath(EnvHomePath)).tupled .map { case (homeDrive, homePath) => Path(homeDrive).resolve(homePath) } - .flatMapF(checkExists(_)) + .flatMap(checkExists(_)) .flatTapNone( Logger[F].debug(s"$EnvHomeDrive and/or $EnvHomePath is/are not defined, or path does not exist") ) .orElse { // otherwise, of USERPROFILE env var is set envPath(EnvUserProfile) - .flatMapF(checkExists(_)) + .flatMap(checkExists(_)) .flatTapNone( Logger[F].debug(s"$EnvUserProfile is not defined, or path does not exist") ) @@ -279,19 +302,21 @@ object KubeConfig { } } - private def sysProp[F[_]](name: String)(implicit F: Async[F]): OptionT[F, String] = - OptionT(F.delay(Option(System.getProperty(name)).filterNot(_.isEmpty))) + private def sysProp[F[_]: Applicative](name: String): OptionT[F, String] = + OptionT.fromOption( + Option(System.getProperty(name)).filterNot(_.isEmpty) + ) - private def env[F[_]](name: String)(implicit F: Async[F]): OptionT[F, String] = - OptionT(F.delay(Option(System.getenv(name)).filterNot(_.isEmpty))) + private def env[F[_]: Env: Applicative](name: String): OptionT[F, String] = + OptionT(Env[F].get(name)).filterNot(_.isEmpty) - private def path[F[_]: Async](path: String): OptionT[F, Path] = + private def path[F[_]: Applicative](path: String): OptionT[F, Path] = OptionT.pure[F](Path(path)) - private def envPath[F[_]: Async](name: String): OptionT[F, Path] = + private def envPath[F[_]: Env: Applicative](name: String): OptionT[F, Path] = env(name).map(Path(_)) - private def checkExists[F[_]: Async](path: Path): F[Option[Path]] = - Files[F].exists(path).map(if (_) Option(path) else none) // Option.when does not exist in scala 2.12 + private def checkExists[F[_]: Files: Applicative](path: Path): OptionT[F, Path] = + OptionT(Files[F].exists(path).map(if (_) path.some else none)) // Option.when does not exist in scala 2.12 } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala index 812d3fa1..5bd60bea 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/KubernetesClient.scala @@ -1,21 +1,25 @@ package com.goyeau.kubernetes.client +import cats.Monad import cats.syntax.all.* +import cats.effect.syntax.all.* import cats.data.OptionT import cats.effect.* +import cats.effect.std.Env import com.goyeau.kubernetes.client.api.* import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} -import com.goyeau.kubernetes.client.util.SslContexts import com.goyeau.kubernetes.client.util.cache.{AuthorizationParse, ExecToken} import io.circe.{Decoder, Encoder} import org.http4s.client.Client +import org.http4s.client.websocket.WSClient import org.http4s.headers.Authorization -import org.http4s.jdkhttpclient.{JdkHttpClient, JdkWSClient, WSClient} import org.typelevel.log4cats.Logger +import fs2.io.process.Processes +import fs2.io.file.Files +import fs2.io.net.Network +import org.http4s.ember.client.EmberClientBuilder -import java.net.http.HttpClient - -class KubernetesClient[F[_]: Async: Logger]( +class KubernetesClient[F[_]: Async: Files: Logger]( httpClient: Client[F], wsClient: WSClient[F], config: KubeConfig[F], @@ -65,14 +69,16 @@ class KubernetesClient[F[_]: Async: Logger]( } -object KubernetesClient { - def apply[F[_]: Async: Logger](config: KubeConfig[F]): Resource[F, KubernetesClient[F]] = +object KubernetesClient extends PlatformSpecific { + + private[client] def create[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F], + clients: KubeConfig[F] => Resource[F, Clients[F]], + adaptClients: Clients[F] => Resource[F, Clients[F]] + ): Resource[F, KubernetesClient[F]] = for { - client <- Resource.eval { - Sync[F].delay(HttpClient.newBuilder().sslContext(SslContexts.fromConfig(config)).build()) - } - httpClient <- JdkHttpClient[F](client) - wsClient <- JdkWSClient[F](client) + clients <- clients(config) + clients <- adaptClients(clients) authorization <- Resource.eval { OptionT .fromOption(config.authorization) @@ -98,12 +104,56 @@ object KubernetesClient { .value } } yield new KubernetesClient( - httpClient, - wsClient, + clients.httpClient, + clients.wsClient, config, authorization ) - def apply[F[_]: Async: Logger](config: F[KubeConfig[F]]): Resource[F, KubernetesClient[F]] = - Resource.eval(config).flatMap(apply(_)) + def ember[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F], + adaptClients: Clients[F] => Resource[F, Clients[F]] + ): Resource[F, KubernetesClient[F]] = + create(config, emberClients(_), adaptClients) + + def ember[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F] + ): Resource[F, KubernetesClient[F]] = + create(config, emberClients(_), noAdapt[F]) + + def ember[F[_]: Async: Files: Logger: Network: Processes: Env]( + config: F[KubeConfig[F]], + adaptClients: Clients[F] => Resource[F, Clients[F]] + ): Resource[F, KubernetesClient[F]] = + Resource.eval(config).flatMap(ember(_, adaptClients)) + + def ember[F[_]: Async: Files: Logger: Network: Processes: Env]( + config: F[KubeConfig[F]] + ): Resource[F, KubernetesClient[F]] = + Resource.eval(config).flatMap(ember(_, noAdapt[F])) + + @deprecated("use .ember", "0.12.0") + def apply[F[_]: Async: Logger: Files: Network: Processes: Env]( + config: KubeConfig[F] + ): Resource[F, KubernetesClient[F]] = + ember(config) + + @deprecated("use .ember", "0.12.0") + def apply[F[_]: Async: Files: Logger: Network: Processes: Env]( + config: F[KubeConfig[F]] + ): Resource[F, KubernetesClient[F]] = + ember(config) + + private def emberClients[F[_]: Async: Network: Env: Files](config: KubeConfig[F]): Resource[F, Clients[F]] = + for { + tlsContext <- TlsContexts.fromConfig(config) + builderRaw = EmberClientBuilder.default[F] + builder = tlsContext.fold(builderRaw)(builderRaw.withTLSContext) + clients <- builder.buildWebSocket + (http, ws) = clients + } yield Clients(http, ws) + + private[client] def noAdapt[F[_]: Monad]: Clients[F] => Resource[F, Clients[F]] = + (c: Clients[F]) => c.pure[F].toResource + } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourcesApi.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourcesApi.scala index 47557bfc..80a8424e 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourcesApi.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/api/CustomResourcesApi.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.api +import cats.syntax.all.* import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} @@ -53,9 +54,8 @@ private[client] class NamespacedCustomResourcesApi[F[_], A, B]( val resourceUri: Uri = uri"/apis" / context.group / context.version / "namespaces" / namespace / context.plural def updateStatus(name: String, resource: CustomResource[A, B]): F[Status] = - httpClient.status( - Request[F](PUT, config.server.resolve(resourceUri / name / "status")) - .withEntity(resource) - .withOptionalAuthorization(authorization) - ) + Request[F](PUT, config.server.resolve(resourceUri / name / "status")) + .withEntity(resource) + .withOptionalAuthorization(authorization) + .flatMap(httpClient.status(_)) } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/api/PodsApi.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/api/PodsApi.scala index 0d6c7bab..6961bc4d 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/api/PodsApi.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/api/PodsApi.scala @@ -1,6 +1,7 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, Resource} +import cats.effect.Async +import cats.effect.syntax.all.* import cats.syntax.all.* import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.api.ExecRouting.* @@ -18,15 +19,17 @@ import org.http4s.* import org.http4s.client.Client import org.http4s.headers.Authorization import org.http4s.implicits.* -import org.http4s.jdkhttpclient.* +import org.http4s.client.websocket.WSClient +import org.http4s.client.websocket.WSRequest import org.typelevel.ci.CIString import org.typelevel.log4cats.Logger import scodec.bits.ByteVector -import java.nio.file.Path as JPath import scala.concurrent.duration.DurationInt +import org.http4s.client.websocket.WSFrame +import org.http4s.client.websocket.WSDataFrame -private[client] class PodsApi[F[_]: Logger]( +private[client] class PodsApi[F[_]: Files: Logger]( val httpClient: Client[F], wsClient: WSClient[F], val config: KubeConfig[F], @@ -67,7 +70,7 @@ private[client] object ExecRouting { val StatusId: Byte = 3.byteValue } -private[client] class NamespacedPodsApi[F[_]]( +private[client] class NamespacedPodsApi[F[_]: Files]( val httpClient: Client[F], wsClient: WSClient[F], val config: KubeConfig[F], @@ -113,24 +116,13 @@ private[client] class NamespacedPodsApi[F[_]]( ("container" -> container) ++? ("command" -> commands) - WSRequest(uri, method = Method.POST) - .withOptionalAuthorization(authorization) - .map { r => - r.copy( - headers = r.headers.put(Header.Raw(CIString("Sec-WebSocket-Protocol"), "v4.channel.k8s.io")) - ) - } + WSRequest( + uri, + headers = Headers(Header.Raw(CIString("Sec-WebSocket-Protocol"), "v4.channel.k8s.io")), + method = Method.POST + ).withOptionalAuthorization(authorization) } - @deprecated("Use download() which uses fs2.io.file.Path", "0.8.2") - def downloadFile( - podName: String, - sourceFile: JPath, - destinationFile: JPath, - container: Option[String] = None - ): F[(List[StdErr], Option[ErrorOrStatus])] = - download(podName, Path.fromNioPath(sourceFile), Path.fromNioPath(destinationFile), container) - def download( podName: String, sourceFile: Path, @@ -141,35 +133,21 @@ private[client] class NamespacedPodsApi[F[_]]( F.ref(List.empty[StdErr]), F.ref(none[ErrorOrStatus]) ).tupled.flatMap { case (stdErr, errorOrStatus) => - execRequest(podName, Seq("sh", "-c", s"cat ${sourceFile.toString}"), container).flatMap { request => - wsClient.connectHighLevel(request).use { connection => - connection.receiveStream - .through(processWebSocketData) - .evalMapFilter[F, Chunk[Byte]] { - case Left(StdOut(data)) => Chunk.array(data).some.pure[F] - case Left(e: StdErr) => stdErr.update(e :: _).as(None) - case Right(statusOrError) => errorOrStatus.update(_.orElse(statusOrError.some)).as(None) - } - .unchunks - .through(Files[F].writeAll(destinationFile)) - .compile - .drain - .flatMap { _ => - (stdErr.get.map(_.reverse), errorOrStatus.get).tupled - } + execStream(podName, container, Seq("sh", "-c", s"cat ${sourceFile.toString}")) + .evalMapFilter[F, Chunk[Byte]] { + case Left(StdOut(data)) => Chunk.array(data).some.pure[F] + case Left(e: StdErr) => stdErr.update(e :: _).as(None) + case Right(statusOrError) => errorOrStatus.update(_.orElse(statusOrError.some)).as(None) + } + .unchunks + .through(Files[F].writeAll(destinationFile)) + .compile + .drain + .flatMap { _ => + (stdErr.get.map(_.reverse), errorOrStatus.get).tupled } - } } - @deprecated("Use upload() which uses fs2.io.file.Path", "0.8.2") - def uploadFile( - podName: String, - sourceFile: JPath, - destinationFile: JPath, - container: Option[String] = None - ): F[(List[StdErr], Option[ErrorOrStatus])] = - upload(podName, Path.fromNioPath(sourceFile), Path.fromNioPath(destinationFile), container) - def upload( podName: String, sourceFile: Path, @@ -178,16 +156,7 @@ private[client] class NamespacedPodsApi[F[_]]( ): F[(List[StdErr], Option[ErrorOrStatus])] = { val mkDirResult = destinationFile.parent match { case Some(dir) => - execRequest( - podName, - Seq("sh", "-c", s"mkdir -p $dir"), - container - ).flatMap { mkDirRequest => - wsClient - .connectHighLevel(mkDirRequest) - .map(conn => F.delay(conn.receiveStream.through(processWebSocketData))) - .use(_.flatMap(foldErrorStream)) - } + foldErrorStream(execStream(podName, container, Seq("sh", "-c", s"mkdir -p $dir"))) case None => (List.empty -> None).pure } @@ -200,37 +169,34 @@ private[client] class NamespacedPodsApi[F[_]]( ) val uploadFileResult = - uploadRequest.flatMap { uploadRequest => - wsClient.connectHighLevel(uploadRequest).use { connection => - val source = Files[F].readAll(sourceFile, 4096, Flags.Read) - val sendData = source - .mapChunks(chunk => Chunk(WSFrame.Binary(ByteVector(chunk.toChain.prepend(StdInId).toVector)))) - .through(connection.sendPipe) - val retryAttempts = 5 - val sendWithRetry = Stream - .retry(sendData.compile.drain, delay = 500.millis, nextDelay = _ * 2, maxAttempts = retryAttempts) - .onError { case e => - Stream.eval(Logger[F].error(e)(s"Failed send file data after $retryAttempts attempts")) - } - - val result = for { - signal <- Stream.eval(SignallingRef[F, Boolean](false)) - dataStream = sendWithRetry *> Stream.eval(signal.set(true)) - - output = connection.receiveStream - .through( - processWebSocketData - ) - .interruptWhen(signal) - .concurrently(dataStream) - - errors = foldErrorStream( - output - ).map { case (errors, _) => errors } - } yield errors - - result.compile.lastOrError.flatten - } + uploadRequest.toResource.flatMap(wsClient.connectHighLevel).use { connection => + val source = Files[F].readAll(sourceFile, 4096, Flags.Read) + val sendData = source + .mapChunks(chunk => Chunk(WSFrame.Binary(ByteVector(chunk.toChain.prepend(StdInId).toVector)))) + .through(connection.sendPipe) + val retryAttempts = 5 + val sendWithRetry = Stream + .retry(sendData.compile.drain, delay = 500.millis, nextDelay = _ * 2, maxAttempts = retryAttempts) + .onError { case e => + Stream.eval(Logger[F].error(e)(s"Failed send file data after $retryAttempts attempts")) + } + + val result = for { + signal <- Stream.eval(SignallingRef[F, Boolean](false)) + dataStream = sendWithRetry *> Stream.eval(signal.set(true)) + + output = connection.receiveStream + .through(skipConnectionClosedErrors) + .through(processWebSocketData) + .interruptWhen(signal) + .concurrently(dataStream) + + errors = foldErrorStream( + output + ).map { case (errors, _) => errors } + } yield errors + + result.compile.lastOrError.flatten } for { @@ -248,12 +214,15 @@ private[client] class NamespacedPodsApi[F[_]]( stdout: Boolean = true, stderr: Boolean = true, tty: Boolean = false - ): Resource[F, F[Stream[F, Either[ExecStream, ErrorOrStatus]]]] = - Resource.eval(execRequest(podName, command, container, stdin, stdout, stderr, tty)).flatMap { request => - wsClient.connectHighLevel(request).map { connection => - F.delay(connection.receiveStream.through(processWebSocketData)) + ): Stream[F, Either[ExecStream, ErrorOrStatus]] = + Stream + .eval(execRequest(podName, command, container, stdin, stdout, stderr, tty)) + .flatMap(request => Stream.resource(wsClient.connectHighLevel(request))) + .flatMap { connection => + connection.receiveStream + .through(skipConnectionClosedErrors) + .through(processWebSocketData) } - } def exec( podName: String, @@ -264,11 +233,32 @@ private[client] class NamespacedPodsApi[F[_]]( stderr: Boolean = true, tty: Boolean = false ): F[(List[ExecStream], Option[ErrorOrStatus])] = - execStream(podName, container, command, stdin, stdout, stderr, tty).use(_.flatMap(foldStream)) + foldStream(execStream(podName, container, command, stdin, stdout, stderr, tty)) + + private def skipConnectionClosedErrors: Pipe[F, WSDataFrame, WSDataFrame] = + _.map(_.some) + .recover { + // Need to handle (and ignore) this exception + // + // Because of the "conflict" between the http4s WS client and + // the underlying JDK WS client (which are both high-level clients) + // an extra "Close" frame gets sent to the server, potentially + // after the TCP connection is closed, which causes this exception. + // + // This will be solved in a later version of the http4s (core or jdk). + case e: java.io.IOException if e.getMessage == "closed output" => none + } + .recover { + // Temporary hack to stop ember streams from exploding. + // + // This will hopefully be solved in a later version of the http4s (ember). + case e: Exception if e.getMessage == "Connection already closed" => none + } + .unNone private def foldStream( stdoutStream: Stream[F, Either[ExecStream, ErrorOrStatus]] - ) = + ): F[(List[ExecStream], Option[ErrorOrStatus])] = stdoutStream.compile.fold((List.empty[ExecStream], none[ErrorOrStatus])) { case ((accEvents, accStatus), data) => data match { case Left(event) => @@ -280,7 +270,7 @@ private[client] class NamespacedPodsApi[F[_]]( private def foldErrorStream( stdoutStream: Stream[F, Either[ExecStream, ErrorOrStatus]] - ) = + ): F[(List[StdErr], Option[ErrorOrStatus])] = stdoutStream.compile.fold((List.empty[StdErr], none[ErrorOrStatus])) { case ((accEvents, accStatus), data) => data match { case Left(event) => diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala index ebb6b525..4396dbb1 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/api/RawApi.scala @@ -5,8 +5,8 @@ import cats.effect.{Async, Resource} import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.operation.* import org.http4s.client.Client +import org.http4s.client.websocket.{WSClient, WSConnectionHighLevel, WSRequest} import org.http4s.headers.Authorization -import org.http4s.jdkhttpclient.{WSClient, WSConnectionHighLevel, WSRequest} import org.http4s.{Request, Response} private[client] class RawApi[F[_]]( @@ -19,14 +19,9 @@ private[client] class RawApi[F[_]]( def runRequest( request: Request[F] ): Resource[F, Response[F]] = - Request[F]( - method = request.method, - uri = config.server.resolve(request.uri), - httpVersion = request.httpVersion, - headers = request.headers, - body = request.body, - attributes = request.attributes - ).withOptionalAuthorization(authorization) + request + .withUri(config.server.resolve(request.uri)) + .withOptionalAuthorization(authorization) .toResource .flatMap(httpClient.run) @@ -34,7 +29,7 @@ private[client] class RawApi[F[_]]( request: WSRequest ): Resource[F, WSConnectionHighLevel[F]] = request - .copy(uri = config.server.resolve(request.uri)) + .withUri(config.server.resolve(request.uri)) .withOptionalAuthorization(authorization) .toResource .flatMap { request => diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/api/SecretsApi.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/api/SecretsApi.scala index 1bc24d78..deef2e88 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/api/SecretsApi.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/api/SecretsApi.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.api +import cats.syntax.all.* import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.operation.* @@ -10,8 +11,6 @@ import org.http4s.headers.Authorization import org.http4s.implicits.* import org.http4s.{Status, Uri} -import java.util.Base64 - private[client] class SecretsApi[F[_]]( val httpClient: Client[F], val config: KubeConfig[F], @@ -47,13 +46,30 @@ private[client] class NamespacedSecretsApi[F[_]]( with Watchable[F, Secret] { val resourceUri: Uri = uri"/api" / "v1" / "namespaces" / namespace / "secrets" - def createEncode(resource: Secret): F[Status] = create(encode(resource)) + def createEncode(resource: Secret): F[Status] = encode(resource).flatMap(create) def createOrUpdateEncode(resource: Secret): F[Status] = - createOrUpdate(encode(resource)) + encode(resource).flatMap(createOrUpdate) + + private def encode(resource: Secret): F[Secret] = + resource.data.fold( + resource.pure[F] + ) { data => + data.toSeq + .traverse { case (k, v) => + fs2.Stream + .emit(v) + .covary[F] + .through(fs2.text.utf8.encode) + .through(fs2.text.base64.encode) + .foldMonoid + .compile + .lastOrError + .map { encoded => + k -> encoded + } + } + .map(encoded => resource.copy(data = encoded.toMap.some)) + } - private def encode(resource: Secret) = - resource.copy(data = resource.data.map(_.map { case (k, v) => - k -> Base64.getEncoder.encodeToString(v.getBytes) - })) } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrArray.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrArray.scala index ffbc99ac..2a902c72 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrArray.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrArray.scala @@ -5,7 +5,7 @@ import io.circe.syntax.* import io.circe.{Decoder, Encoder} import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps -trait JSONSchemaPropsOrArray +sealed trait JSONSchemaPropsOrArray case class SchemaNotArrayValue(value: JSONSchemaProps) extends JSONSchemaPropsOrArray case class ArrayValue(value: Array[JSONSchemaProps]) extends JSONSchemaPropsOrArray diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrBool.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrBool.scala index a1d9ee4a..d08a6e83 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrBool.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrBool.scala @@ -5,7 +5,7 @@ import io.circe.syntax.* import io.circe.{Decoder, Encoder, Json} import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps -trait JSONSchemaPropsOrBool +sealed trait JSONSchemaPropsOrBool case class SchemaNotBoolValue(value: JSONSchemaProps) extends JSONSchemaPropsOrBool case class BoolValue(value: Boolean) extends JSONSchemaPropsOrBool diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrStringArray.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrStringArray.scala index c269e646..9108281d 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrStringArray.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/crd/JSONSchemaPropsOrStringArray.scala @@ -5,7 +5,7 @@ import io.circe.syntax.* import io.circe.{Decoder, Encoder} import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.JSONSchemaProps -trait JSONSchemaPropsOrStringArray +sealed trait JSONSchemaPropsOrStringArray case class SchemaNotStringArrayValue(value: JSONSchemaProps) extends JSONSchemaPropsOrStringArray case class StringArrayValue(value: Array[String]) extends JSONSchemaPropsOrStringArray diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Creatable.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Creatable.scala index de9f3b84..358fe5a2 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Creatable.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Creatable.scala @@ -23,10 +23,10 @@ private[client] trait Creatable[F[_], Resource <: { def metadata: Option[ObjectM implicit protected def resourceDecoder: Decoder[Resource] def create(resource: Resource): F[Status] = - httpClient.status(buildRequest(resource)) + buildRequest(resource).flatMap(httpClient.status(_)) def createWithResource(resource: Resource): F[Resource] = - httpClient.expect[Resource](buildRequest(resource)) + buildRequest(resource).flatMap(httpClient.expect[Resource](_)) private def buildRequest(resource: Resource) = Request[F](POST, config.server.resolve(resourceUri)) @@ -35,10 +35,11 @@ private[client] trait Creatable[F[_], Resource <: { def metadata: Option[ObjectM def createOrUpdate(resource: Resource): F[Status] = { val fullResourceUri = config.server.resolve(resourceUri) / resource.metadata.get.name.get - def update = httpClient.status(buildRequest(resource, fullResourceUri)) + def update = buildRequest(resource, fullResourceUri).flatMap(httpClient.status(_)) - httpClient - .status(Request[F](GET, fullResourceUri).withOptionalAuthorization(authorization)) + Request[F](GET, fullResourceUri) + .withOptionalAuthorization(authorization) + .flatMap(httpClient.status(_)) .flatMap { case status if status.isSuccess => update case Status.NotFound => @@ -52,7 +53,7 @@ private[client] trait Creatable[F[_], Resource <: { def metadata: Option[ObjectM def createOrUpdateWithResource(resource: Resource): F[Resource] = { val fullResourceUri = config.server.resolve(resourceUri) / resource.metadata.get.name.get - def updateWithResource = httpClient.expect[Resource](buildRequest(resource, fullResourceUri)) + def updateWithResource = buildRequest(resource, fullResourceUri).flatMap(httpClient.expect[Resource](_)) httpClient .expectOptionF[Resource]( diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Deletable.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Deletable.scala index 5663991f..5c10b142 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Deletable.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Deletable.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.operation +import cats.syntax.all.* import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.util.CirceEntityCodec.* @@ -22,10 +23,9 @@ private[client] trait Deletable[F[_]] { maybeOptions.fold(Entity[F](EmptyBody.covary[F], Some(0L)))(EntityEncoder[F, DeleteOptions].toEntity(_)) } - httpClient.status( - Request[F](method = DELETE, uri = config.server.resolve(resourceUri) / name) - .withEntity(deleteOptions)(encoder) - .withOptionalAuthorization(authorization) - ) + Request[F](method = DELETE, uri = config.server.resolve(resourceUri) / name) + .withEntity(deleteOptions)(encoder) + .withOptionalAuthorization(authorization) + .flatMap(httpClient.status(_)) } } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/GroupDeletable.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/GroupDeletable.scala index b81207a5..7049d872 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/GroupDeletable.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/GroupDeletable.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.operation +import cats.syntax.all.* import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.util.Uris.addLabels @@ -15,8 +16,10 @@ private[client] trait GroupDeletable[F[_]] { protected def authorization: Option[F[Authorization]] protected def resourceUri: Uri - def deleteAll(labels: Map[String, String] = Map.empty): F[Status] = { - val uri = addLabels(labels, config.server.resolve(resourceUri)) - httpClient.status(Request[F](DELETE, uri).withOptionalAuthorization(authorization)) - } + def deleteAll(labels: Map[String, String] = Map.empty): F[Status] = + Request[F]( + DELETE, + uri = addLabels(labels, config.server.resolve(resourceUri)) + ).withOptionalAuthorization(authorization).flatMap(httpClient.status(_)) + } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Proxy.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Proxy.scala index 625a2bd6..75dbca42 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Proxy.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Proxy.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.operation +import cats.syntax.all.* import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig import com.goyeau.kubernetes.client.operation.* @@ -23,14 +24,12 @@ private[client] trait Proxy[F[_]] { contentType: `Content-Type` = `Content-Type`(MediaType.text.plain), data: Option[String] = None ): F[String] = - httpClient.expect[String]( - Request( - method, - (config.server.resolve(resourceUri) / name / "proxy").addPath(path.toRelative.renderString), - body = data.fold[EntityBody[F]](EmptyBody)( - implicitly[EntityEncoder[F, String]].withContentType(contentType).toEntity(_).body - ) - ).withOptionalAuthorization(authorization) - )(EntityDecoder.text) + Request( + method, + (config.server.resolve(resourceUri) / name / "proxy").addPath(path.toRelative.renderString), + body = data.fold[EntityBody[F]](EmptyBody)( + implicitly[EntityEncoder[F, String]].withContentType(contentType).toEntity(_).body + ) + ).withOptionalAuthorization(authorization).flatMap(httpClient.expect(_)(EntityDecoder.text)) } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Replaceable.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Replaceable.scala index 33ec79b4..a7af504e 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Replaceable.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/Replaceable.scala @@ -1,5 +1,6 @@ package com.goyeau.kubernetes.client.operation +import cats.syntax.all.* import scala.language.reflectiveCalls import cats.effect.Async import com.goyeau.kubernetes.client.KubeConfig @@ -21,10 +22,10 @@ private[client] trait Replaceable[F[_], Resource <: { def metadata: Option[Objec implicit protected def resourceDecoder: Decoder[Resource] def replace(resource: Resource): F[Status] = - httpClient.status(buildRequest(resource)) + buildRequest(resource).flatMap(httpClient.status(_)) def replaceWithResource(resource: Resource): F[Resource] = - httpClient.expect[Resource](buildRequest(resource)) + buildRequest(resource).flatMap(httpClient.expect[Resource](_)) private def buildRequest(resource: Resource) = Request[F](PUT, config.server.resolve(resourceUri) / resource.metadata.get.name.get) diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/package.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/package.scala index 6da6386f..8f07da0b 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/operation/package.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/operation/package.scala @@ -5,7 +5,7 @@ import cats.syntax.all.* import cats.{Applicative, FlatMap} import org.http4s.client.Client import org.http4s.headers.Authorization -import org.http4s.jdkhttpclient.WSRequest +import org.http4s.client.websocket.WSRequest import org.http4s.{EntityDecoder, Request, Response} package object operation { @@ -22,7 +22,7 @@ package object operation { def withOptionalAuthorization(authorization: Option[F[Authorization]]): F[WSRequest] = authorization.fold(request.pure[F]) { authorization => authorization.map { auth => - request.copy(headers = request.headers.put(auth)) + request.withHeaders(request.headers.put(auth)) } } } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/Text.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/Text.scala index b30b6c42..bb8a48a7 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/Text.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/Text.scala @@ -1,12 +1,12 @@ package com.goyeau.kubernetes.client.util -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.all.* import fs2.io.file.{Files, Path} object Text { - def readFile[F[_]: Sync: Files](path: Path): F[String] = + def readFile[F[_]: Concurrent: Files](path: Path): F[String] = Files[F].readAll(path).through(fs2.text.utf8.decode).compile.toList.map(_.mkString) } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/Yamls.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/Yamls.scala index 0b13ee57..93297631 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/Yamls.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/Yamls.scala @@ -1,11 +1,11 @@ package com.goyeau.kubernetes.client.util -import cats.effect.Sync +import cats.effect.Concurrent import cats.implicits.* import com.goyeau.kubernetes.client.KubeConfig import fs2.io.file.{Files, Path} import io.circe.generic.semiauto.* -import io.circe.yaml.parser.* +import io.circe.scalayaml.parser.* import io.circe.{Codec, Decoder, Encoder} import org.http4s.Uri import org.typelevel.log4cats.Logger @@ -46,7 +46,7 @@ case class AuthInfoExec( apiVersion: String, command: String, env: Option[Seq[AuthInfoExecEnv]], - args: Option[Seq[String]], + args: Option[List[String]], installHint: Option[String], provideClusterInfo: Option[Boolean], interactiveMode: Option[String] @@ -63,17 +63,20 @@ case class ExecCredentialStatus( private[client] object Yamls { - def fromKubeConfigFile[F[_]: Sync: Logger: Files](kubeconfig: Path, contextMaybe: Option[String]): F[KubeConfig[F]] = + def fromKubeConfigFile[F[_]: Concurrent: Logger: Files]( + kubeconfig: Path, + contextMaybe: Option[String] + ): F[KubeConfig[F]] = for { configString <- Text.readFile(kubeconfig) - configJson <- Sync[F].fromEither(parse(configString)) - config <- Sync[F].fromEither(configJson.as[Config]) + configJson <- Concurrent[F].fromEither(parse(configString)) + config <- Concurrent[F].fromEither(configJson.as[Config]) contextName = contextMaybe.getOrElse(config.`current-context`) namedContext <- config.contexts .find(_.name == contextName) .liftTo[F](new IllegalArgumentException(s"Can't find context named $contextName in $kubeconfig")) - _ <- Logger[F].debug(s"KubeConfig with context ${namedContext.name}") + _ <- Logger[F].debug(s"KubeConfig: $kubeconfig with context ${namedContext.name}") context = namedContext.context namedCluster <- @@ -88,7 +91,7 @@ private[client] object Yamls { .liftTo[F](new IllegalArgumentException(s"Can't find user named ${context.user} in $kubeconfig")) user = namedAuthInfo.user - server <- Sync[F].fromEither(Uri.fromString(cluster.server)) + server <- Concurrent[F].fromEither(Uri.fromString(cluster.server)) config <- KubeConfig.of[F]( server = server, caCertData = cluster.`certificate-authority-data`, @@ -99,6 +102,9 @@ private[client] object Yamls { clientKeyFile = user.`client-key`.map(Path(_)), authInfoExec = user.exec ) + _ <- Logger[F].debug( + s"KubeConfig created, context: $contextName, cluster: ${context.cluster}, user: ${context.user}, server: $server" + ) } yield config implicit lazy val configDecoder: Decoder[Config] = deriveDecoder diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationCache.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationCache.scala index c69c4f01..133ebfc8 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationCache.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationCache.scala @@ -1,11 +1,10 @@ package com.goyeau.kubernetes.client.util package cache -import cats.effect.Async +import cats.effect.{Clock, Concurrent} import cats.syntax.all.* import org.http4s.headers.Authorization import org.typelevel.log4cats.Logger -import scala.compat.java8.DurationConverters.* import scala.concurrent.duration.* @@ -17,11 +16,11 @@ private[client] trait AuthorizationCache[F[_]] { object AuthorizationCache { - def apply[F[_]: Logger]( + def apply[F[_]: Logger: Clock: Concurrent]( retrieve: F[AuthorizationWithExpiration], refreshBeforeExpiration: FiniteDuration = 0.seconds - )(implicit F: Async[F]): F[AuthorizationCache[F]] = - F.ref(Option.empty[AuthorizationWithExpiration]).map { cache => + ): F[AuthorizationCache[F]] = + Concurrent[F].ref(Option.empty[AuthorizationWithExpiration]).map { cache => new AuthorizationCache[F] { override def get: F[Authorization] = { @@ -37,18 +36,18 @@ object AuthorizationCache { cache.get .flatMap { case Some(cached) => - F.realTimeInstant + Clock[F].realTime .flatMap { now => - val minExpiry = now.plus(refreshBeforeExpiration.toJava) - val shouldRenew = cached.expirationTimestamp.exists(_.isBefore(minExpiry)) + val minExpiry = now.plus(refreshBeforeExpiration) + val shouldRenew = cached.expirationTimestamp.exists(_ < minExpiry) if (shouldRenew) getAndCacheToken.flatMap { case Some(token) => token.pure[F] case None => - val expired = cached.expirationTimestamp.exists(_.isBefore(now)) + val expired = cached.expirationTimestamp.exists(_ < now) Logger[F] .debug(s"using the cached token (expired: $expired)") >> - F.raiseError[AuthorizationWithExpiration]( + Concurrent[F].raiseError[AuthorizationWithExpiration]( new IllegalStateException( s"failed to retrieve a new authorization token, cached token has expired" ) @@ -60,7 +59,7 @@ object AuthorizationCache { case None => getAndCacheToken.flatMap[AuthorizationWithExpiration] { case Some(token) => token.pure[F] - case None => F.raiseError(new IllegalStateException(s"no authorization token")) + case None => Concurrent[F].raiseError(new IllegalStateException(s"no authorization token")) } } .map(_.authorization) diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationParse.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationParse.scala index 70a7c698..4a9d2397 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationParse.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationParse.scala @@ -8,11 +8,7 @@ import io.circe.generic.semiauto.deriveCodec import io.circe.parser.* import org.http4s.{AuthScheme, Credentials} import org.http4s.headers.Authorization - -import java.nio.charset.StandardCharsets -import java.time.Instant -import java.util.Base64 -import scala.util.Try +import scala.concurrent.duration.* private[util] case class JwtPayload( exp: Option[Long] @@ -22,26 +18,42 @@ object AuthorizationParse { implicit private val jwtPayloadCodec: Codec[JwtPayload] = deriveCodec - private val base64 = Base64.getDecoder - def apply[F[_]](retrieve: F[Authorization])(implicit F: Async[F]): F[AuthorizationWithExpiration] = - retrieve.map { token => - val expirationTimestamp = - token match { + retrieve + .flatMap { token => + (token match { case Authorization(Credentials.Token(AuthScheme.Bearer, token)) => token.split('.') match { case Array(_, payload, _) => - Try(new String(base64.decode(payload), StandardCharsets.US_ASCII)).toOption - .flatMap(payload => decode[JwtPayload](payload).toOption) - .flatMap(_.exp) - .map(Instant.ofEpochSecond) + fs2.Stream + .emit(payload) + .covary[F] + .through(fs2.text.base64.decode) + .through(fs2.text.utf8.decode) + .compile + .last + .flatMap { + case Some(payload) => + F + .fromEither(decode[JwtPayload](payload)) + .map(_.exp) + .flatMap { + case Some(expiration) => F.delay(expiration.seconds.some) + case None => none[FiniteDuration].pure[F] + } + + case None => + none[FiniteDuration].pure[F] + + } case _ => - none + none[FiniteDuration].pure[F] } - case _ => none + case _ => none[FiniteDuration].pure[F] + }).map { expirationTimestamp => + AuthorizationWithExpiration(expirationTimestamp = expirationTimestamp, authorization = token) } - AuthorizationWithExpiration(expirationTimestamp = expirationTimestamp, authorization = token) - } + } } diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationWithExpiration.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationWithExpiration.scala index 46ab400a..28655be0 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationWithExpiration.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/AuthorizationWithExpiration.scala @@ -2,9 +2,9 @@ package com.goyeau.kubernetes.client.util.cache import org.http4s.headers.Authorization -import java.time.Instant +import scala.concurrent.duration.FiniteDuration case class AuthorizationWithExpiration( - expirationTimestamp: Option[Instant], + expirationTimestamp: Option[FiniteDuration], authorization: Authorization ) diff --git a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/ExecToken.scala b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/ExecToken.scala index cdb49e26..5b10f014 100644 --- a/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/ExecToken.scala +++ b/kubernetes-client/src/com/goyeau/kubernetes/client/util/cache/ExecToken.scala @@ -10,58 +10,66 @@ import org.http4s.AuthScheme import org.http4s.Credentials.Token import org.http4s.headers.Authorization import org.typelevel.log4cats.Logger +import fs2.io.process.Processes +import fs2.io.process.ProcessBuilder -import java.time.Instant -import scala.sys.process.Process -import scala.util.control.NonFatal +import scala.concurrent.duration.* private[client] object ExecToken { - def apply[F[_]: Logger](exec: AuthInfoExec)(implicit F: Async[F]): F[AuthorizationWithExpiration] = - F - .blocking { - val env = exec.env.getOrElse(Seq.empty).map(e => e.name -> e.value) - val cmd = Seq.concat( - Seq(exec.command), - exec.args.getOrElse(Seq.empty) - ) - Process(cmd, None, env*).!! + private def parseTimestamp[F[_]: Async](s: String): F[FiniteDuration] = Async[F].delay { + java.time.Instant.parse(s).toEpochMilli.milliseconds + } + + def apply[F[_]: Logger: Processes](exec: AuthInfoExec)(implicit F: Async[F]): F[AuthorizationWithExpiration] = { + val env = exec.env.getOrElse(Seq.empty).view.map(e => e.name -> e.value).toMap + + val processBuilder = ProcessBuilder( + command = exec.command, + args = exec.args.getOrElse(List.empty) + ).withExtraEnv(env) + + processBuilder + .spawn[F] + .use { p => + p.stdout + .through(fs2.text.utf8.decode) + .compile + .string + .flatMap(output => F.fromEither(decode[ExecCredential](output))) + .flatMap { execCredential => + execCredential.status.token match { + case Some(token) => + parseTimestamp[F](execCredential.status.expirationTimestamp) + .adaptError { error => + new IllegalArgumentException( + s"Failed to parse `.status.expirationTimestamp`: ${execCredential.status.expirationTimestamp}: ${error.getMessage}", + error + ) + } + .map { expirationTimestamp => + AuthorizationWithExpiration( + expirationTimestamp = expirationTimestamp.some, + authorization = Authorization(Token(AuthScheme.Bearer, token)) + ) + } + case None => + F.raiseError[AuthorizationWithExpiration]( + new UnsupportedOperationException( + "Missing `.status.token` in the credentials plugin output: client certificate/client key is not supported, token is required" + ) + ) + } + } } .onError { case e: IOException => Logger[F].error( - s"Failed to execute the credentials plugin: ${exec.command}: ${e.getMessage}.${exec.installHint + s"Failed to execute the credentials plugin: ${exec.command}${exec.args + .fold("")(_.mkString(" ", " ", ""))}: ${e.getMessage}.${exec.installHint .fold("")(hint => s"\n$hint")}" ) } - .flatMap { output => - F.fromEither( - decode[ExecCredential](output) - ) - } - .flatMap { execCredential => - execCredential.status.token match { - case Some(token) => - F - .delay(Instant.parse(execCredential.status.expirationTimestamp)) - .adaptError { case NonFatal(error) => - new IllegalArgumentException( - s"Failed to parse `.status.expirationTimestamp`: ${execCredential.status.expirationTimestamp}: ${error.getMessage}", - error - ) - } - .map { expirationTimestamp => - AuthorizationWithExpiration( - expirationTimestamp = expirationTimestamp.some, - authorization = Authorization(Token(AuthScheme.Bearer, token)) - ) - } - case None => - F.raiseError( - new UnsupportedOperationException( - "Missing `.status.token` in the credentials plugin output: client certificate/client key is not supported, token is required" - ) - ) - } - } + + } } diff --git a/kubernetes-client/test/src-js/com/goyeau/kubernetes/client/TestPlatformSpecific.scala b/kubernetes-client/test/src-js/com/goyeau/kubernetes/client/TestPlatformSpecific.scala new file mode 100644 index 00000000..e3ef22c6 --- /dev/null +++ b/kubernetes-client/test/src-js/com/goyeau/kubernetes/client/TestPlatformSpecific.scala @@ -0,0 +1,57 @@ +package com.goyeau.kubernetes.client + +import cats.syntax.all.* +import cats.effect.* +import cats.effect.std.Env +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.console.ConsoleLogger +import org.typelevel.log4cats.LoggerName + +object TestPlatformSpecific { + + def mkClient(implicit L: Logger[IO]): Resource[IO, KubernetesClient[IO]] = + Env[IO].get("KUBE_CONTEXT_NAME").toResource.flatMap { contextOverride => + val kubeConfig = KubeConfig.inHomeDir[IO]( + contextOverride.getOrElse("minikube") + ) + KubernetesClient.ember(kubeConfig) + } + + def getLogger(implicit name: LoggerName): Logger[IO] = { + val console = new ConsoleLogger[IO]() + new Logger[IO] { + + override def trace(t: Throwable)(message: => String): IO[Unit] = + console.trace(t)(s"[${name.value}] $message") + + override def trace(message: => String): IO[Unit] = + console.trace(s"[${name.value}] $message") + + override def debug(t: Throwable)(message: => String): IO[Unit] = + console.debug(t)(s"[${name.value}] $message") + + override def debug(message: => String): IO[Unit] = + console.debug(s"[${name.value}] $message") + + override def info(t: Throwable)(message: => String): IO[Unit] = + console.info(t)(s"[${name.value}] $message") + + override def info(message: => String): IO[Unit] = + console.info(s"[${name.value}] $message") + + override def warn(t: Throwable)(message: => String): IO[Unit] = + console.warn(t)(s"[${name.value}] $message") + + override def warn(message: => String): IO[Unit] = + console.warn(s"[${name.value}] $message") + + override def error(t: Throwable)(message: => String): IO[Unit] = + console.error(t)(s"[${name.value}] $message") + + override def error(message: => String): IO[Unit] = + console.error(s"[${name.value}] $message") + + } + } + +} diff --git a/kubernetes-client/test/src-jvm/com/goyeau/kubernetes/client/TestPlatformSpecific.scala b/kubernetes-client/test/src-jvm/com/goyeau/kubernetes/client/TestPlatformSpecific.scala new file mode 100644 index 00000000..da48abd3 --- /dev/null +++ b/kubernetes-client/test/src-jvm/com/goyeau/kubernetes/client/TestPlatformSpecific.scala @@ -0,0 +1,31 @@ +package com.goyeau.kubernetes.client + +import cats.effect.* +import cats.effect.std.Env +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.slf4j.Slf4jLogger +import org.typelevel.log4cats.LoggerName + +object TestPlatformSpecific { + + def mkClient(implicit L: Logger[IO]): Resource[IO, KubernetesClient[IO]] = + Env[IO].get("KUBE_CONTEXT_NAME").toResource.flatMap { contextOverride => + Env[IO].get("KUBE_CLIENT_IMPLEMENTATION").toResource.flatMap { implementationOverride => + val kubeConfig = KubeConfig.inHomeDir[IO]( + contextOverride.getOrElse("minikube") + ) + implementationOverride.getOrElse("jdk") match { + case "jdk" => KubernetesClient.jdk(kubeConfig) + case "ember" => KubernetesClient.ember(kubeConfig) + case other => + IO.raiseError( + new IllegalArgumentException(s"unknown implementation: $other, specified in KUBE_CLIENT_IMPLEMENTATION") + ).toResource + } + + } + } + + def getLogger(implicit name: LoggerName): Logger[IO] = Slf4jLogger.getLogger[IO] + +} diff --git a/kubernetes-client/test/src-native/com/goyeau/kubernetes/client/TestPlatformSpecific.scala b/kubernetes-client/test/src-native/com/goyeau/kubernetes/client/TestPlatformSpecific.scala new file mode 100644 index 00000000..72dc0648 --- /dev/null +++ b/kubernetes-client/test/src-native/com/goyeau/kubernetes/client/TestPlatformSpecific.scala @@ -0,0 +1,33 @@ +package com.goyeau.kubernetes.client + +import cats.syntax.all.* +import cats.effect.* +import cats.effect.std.Env +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.LoggerName + +object TestPlatformSpecific { + + def mkClient(implicit L: Logger[IO]): Resource[IO, KubernetesClient[IO]] = + Env[IO].get("KUBE_CONTEXT_NAME").toResource.flatMap { contextOverride => + val kubeConfig = KubeConfig.inHomeDir[IO]( + contextOverride.getOrElse("minikube") + ) + KubernetesClient.ember(kubeConfig) + } + + def getLogger(implicit name: LoggerName): Logger[IO] = new Logger[IO] { + def error(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def warn(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def info(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def debug(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def trace(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + + def error(t: Throwable)(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def warn(t: Throwable)(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def info(t: Throwable)(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def debug(t: Throwable)(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + def trace(t: Throwable)(message: => String): IO[Unit] = IO(println(s"[$name] $message")) + } + +} diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/MinikubeClientProvider.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/MinikubeClientProvider.scala new file mode 100644 index 00000000..2c57724b --- /dev/null +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/MinikubeClientProvider.scala @@ -0,0 +1,62 @@ +package com.goyeau.kubernetes.client + +import cats.syntax.all.* +import cats.effect.* +import cats.effect.std.Env +import com.goyeau.kubernetes.client.Utils.retry +import com.goyeau.kubernetes.client.api.NamespacesApiTest +import com.goyeau.kubernetes.client.KubernetesClient +import munit.CatsEffectSuite +import org.typelevel.log4cats.Logger +import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions +import munit.catseffect.IOFixture + +abstract class MinikubeClientProvider extends CatsEffectSuite { + + implicit def logger: Logger[IO] + + val kubernetesClient: Resource[IO, KubernetesClient[IO]] = TestPlatformSpecific.mkClient + + def resourceName: String + + def defaultNamespace: String = resourceName.toLowerCase + + protected val extraNamespace = List.empty[String] + + protected def createNamespace(namespace: String): IO[Unit] = kubernetesClient.use { implicit client => + client.namespaces.deleteTerminated(namespace) *> retry( + NamespacesApiTest.createChecked(namespace), + actionClue = Some(s"Creating '$namespace' namespace") + ) + }.void + + private def deleteNamespace(namespace: String) = kubernetesClient.use { client => + client.namespaces.delete( + namespace, + DeleteOptions(gracePeriodSeconds = 0L.some, propagationPolicy = "Foreground".some).some + ) + }.void + + protected def createNamespaces(): IO[Unit] = { + val ns = defaultNamespace +: extraNamespace + logger.info(s"Creating namespaces: $ns") *> + ns.traverse_(name => createNamespace(name)) + } + + def usingMinikube[T](body: KubernetesClient[IO] => IO[T]): IO[T] = + kubernetesClient.use(body) + + override def munitFixtures: Seq[IOFixture[Unit]] = List( + ResourceSuiteLocalFixture( + name = "namespaces", + Resource.make( + createNamespaces() + ) { _ => + val ns = defaultNamespace +: extraNamespace + logger.info(s"Deleting namespaces: $ns") *> + ns.traverse_(name => deleteNamespace(name)) + } + ) + ) + +} diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/Utils.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/Utils.scala index d96eecbb..fc66325d 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/Utils.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/Utils.scala @@ -1,27 +1,21 @@ package com.goyeau.kubernetes.client -import cats.effect.Temporal -import cats.implicits.* -import cats.{ApplicativeError, Defer} +import cats.effect.* +import cats.syntax.all.* import org.typelevel.log4cats.Logger import scala.concurrent.duration.* object Utils { - def retry[F[_], Result]( - f: F[Result], + def retry[Result]( + f: IO[Result], initialDelay: FiniteDuration = 1.second, maxRetries: Int = 10, actionClue: Option[String] = None, firstRun: Boolean = true - )(implicit - temporal: Temporal[F], - F: ApplicativeError[F, Throwable], - D: Defer[F], - log: Logger[F] - ): F[Result] = + )(implicit log: Logger[IO]): IO[Result] = f .flatTap { _ => - F.whenA(!firstRun)(log.info(s"Succeeded after retrying${actionClue.map(c => s", action: $c").getOrElse("")}")) + IO.whenA(!firstRun)(log.info(s"Succeeded after retrying${actionClue.map(c => s", action: $c").getOrElse("")}")) } .handleErrorWith { exception => val firstLine = exception.getMessage.takeWhile(_ != '\n') @@ -35,12 +29,12 @@ object Utils { log.info( s"$message. Retrying in $initialDelay${actionClue.map(c => s", action: $c").getOrElse("")}. Retries left: $maxRetries" ) *> - temporal.sleep(initialDelay) *> - D.defer(retry(f, initialDelay, maxRetries - 1, actionClue, firstRun = false)) + IO.sleep(initialDelay) *> + IO.defer(retry(f, initialDelay, maxRetries - 1, actionClue, firstRun = false)) else log.info( s"Giving up ${actionClue.map(c => s", action: $c").getOrElse("")}. No retries left" ) *> - F.raiseError(exception) + IO.raiseError(exception) } } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ConfigMapsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ConfigMapsApiTest.scala index 5cb5f44a..f6e229ea 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ConfigMapsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ConfigMapsApiTest.scala @@ -2,25 +2,24 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.core.v1.{ConfigMap, ConfigMapList} import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class ConfigMapsApiTest - extends FunSuite - with CreatableTests[IO, ConfigMap] - with GettableTests[IO, ConfigMap] - with ListableTests[IO, ConfigMap, ConfigMapList] - with ReplaceableTests[IO, ConfigMap] - with DeletableTests[IO, ConfigMap, ConfigMapList] - with WatchableTests[IO, ConfigMap] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[ConfigMap] + with GettableTests[ConfigMap] + with ListableTests[ConfigMap, ConfigMapList] + with ReplaceableTests[ConfigMap] + with DeletableTests[ConfigMap, ConfigMapList] + with WatchableTests[ConfigMap] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[ConfigMap].getSimpleName override def api(implicit client: KubernetesClient[IO]): ConfigMapsApi[IO] = client.configMaps diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ContextProvider.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ContextProvider.scala deleted file mode 100644 index c749adbd..00000000 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ContextProvider.scala +++ /dev/null @@ -1,11 +0,0 @@ -package com.goyeau.kubernetes.client.api - -import cats.Parallel -import cats.effect.unsafe.implicits.global -import cats.effect.IO - -trait ContextProvider { - def unsafeRunSync[A](f: IO[A]): A = f.unsafeRunSync() - - implicit lazy val parallel: Parallel[IO] = IO.parallelForIO -} diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CronJobsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CronJobsApiTest.scala index 02b69e8b..a2be46f5 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CronJobsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CronJobsApiTest.scala @@ -1,29 +1,28 @@ package com.goyeau.kubernetes.client.api import cats.syntax.all.* -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.operation.* import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.batch.v1.{CronJob, CronJobList, CronJobSpec, JobSpec, JobTemplateSpec} import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class CronJobsApiTest - extends FunSuite - with CreatableTests[IO, CronJob] - with GettableTests[IO, CronJob] - with ListableTests[IO, CronJob, CronJobList] - with ReplaceableTests[IO, CronJob] - with DeletableTests[IO, CronJob, CronJobList] - with DeletableTerminatedTests[IO, CronJob, CronJobList] - with WatchableTests[IO, CronJob] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[CronJob] + with GettableTests[CronJob] + with ListableTests[CronJob, CronJobList] + with ReplaceableTests[CronJob] + with DeletableTests[CronJob, CronJobList] + with DeletableTerminatedTests[CronJob, CronJobList] + with WatchableTests[CronJob] + { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[CronJob].getSimpleName override def api(implicit client: KubernetesClient[IO]): CronJobsApi[IO] = client.cronJobs diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApiTest.scala index 635739b1..d8605ad2 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourceDefinitionsApiTest.scala @@ -1,32 +1,30 @@ package com.goyeau.kubernetes.client.api -import cats.Applicative +import cats.syntax.all.* import cats.effect.* -import cats.implicits.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest.* import com.goyeau.kubernetes.client.operation.* import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import munit.Assertions.* import org.http4s.Status import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific class CustomResourceDefinitionsApiTest - extends FunSuite - with CreatableTests[IO, CustomResourceDefinition] - with GettableTests[IO, CustomResourceDefinition] - with ListableTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] - with ReplaceableTests[IO, CustomResourceDefinition] - with DeletableTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] - with DeletableTerminatedTests[IO, CustomResourceDefinition, CustomResourceDefinitionList] - with WatchableTests[IO, CustomResourceDefinition] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[CustomResourceDefinition] + with GettableTests[CustomResourceDefinition] + with ListableTests[CustomResourceDefinition, CustomResourceDefinitionList] + with ReplaceableTests[CustomResourceDefinition] + with DeletableTests[CustomResourceDefinition, CustomResourceDefinitionList] + with DeletableTerminatedTests[CustomResourceDefinition, CustomResourceDefinitionList] + with WatchableTests[CustomResourceDefinition] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[CustomResourceDefinition].getSimpleName override val resourceIsNamespaced = false override val watchIsNamespaced: Boolean = resourceIsNamespaced @@ -61,17 +59,22 @@ class CustomResourceDefinitionsApiTest def checkUpdated(updatedResource: CustomResourceDefinition): Unit = assertEquals(updatedResource.spec.versions.headOption, versions.copy(served = false).some) - - override def afterAll(): Unit = { - usingMinikube { client => - for { - status <- client.customResourceDefinitions.deleteAll(crdLabel) - _ = assertEquals(status, Status.Ok, status.sanitizedReason) - _ <- logger.info(s"All CRD with label '$crdLabel' are deleted.") - } yield () - } - super.afterAll() - } + + + override def munitFixtures = super.munitFixtures ++ List( + ResourceSuiteLocalFixture( + name = "crd cleanup", + Resource.onFinalize[IO] { + usingMinikube { client => + for { + status <- client.customResourceDefinitions.deleteAll(crdLabel) + _ = assertEquals(status, Status.Ok, status.sanitizedReason) + _ <- logger.info(s"All CRD with label '$crdLabel' are deleted.") + } yield () + } + } + ) + ) override def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO] @@ -140,22 +143,22 @@ object CustomResourceDefinitionsApiTest { ).some ) - def createChecked[F[_]: Async]( + def createChecked( resourceName: String, labels: Map[String, String] - )(implicit client: KubernetesClient[F]): F[CustomResourceDefinition] = + )(implicit client: KubernetesClient[IO]): IO[CustomResourceDefinition] = for { status <- client.customResourceDefinitions.create(crd(resourceName, labels)) _ = assertEquals(status, Status.Created, status.sanitizedReason) crd <- getChecked(resourceName) - _ <- Sync[F].delay(println(s"CRD '$resourceName' created, labels: $labels")) + _ <- IO(println(s"CRD '$resourceName' created, labels: $labels")) } yield crd - def getChecked[F[_]: Async]( + def getChecked( resourceName: String - )(implicit client: KubernetesClient[F]): F[CustomResourceDefinition] = + )(implicit client: KubernetesClient[IO]): IO[CustomResourceDefinition] = for { - crdName <- Applicative[F].pure(crdName(resourceName)) + crdName <- IO.pure(crdName(resourceName)) resource <- client.customResourceDefinitions.get(crdName) _ = assertEquals(resource.metadata.flatMap(_.name), Some(crdName)) } yield resource diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourcesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourcesApiTest.scala index 5818e6c9..2a1f2012 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourcesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/CustomResourcesApiTest.scala @@ -1,18 +1,18 @@ package com.goyeau.kubernetes.client.api +import cats.syntax.all.* import cats.effect.* -import cats.implicits.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest.* import com.goyeau.kubernetes.client.api.CustomResourcesApiTest.{CronTabResource, CronTabResourceList} import com.goyeau.kubernetes.client.crd.{CrdContext, CustomResource, CustomResourceList} import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.circe.* import io.circe.generic.semiauto.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status case class CronTab(cronSpec: String, image: String, replicas: Int) @@ -32,17 +32,15 @@ object CustomResourcesApiTest { } class CustomResourcesApiTest - extends FunSuite - with CreatableTests[IO, CronTabResource] - with GettableTests[IO, CronTabResource] - with ListableTests[IO, CronTabResource, CronTabResourceList] - with ReplaceableTests[IO, CronTabResource] - with DeletableTests[IO, CronTabResource, CronTabResourceList] - with WatchableTests[IO, CronTabResource] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[CronTabResource] + with GettableTests[CronTabResource] + with ListableTests[CronTabResource, CronTabResourceList] + with ReplaceableTests[CronTabResource] + with DeletableTests[CronTabResource, CronTabResourceList] + with WatchableTests[CronTabResource] { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[CronTab].getSimpleName val kind = classOf[CronTab].getSimpleName val context = CrdContext(group, "v1", plural(resourceName)) @@ -106,32 +104,62 @@ class CustomResourcesApiTest } } - override def beforeAll(): Unit = { - createNamespaces() - - usingMinikube(implicit client => - client.customResourceDefinitions.deleteTerminated(resourceName) *> CustomResourceDefinitionsApiTest - .getChecked( - resourceName + override def munitFixtures = super.munitFixtures ++ List( + ResourceSuiteLocalFixture( + name = "crds", + Resource.make( + usingMinikube(implicit client => + client.customResourceDefinitions.deleteTerminated(resourceName) *> CustomResourceDefinitionsApiTest + .getChecked( + resourceName + ) + .recoverWith { case _ => + logger.info(s"CRD '$resourceName' is not there, creating it.") *> + CustomResourceDefinitionsApiTest + .createChecked(resourceName, crLabels) + } + .void ) - .recoverWith { case _ => - logger.info(s"CRD '$resourceName' is not there, creating it.") *> - CustomResourceDefinitionsApiTest - .createChecked(resourceName, crLabels) + ) { _ => + usingMinikube { implicit client => + val namespaces = extraNamespace :+ defaultNamespace + for { + deleteStatus <- namespaces.traverse(ns => namespacedApi(ns).deleteAll(crLabels)) + _ = deleteStatus.foreach(s => assertEquals(s, Status.Ok)) + _ <- logger.info(s"CRDs with label $crLabels were deleted in $namespaces namespace(s).") + } yield () + } } - .void ) - } + ) + + // override def beforeAll(): Unit = { + // createNamespaces() + + // usingMinikube(implicit client => + // client.customResourceDefinitions.deleteTerminated(resourceName) *> CustomResourceDefinitionsApiTest + // .getChecked( + // resourceName + // ) + // .recoverWith { case _ => + // logger.info(s"CRD '$resourceName' is not there, creating it.") *> + // CustomResourceDefinitionsApiTest + // .createChecked(resourceName, crLabels) + // } + // .void + // ) + // } + + // override def afterAll(): IO[Unit] = { + // usingMinikube { implicit client => + // val namespaces = extraNamespace :+ defaultNamespace + // for { + // deleteStatus <- namespaces.traverse(ns => namespacedApi(ns).deleteAll(crLabels)) + // _ = deleteStatus.foreach(s => assertEquals(s, Status.Ok)) + // _ <- logger.info(s"CRDs with label $crLabels were deleted in $namespaces namespace(s).") + // } yield () + // } *> + // super.afterAll() + // } - override def afterAll(): Unit = { - usingMinikube { implicit client => - val namespaces = extraNamespace :+ defaultNamespace - for { - deleteStatus <- namespaces.traverse(ns => namespacedApi(ns).deleteAll(crLabels)) - _ = deleteStatus.foreach(s => assertEquals(s, Status.Ok)) - _ <- logger.info(s"CRDs with label $crLabels were deleted in $namespaces namespace(s).") - } yield () - } - super.afterAll() - } } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/DeploymentsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/DeploymentsApiTest.scala index 95c5ccee..a1db69fc 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/DeploymentsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/DeploymentsApiTest.scala @@ -1,28 +1,27 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.operation.* import com.goyeau.kubernetes.client.{IntValue, KubernetesClient, StringValue, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.apps.v1.* import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} -import munit.FunSuite class DeploymentsApiTest - extends FunSuite - with CreatableTests[IO, Deployment] - with GettableTests[IO, Deployment] - with ListableTests[IO, Deployment, DeploymentList] - with ReplaceableTests[IO, Deployment] - with DeletableTests[IO, Deployment, DeploymentList] - with DeletableTerminatedTests[IO, Deployment, DeploymentList] - with WatchableTests[IO, Deployment] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[Deployment] + with GettableTests[Deployment] + with ListableTests[Deployment, DeploymentList] + with ReplaceableTests[Deployment] + with DeletableTests[Deployment, DeploymentList] + with DeletableTerminatedTests[Deployment, DeploymentList] + with WatchableTests[Deployment] + { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Deployment].getSimpleName override def api(implicit client: KubernetesClient[IO]): DeploymentsApi[IO] = client.deployments diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApiTest.scala index 3f89f044..1e0d34a8 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/HorizontalPodAutoscalersApiTest.scala @@ -2,9 +2,10 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.autoscaling.v1.{ CrossVersionObjectReference, HorizontalPodAutoscaler, @@ -12,20 +13,18 @@ import io.k8s.api.autoscaling.v1.{ HorizontalPodAutoscalerSpec } import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class HorizontalPodAutoscalersApiTest - extends FunSuite - with CreatableTests[IO, HorizontalPodAutoscaler] - with GettableTests[IO, HorizontalPodAutoscaler] - with ListableTests[IO, HorizontalPodAutoscaler, HorizontalPodAutoscalerList] - with ReplaceableTests[IO, HorizontalPodAutoscaler] - with DeletableTests[IO, HorizontalPodAutoscaler, HorizontalPodAutoscalerList] - with WatchableTests[IO, HorizontalPodAutoscaler] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[HorizontalPodAutoscaler] + with GettableTests[HorizontalPodAutoscaler] + with ListableTests[HorizontalPodAutoscaler, HorizontalPodAutoscalerList] + with ReplaceableTests[HorizontalPodAutoscaler] + with DeletableTests[HorizontalPodAutoscaler, HorizontalPodAutoscalerList] + with WatchableTests[HorizontalPodAutoscaler] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[HorizontalPodAutoscaler].getSimpleName override def api(implicit client: KubernetesClient[IO]): HorizontalPodAutoscalersApi[IO] = diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/IngressesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/IngressesApiTest.scala index 86b505be..26d9dff2 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/IngressesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/IngressesApiTest.scala @@ -2,25 +2,24 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.networking.v1.{Ingress, IngressList, IngressRule, IngressSpec} import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class IngressesApiTest - extends FunSuite - with CreatableTests[IO, Ingress] - with GettableTests[IO, Ingress] - with ListableTests[IO, Ingress, IngressList] - with ReplaceableTests[IO, Ingress] - with DeletableTests[IO, Ingress, IngressList] - with WatchableTests[IO, Ingress] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[Ingress] + with GettableTests[Ingress] + with ListableTests[Ingress, IngressList] + with ReplaceableTests[Ingress] + with DeletableTests[Ingress, IngressList] + with WatchableTests[Ingress] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Ingress].getSimpleName override def api(implicit client: KubernetesClient[IO]): IngressessApi[IO] = client.ingresses diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/JobsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/JobsApiTest.scala index fd7a4418..699d2f1a 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/JobsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/JobsApiTest.scala @@ -1,29 +1,28 @@ package com.goyeau.kubernetes.client.api import cats.syntax.all.* -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.operation.* import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.batch.v1.{Job, JobList, JobSpec} import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class JobsApiTest - extends FunSuite - with CreatableTests[IO, Job] - with GettableTests[IO, Job] - with ListableTests[IO, Job, JobList] - with ReplaceableTests[IO, Job] - with DeletableTests[IO, Job, JobList] - with DeletableTerminatedTests[IO, Job, JobList] - with WatchableTests[IO, Job] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[Job] + with GettableTests[Job] + with ListableTests[Job, JobList] + with ReplaceableTests[Job] + with DeletableTests[Job, JobList] + with DeletableTerminatedTests[Job, JobList] + with WatchableTests[Job] + { - implicit lazy val F: Async[IO] = IO.asyncForIO - implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger lazy val resourceName: String = classOf[Job].getSimpleName override def api(implicit client: KubernetesClient[IO]): JobsApi[IO] = client.jobs diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/LeasesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/LeasesApiTest.scala index 05bc1588..0aa79dfc 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/LeasesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/LeasesApiTest.scala @@ -2,25 +2,24 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import io.k8s.api.coordination.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific class LeasesApiTest - extends FunSuite - with CreatableTests[IO, Lease] - with GettableTests[IO, Lease] - with ListableTests[IO, Lease, LeaseList] - with ReplaceableTests[IO, Lease] - with DeletableTests[IO, Lease, LeaseList] - with WatchableTests[IO, Lease] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[Lease] + with GettableTests[Lease] + with ListableTests[Lease, LeaseList] + with ReplaceableTests[Lease] + with DeletableTests[Lease, LeaseList] + with WatchableTests[Lease] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Lease].getSimpleName override def api(implicit client: KubernetesClient[IO]): LeasesApi[IO] = client.leases diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NamespacesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NamespacesApiTest.scala index ab0edd71..67702406 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NamespacesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NamespacesApiTest.scala @@ -1,30 +1,27 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, IO, Sync} -import cats.implicits.* +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.Utils.* -import com.goyeau.kubernetes.client.operation.MinikubeClientProvider import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.core.v1.{Namespace, NamespaceList} import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta import org.http4s.Status import org.http4s.client.UnexpectedStatus import munit.Assertions.* -import munit.FunSuite -class NamespacesApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextProvider { +class NamespacesApiTest extends MinikubeClientProvider { import NamespacesApiTest.* - implicit lazy val F: Async[IO] = IO.asyncForIO - implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger lazy val resourceName: String = classOf[Namespace].getSimpleName test("create a namespace") { usingMinikube { implicit client => val namespaceName = s"${resourceName.toLowerCase}-ns-create" - createChecked[IO](namespaceName).guarantee(client.namespaces.delete(namespaceName).void) + createChecked(namespaceName).guarantee(client.namespaces.delete(namespaceName).void) } } @@ -78,9 +75,7 @@ class NamespacesApiTest extends FunSuite with MinikubeClientProvider[IO] with Co } test("get a namespace fail on non existing namespace") { - intercept[UnexpectedStatus] { - usingMinikube(implicit client => getChecked[IO]("non-existing")) - } + usingMinikube(implicit client => getChecked("non-existing")).intercept[UnexpectedStatus] } test("delete a namespace") { @@ -158,9 +153,9 @@ class NamespacesApiTest extends FunSuite with MinikubeClientProvider[IO] with Co object NamespacesApiTest { - def createChecked[F[_]: Async: Logger]( + def createChecked( namespaceName: String - )(implicit client: KubernetesClient[F]): F[Namespace] = + )(implicit client: KubernetesClient[IO], logger: Logger[IO]): IO[Namespace] = for { status <- client.namespaces.create(Namespace(metadata = Option(ObjectMeta(name = Option(namespaceName))))) _ = assertEquals(status, Status.Created, s"Namespace '$namespaceName' creation failed.") @@ -175,13 +170,13 @@ object NamespacesApiTest { ) } yield namespace - def listChecked[F[_]: Sync](namespaceNames: Seq[String])(implicit client: KubernetesClient[F]): F[NamespaceList] = + def listChecked(namespaceNames: Seq[String])(implicit client: KubernetesClient[IO]): IO[NamespaceList] = for { namespaces <- client.namespaces.list() _ = assert(namespaceNames.toSet.subsetOf(namespaces.items.flatMap(_.metadata).flatMap(_.name).toSet)) } yield namespaces - def getChecked[F[_]: Sync](namespaceName: String)(implicit client: KubernetesClient[F]): F[Namespace] = + def getChecked(namespaceName: String)(implicit client: KubernetesClient[IO]): IO[Namespace] = for { namespace <- client.namespaces.get(namespaceName) _ = assertEquals(namespace.metadata.flatMap(_.name), Some(namespaceName)) diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NodesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NodesApiTest.scala index 340bd3ca..c43f28aa 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NodesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/NodesApiTest.scala @@ -1,23 +1,16 @@ package com.goyeau.kubernetes.client.api -import cats.effect.IO -import cats.Applicative +import cats.syntax.all.* import cats.effect.* -import cats.implicits.* import com.goyeau.kubernetes.client.KubernetesClient -import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest.* -import com.goyeau.kubernetes.client.operation.* -import io.k8s.api.coordination.v1.* +import com.goyeau.kubernetes.client.MinikubeClientProvider import io.k8s.api.core.v1.Node -import io.k8s.apiextensionsapiserver.pkg.apis.apiextensions.v1.* -import munit.Assertions.* -import munit.FunSuite import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific -class NodesApiTest extends FunSuite with ContextProvider with MinikubeClientProvider[IO] { - implicit lazy val F: Async[IO] = IO.asyncForIO - implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] +class NodesApiTest extends MinikubeClientProvider { + + implicit lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger lazy val resourceName: String = classOf[Node].getSimpleName def api(implicit client: KubernetesClient[IO]): NodesApi[IO] = client.nodes diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/PodsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/PodsApiTest.scala index 9df4cbc3..9329279c 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/PodsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/PodsApiTest.scala @@ -1,9 +1,9 @@ package com.goyeau.kubernetes.client.api import cats.syntax.all.* -import cats.effect.unsafe.implicits.global -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.Utils.retry import com.goyeau.kubernetes.client.api.ExecStream.{StdErr, StdOut} import com.goyeau.kubernetes.client.operation.* @@ -12,27 +12,23 @@ import fs2.{text, Stream} import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1 import io.k8s.apimachinery.pkg.apis.meta.v1.{ListMeta, ObjectMeta} -import munit.FunSuite import org.http4s.Status import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.circe.syntax.* -import java.nio.file.Files as JFiles import scala.util.Random class PodsApiTest - extends FunSuite - with CreatableTests[IO, Pod] - with GettableTests[IO, Pod] - with ListableTests[IO, Pod, PodList] - with ReplaceableTests[IO, Pod] - with DeletableTests[IO, Pod, PodList] - with DeletableTerminatedTests[IO, Pod, PodList] - with WatchableTests[IO, Pod] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[Pod] + with GettableTests[Pod] + with ListableTests[Pod, PodList] + with ReplaceableTests[Pod] + with DeletableTests[Pod, PodList] + with DeletableTerminatedTests[Pod, PodList] + with WatchableTests[Pod] { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Pod].getSimpleName override def api(implicit client: KubernetesClient[IO]): PodsApi[IO] = client.pods @@ -83,7 +79,7 @@ class PodsApiTest test("exec into pod") { val podName = s"${resourceName.toLowerCase}-exec" - val (messages, status) = kubernetesClient + kubernetesClient .use { implicit client => for { status <- namespacedApi(defaultNamespace).create(testPod(podName)) @@ -96,27 +92,28 @@ class PodsApiTest ) } yield res } - .unsafeRunSync() + .map { case (messages, status) => + assertNotEquals(messages.length, 0) - assertEquals(status, successStatus) - assertNotEquals(messages.length, 0) + val stdOut = messages + .collect { case o: StdOut => + o.asString + } + .mkString("") + val expectedDirs = Seq("dev", "etc", "home", "usr", "var").mkString("|").r - val stdOut = messages - .collect { case o: StdOut => - o.asString - } - .mkString("") - val expectedDirs = Seq("dev", "etc", "home", "usr", "var").mkString("|").r + assert(expectedDirs.findFirstIn(stdOut).isDefined, stdOut) - assert(expectedDirs.findFirstIn(stdOut).isDefined, stdOut) + val errors = messages.collect { case e: StdErr => e } + assertEquals(errors.length, 0) - val errors = messages.collect { case e: StdErr => e } - assertEquals(errors.length, 0) + assertEquals(status, successStatus) + } } test("download a file") { val podName = s"${resourceName.toLowerCase}-download" - val (messages, status) = kubernetesClient + kubernetesClient .use { implicit client => for { status <- namespacedApi(defaultNamespace).create(testPod(podName)) @@ -130,14 +127,14 @@ class PodsApiTest ) } yield res } - .unsafeRunSync() - - assertEquals(status, successStatus) - assertEquals(messages.length, 0, messages.map(_.asString).mkString("")) + .map { case (messages, status) => + assertEquals(status, successStatus) + assertEquals(messages.length, 0, messages.map(_.asString).mkString("")) + } } - private def tempFile = - F.delay(Path.fromNioPath(JFiles.createTempFile("test-file-", ".txt"))) + private def tempFile: IO[Path] = + Files.forIO.createTempFile(dir = none, prefix = "test-file-", suffix = ".txt", permissions = none) private def fileExists(podName: String, container: Option[String], targetPath: Path)(implicit client: KubernetesClient[IO] @@ -233,7 +230,6 @@ class PodsApiTest ) } yield () } - .unsafeRunSync() } test("watch the logs") { @@ -274,7 +270,6 @@ class PodsApiTest ) } yield () } - .unsafeRunSync() } private val podStatusCount = 4 diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala index cb082a5b..5421b27d 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/RawApiTest.scala @@ -1,38 +1,23 @@ package com.goyeau.kubernetes.client.api -import cats.syntax.all.* -import cats.effect.unsafe.implicits.global -import cats.effect.{Async, IO} -import com.goyeau.kubernetes.client.KubernetesClient -import com.goyeau.kubernetes.client.Utils.retry -import com.goyeau.kubernetes.client.api.ExecStream.{StdErr, StdOut} -import com.goyeau.kubernetes.client.operation.* -import fs2.io.file.{Files, Path} -import fs2.{text, Stream} +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* import io.k8s.api.core.v1.* -import io.k8s.apimachinery.pkg.apis.meta.v1 -import io.k8s.apimachinery.pkg.apis.meta.v1.{ListMeta, ObjectMeta} -import munit.FunSuite -import org.http4s.{Request, Status, Uri} +import org.http4s.{Request, Status} import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific -import java.nio.file.Files as JFiles -import scala.util.Random import org.http4s.implicits.* -import org.http4s.jdkhttpclient.WSConnectionHighLevel -class RawApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextProvider { +class RawApiTest extends MinikubeClientProvider { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger // MinikubeClientProvider will create a namespace with this name, even though it's not used in this test override lazy val resourceName: String = "raw-api-tests" test("list nodes with raw requests") { - kubernetesClient - .use { implicit client => + usingMinikube { implicit client => for { response <- client.raw .runRequest( @@ -51,7 +36,7 @@ class RawApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextPr Status.Ok, s"non 200 status for get nodes raw request" ) - nodeList <- F.fromEither( + nodeList <- IO.fromEither( io.circe.parser.decode[NodeList](body) ) _ = assert( @@ -64,7 +49,7 @@ class RawApiTest extends FunSuite with MinikubeClientProvider[IO] with ContextPr ) } yield () } - .unsafeRunSync() + } } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ReplicaSetsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ReplicaSetsApiTest.scala index 909ac68b..1cc35106 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ReplicaSetsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ReplicaSetsApiTest.scala @@ -1,28 +1,27 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.apps.v1.* import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} -import munit.FunSuite class ReplicaSetsApiTest - extends FunSuite - with CreatableTests[IO, ReplicaSet] - with GettableTests[IO, ReplicaSet] - with ListableTests[IO, ReplicaSet, ReplicaSetList] - with ReplaceableTests[IO, ReplicaSet] - with DeletableTests[IO, ReplicaSet, ReplicaSetList] - with DeletableTerminatedTests[IO, ReplicaSet, ReplicaSetList] - with WatchableTests[IO, ReplicaSet] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[ReplicaSet] + with GettableTests[ReplicaSet] + with ListableTests[ReplicaSet, ReplicaSetList] + with ReplaceableTests[ReplicaSet] + with DeletableTests[ReplicaSet, ReplicaSetList] + with DeletableTerminatedTests[ReplicaSet, ReplicaSetList] + with WatchableTests[ReplicaSet] + { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[ReplicaSet].getSimpleName override def api(implicit client: KubernetesClient[IO]): ReplicaSetsApi[IO] = client.replicaSets diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/SecretsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/SecretsApiTest.scala index be170c01..d9d9d596 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/SecretsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/SecretsApiTest.scala @@ -1,29 +1,27 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, IO} +import cats.syntax.all.* +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.core.v1.{Secret, SecretList} import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import java.util.Base64 -import munit.FunSuite import org.http4s.Status -import scala.collection.compat.* class SecretsApiTest - extends FunSuite - with CreatableTests[IO, Secret] - with GettableTests[IO, Secret] - with ListableTests[IO, Secret, SecretList] - with ReplaceableTests[IO, Secret] - with DeletableTests[IO, Secret, SecretList] - with WatchableTests[IO, Secret] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[Secret] + with GettableTests[Secret] + with ListableTests[Secret, SecretList] + with ReplaceableTests[Secret] + with DeletableTests[Secret, SecretList] + with WatchableTests[Secret] + { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Secret].getSimpleName override def api(implicit client: KubernetesClient[IO]): SecretsApi[IO] = client.secrets @@ -45,7 +43,7 @@ class SecretsApiTest client: KubernetesClient[IO] ): IO[Secret] = for { - _ <- NamespacesApiTest.createChecked[IO](namespaceName) + _ <- NamespacesApiTest.createChecked(namespaceName) data = Map("test" -> "data") status <- client.secrets @@ -58,7 +56,13 @@ class SecretsApiTest ) _ = assertEquals(status, Status.Created) secret <- getChecked(namespaceName, secretName) - _ = assertEquals(secret.data.get.values.head, Base64.getEncoder.encodeToString(data.values.head.getBytes)) + encoded <- fs2.Stream.emit(data.values.head).covary[IO] + .through(fs2.text.utf8.encode) + .through(fs2.text.base64.encode) + .foldMonoid + .compile + .lastOrError + _ = assertEquals(secret.data.get.values.head, encoded) } yield secret test("createEncode should create a secret") { @@ -109,11 +113,20 @@ class SecretsApiTest ) _ = assertEquals(status, Status.Ok) updatedSecret <- getChecked(namespaceName, secretName) + encoded <- data.traverse { data => + data.view.toSeq.traverse { case (k, v) => + fs2.Stream.emit(v).covary[IO] + .through(fs2.text.utf8.encode) + .through(fs2.text.base64.encode) + .foldMonoid + .compile + .lastOrError + .map { encoded => k -> encoded } + }.map(_.toMap) + } _ = assertEquals( updatedSecret.data, - data.map( - _.view.mapValues(v => Base64.getEncoder.encodeToString(v.getBytes)).toMap - ) + encoded ) } yield ()).guarantee(client.namespaces.delete(namespaceName).void) } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServiceAccountsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServiceAccountsApiTest.scala index b18d6fc2..8213ac39 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServiceAccountsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServiceAccountsApiTest.scala @@ -2,25 +2,24 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite class ServiceAccountsApiTest - extends FunSuite - with CreatableTests[IO, ServiceAccount] - with GettableTests[IO, ServiceAccount] - with ListableTests[IO, ServiceAccount, ServiceAccountList] - with ReplaceableTests[IO, ServiceAccount] - with DeletableTests[IO, ServiceAccount, ServiceAccountList] - with WatchableTests[IO, ServiceAccount] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[ServiceAccount] + with GettableTests[ServiceAccount] + with ListableTests[ServiceAccount, ServiceAccountList] + with ReplaceableTests[ServiceAccount] + with DeletableTests[ServiceAccount, ServiceAccountList] + with WatchableTests[ServiceAccount] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[ServiceAccount].getSimpleName override def api(implicit client: KubernetesClient[IO]): ServiceAccountsApi[IO] = client.serviceAccounts diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServicesApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServicesApiTest.scala index fd871550..e92d46b6 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServicesApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/ServicesApiTest.scala @@ -2,25 +2,24 @@ package com.goyeau.kubernetes.client.api import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific class ServicesApiTest - extends FunSuite - with CreatableTests[IO, Service] - with GettableTests[IO, Service] - with ListableTests[IO, Service, ServiceList] - with ReplaceableTests[IO, Service] - with DeletableTests[IO, Service, ServiceList] - with WatchableTests[IO, Service] - with ContextProvider { - - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + extends MinikubeClientProvider + with CreatableTests[Service] + with GettableTests[Service] + with ListableTests[Service, ServiceList] + with ReplaceableTests[Service] + with DeletableTests[Service, ServiceList] + with WatchableTests[Service] + { + + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[Service].getSimpleName override def api(implicit client: KubernetesClient[IO]): ServicesApi[IO] = client.services diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/StatefulSetsApiTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/StatefulSetsApiTest.scala index afd2d40b..c7020a2e 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/StatefulSetsApiTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/api/StatefulSetsApiTest.scala @@ -1,28 +1,27 @@ package com.goyeau.kubernetes.client.api -import cats.effect.{Async, IO} +import cats.effect.* import com.goyeau.kubernetes.client.{KubernetesClient, TestPodSpec} +import com.goyeau.kubernetes.client.MinikubeClientProvider import com.goyeau.kubernetes.client.operation.* import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger +import com.goyeau.kubernetes.client.TestPlatformSpecific import io.k8s.api.apps.v1.* import io.k8s.api.core.v1.* import io.k8s.apimachinery.pkg.apis.meta.v1.{LabelSelector, ObjectMeta} -import munit.FunSuite class StatefulSetsApiTest - extends FunSuite - with CreatableTests[IO, StatefulSet] - with GettableTests[IO, StatefulSet] - with ListableTests[IO, StatefulSet, StatefulSetList] - with ReplaceableTests[IO, StatefulSet] - with DeletableTests[IO, StatefulSet, StatefulSetList] - with DeletableTerminatedTests[IO, StatefulSet, StatefulSetList] - with WatchableTests[IO, StatefulSet] - with ContextProvider { + extends MinikubeClientProvider + with CreatableTests[StatefulSet] + with GettableTests[StatefulSet] + with ListableTests[StatefulSet, StatefulSetList] + with ReplaceableTests[StatefulSet] + with DeletableTests[StatefulSet, StatefulSetList] + with DeletableTerminatedTests[StatefulSet, StatefulSetList] + with WatchableTests[StatefulSet] + { - implicit override lazy val F: Async[IO] = IO.asyncForIO - implicit override lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit override lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger override lazy val resourceName: String = classOf[StatefulSet].getSimpleName override def api(implicit client: KubernetesClient[IO]): StatefulSetsApi[IO] = client.statefulSets diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/cache/AuthorizationCacheTest.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/cache/AuthorizationCacheTest.scala index 24941c7b..3c3fb734 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/cache/AuthorizationCacheTest.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/cache/AuthorizationCacheTest.scala @@ -4,22 +4,19 @@ import cats.syntax.all.* import cats.effect.* import com.goyeau.kubernetes.client.util.cache.{AuthorizationCache, AuthorizationWithExpiration} import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger -import munit.FunSuite +import com.goyeau.kubernetes.client.TestPlatformSpecific +import munit.CatsEffectSuite import org.http4s.{AuthScheme, Credentials} import org.http4s.headers.Authorization -import cats.effect.unsafe.implicits.global -import java.time.Instant import scala.concurrent.duration.* -class AuthorizationCacheTest extends FunSuite { +class AuthorizationCacheTest extends CatsEffectSuite { - implicit lazy val F: Async[IO] = IO.asyncForIO - implicit lazy val logger: Logger[IO] = Slf4jLogger.getLogger[IO] + implicit lazy val logger: Logger[IO] = TestPlatformSpecific.getLogger private def mkAuthorization( - expirationTimestamp: IO[Option[Instant]] = none.pure, - token: IO[String] = s"test-token".pure + expirationTimestamp: IO[Option[FiniteDuration]] = none.pure[IO], + token: IO[String] = s"test-token".pure[IO] ): IO[AuthorizationWithExpiration] = token.flatMap { token => expirationTimestamp.map { expirationTimestamp => @@ -31,70 +28,65 @@ class AuthorizationCacheTest extends FunSuite { } test(s"retrieve the token initially") { - val io = for { + for { auth <- mkAuthorization() - cache <- AuthorizationCache[IO](retrieve = auth.pure) + cache <- AuthorizationCache[IO](retrieve = auth.pure[IO]) obtained <- cache.get } yield assertEquals(obtained, auth.authorization) - io.unsafeRunSync() } test(s"fail when cannot retrieve the token initially") { - val io = for { + for { cache <- AuthorizationCache[IO](retrieve = IO.raiseError(new RuntimeException("test failure"))) obtained <- cache.get.attempt - } yield assert(obtained.isLeft) - io.unsafeRunSync() + } yield assert(obtained.isLeft) } test(s"retrieve the token once when no expiration") { - val io = for { + for { counter <- IO.ref(1) auth = mkAuthorization(token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i")) cache <- AuthorizationCache[IO](retrieve = auth) - obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) + obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure[IO])) } yield obtained.foreach { case (obtained, _) => assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-1"))) } - io.unsafeRunSync() } test(s"retrieve the token when it's expired") { - val io = for { + for { counter <- IO.ref(1) auth = mkAuthorization( - expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), + expirationTimestamp = IO.realTime.map(_.minus(10.seconds).some), token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") ) cache <- AuthorizationCache[IO](retrieve = auth) - obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) + obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure[IO])) } yield obtained.foreach { case (obtained, i) => assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) } - io.unsafeRunSync() } test(s"retrieve the token when it's going to expire within refreshBeforeExpiration") { - val io = for { + for { counter <- IO.ref(1) auth = mkAuthorization( - expirationTimestamp = IO.realTimeInstant.map(_.plusSeconds(40).some), + expirationTimestamp = IO.realTime.map(_.plus(40.seconds).some), token = counter.getAndUpdate(_ + 1).map(i => s"test-token-$i") ) cache <- AuthorizationCache[IO](retrieve = auth, refreshBeforeExpiration = 1.minute) - obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) + obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure[IO])) } yield obtained.foreach { case (obtained, i) => assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) } - io.unsafeRunSync() } test(s"fail if cannot retrieve the token when it's expired") { - val io = for { + for { counter <- IO.ref(1) shouldFail <- IO.ref(false) auth = mkAuthorization( - expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), + expirationTimestamp = IO.realTime.map(_.minus(10.seconds).some), token = shouldFail.get.flatMap { shouldFail => if (shouldFail) { IO.raiseError(new RuntimeException("test failure")) @@ -104,9 +96,9 @@ class AuthorizationCacheTest extends FunSuite { } ) cache <- AuthorizationCache[IO](retrieve = auth) - obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) + obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure[IO])) _ <- shouldFail.set(true) - obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure)) + obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure[IO])) } yield { obtained.foreach { case (obtained, i) => assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) @@ -116,15 +108,14 @@ class AuthorizationCacheTest extends FunSuite { assert(obtained.isLeft) } } - io.unsafeRunSync() } test(s"fail if cannot retrieve the token when it's expired, then recover") { - val io = for { + for { counter <- IO.ref(1) shouldFail <- IO.ref(false) auth = mkAuthorization( - expirationTimestamp = IO.realTimeInstant.map(_.minusSeconds(10).some), + expirationTimestamp = IO.realTime.map(_.minus(10.seconds).some), token = shouldFail.get.flatMap { shouldFail => if (shouldFail) { IO.raiseError(new RuntimeException("test failure")) @@ -134,11 +125,11 @@ class AuthorizationCacheTest extends FunSuite { } ) cache <- AuthorizationCache[IO](retrieve = auth) - obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure)) + obtained <- (1 to 5).toList.traverse(i => cache.get.product(i.pure[IO])) _ <- shouldFail.set(true) - obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure)) + obtainedFailed <- (1 to 5).toList.traverse(i => cache.get.attempt.product(i.pure[IO])) _ <- shouldFail.set(false) - obtainedAgain <- (6 to 10).toList.traverse(i => cache.get.attempt.product(i.pure)) + obtainedAgain <- (6 to 10).toList.traverse(i => cache.get.attempt.product(i.pure[IO])) } yield { obtained.foreach { case (obtained, i) => assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i"))) @@ -151,7 +142,6 @@ class AuthorizationCacheTest extends FunSuite { assertEquals(obtained, Authorization(Credentials.Token(AuthScheme.Bearer, s"test-token-$i")).asRight) } } - io.unsafeRunSync() } } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/CreatableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/CreatableTests.scala index cd8e873a..16d13ab7 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/CreatableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/CreatableTests.scala @@ -1,35 +1,33 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import com.goyeau.kubernetes.client.Utils.retry import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status -trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait CreatableTests[R <: { def metadata: Option[ObjectMeta] }] { + self: MinikubeClientProvider => - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Creatable[F, Resource] - def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] - def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): Resource - def modifyResource(resource: Resource): Resource - def checkUpdated(updatedResource: Resource): Unit + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Creatable[IO, R] + def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] + def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): R + def modifyResource(resource: R): R + def checkUpdated(updatedResource: R): Unit def createChecked(namespaceName: String, resourceName: String)(implicit - client: KubernetesClient[F] - ): F[Resource] = createChecked(namespaceName, resourceName, Map.empty) + client: KubernetesClient[IO] + ): IO[R] = createChecked(namespaceName, resourceName, Map.empty) def createChecked(namespaceName: String, resourceName: String, labels: Map[String, String])(implicit - client: KubernetesClient[F] - ): F[Resource] = { + client: KubernetesClient[IO] + ): IO[R] = { val resource = sampleResource(resourceName, labels) for { status <- namespacedApi(namespaceName).create(resource) _ <- logger.info(s"Created '$resourceName' in $namespaceName namespace: $status") - _ <- F.delay(assertEquals(status.isSuccess, true, s"$status should be successful")) + _ <- IO.delay(assertEquals(status.isSuccess, true, s"$status should be successful")) resource <- retry( getChecked(namespaceName, resourceName), actionClue = Some(s"Getting after create '$resourceName' in $namespaceName namespace"), @@ -39,8 +37,8 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] } def createWithResourceChecked(namespaceName: String, resourceName: String, labels: Map[String, String] = Map.empty)( - implicit client: KubernetesClient[F] - ): F[Resource] = { + implicit client: KubernetesClient[IO] + ): IO[R] = { val resource = sampleResource(resourceName, labels) for { _ <- namespacedApi(namespaceName).createWithResource(resource) @@ -63,7 +61,7 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"create a $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) resourceName = "create-update-resource" status <- namespacedApi(namespaceName).createOrUpdate(sampleResource(resourceName)) _ = assert(Set(Status.Created, Status.Ok).contains(status), status.sanitizedReason) @@ -75,7 +73,7 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"create a $resourceName with resource") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) resourceName = "create-update-with-resource" _ <- namespacedApi(namespaceName).createOrUpdateWithResource(sampleResource(resourceName)) _ <- getChecked(namespaceName, resourceName) @@ -83,7 +81,7 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] } } - def createOrUpdate(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Unit] = + def createOrUpdate(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[Unit] = for { resource <- getChecked(namespaceName, resourceName) status <- namespacedApi(namespaceName).createOrUpdate(modifyResource(resource)) @@ -93,8 +91,8 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"update a $resourceName already created") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("update-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("update-resource") _ <- createChecked(namespaceName, resourceName) _ <- retry(createOrUpdate(namespaceName, resourceName), actionClue = Some("Updating resource")) updatedResource <- getChecked(namespaceName, resourceName) @@ -104,7 +102,7 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] } def createOrUpdateWithResource(namespaceName: String, resourceName: String)(implicit - client: KubernetesClient[F] + client: KubernetesClient[IO] ) = for { retrievedResource <- getChecked(namespaceName, resourceName) @@ -114,8 +112,8 @@ trait CreatableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"update a $resourceName already created with resource") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("update-with-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("update-with-resource") _ <- createChecked(namespaceName, resourceName) updatedResource <- retry(createOrUpdateWithResource(namespaceName, resourceName)) _ = checkUpdated(updatedResource) diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTerminatedTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTerminatedTests.scala index fae62655..3b567d05 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTerminatedTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTerminatedTests.scala @@ -1,31 +1,27 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status -trait DeletableTerminatedTests[F[ - _ -], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait DeletableTerminatedTests[R <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[R] }] { + self: MinikubeClientProvider => - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): DeletableTerminated[F] - def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): DeletableTerminated[IO] + def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] def listNotContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)( - implicit client: KubernetesClient[F] - ): F[ResourceList] - def deleteTerminated(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = + implicit client: KubernetesClient[IO] + ): IO[ResourceList] + def deleteTerminated(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[Status] = namespacedApi(namespaceName).deleteTerminated(resourceName) test(s"delete $resourceName and block until fully deleted") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("delete-terminated-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("delete-terminated-resource") _ <- deleteTerminated(namespaceName, resourceName) _ <- listNotContains(namespaceName, Set(resourceName)) } yield () @@ -44,7 +40,7 @@ trait DeletableTerminatedTests[F[ test(s"fail on non existing $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) status <- deleteTerminated(namespaceName, "non-existing") // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound _ = assert(Set(Status.NotFound, Status.Ok).contains(status)) diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTests.scala index 55f27f09..41ae08c0 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/DeletableTests.scala @@ -1,26 +1,23 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.syntax.all.* +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import com.goyeau.kubernetes.client.Utils.* import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions -trait DeletableTests[F[ - _ -], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait DeletableTests[R <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[R] }] { + self: MinikubeClientProvider => - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Deletable[F] - def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] + def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] def listNotContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)( - implicit client: KubernetesClient[F] - ): F[ResourceList] - def delete(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = { + implicit client: KubernetesClient[IO] + ): IO[ResourceList] + def delete(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[Status] = { val deleteOptions = DeleteOptions(gracePeriodSeconds = Some(0L)) namespacedApi(namespaceName).delete(resourceName, deleteOptions.some) } @@ -28,8 +25,8 @@ trait DeletableTests[F[ test(s"delete a $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("delete-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("delete-resource") _ <- createChecked(namespaceName, resourceName) status <- delete(namespaceName, resourceName) // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound @@ -54,7 +51,7 @@ trait DeletableTests[F[ test(s"fail on non existing $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) status <- delete(namespaceName, "non-existing") // returns Ok status since Kubernetes 1.23.x, earlier versions return NotFound _ = assert(Set(Status.NotFound, Status.Ok).contains(status)) diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/GettableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/GettableTests.scala index a252656c..b29996cf 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/GettableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/GettableTests.scala @@ -1,21 +1,19 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.client.UnexpectedStatus import scala.language.reflectiveCalls -trait GettableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait GettableTests[R <: { def metadata: Option[ObjectMeta] }] { + self: MinikubeClientProvider => - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Gettable[F, Resource] - def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Gettable[IO, R] + def createChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] - def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] = + def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] = for { resource <- namespacedApi(namespaceName).get(resourceName) _ = assertEquals(resource.metadata.flatMap(_.namespace), Some(namespaceName)) @@ -25,8 +23,8 @@ trait GettableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"get a $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("some-resource-get") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("some-resource-get") _ <- createChecked(namespaceName, resourceName) _ <- getChecked(namespaceName, resourceName) } yield () @@ -34,19 +32,15 @@ trait GettableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] } test("fail on non existing namespace") { - intercept[UnexpectedStatus] { - usingMinikube(implicit client => getChecked("non-existing", "non-existing")) - } + usingMinikube(implicit client => getChecked("non-existing", "non-existing")).intercept[UnexpectedStatus] } test(s"fail on non existing $resourceName") { - intercept[UnexpectedStatus] { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) _ <- getChecked(namespaceName, "non-existing") } yield () - } + }.intercept[UnexpectedStatus] } - } } diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ListableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ListableTests.scala index d3b0868c..ee798552 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ListableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ListableTests.scala @@ -1,19 +1,15 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.syntax.all.* +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import com.goyeau.kubernetes.client.api.NamespacesApiTest import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite +import scala.language.reflectiveCalls -import scala.language.{higherKinds, reflectiveCalls} - -trait ListableTests[F[ - _ -], Resource <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[Resource] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait ListableTests[R <: { def metadata: Option[ObjectMeta] }, ResourceList <: { def items: Seq[R] }] { + self: MinikubeClientProvider => val resourceIsNamespaced = true val namespaceResourceNames = @@ -21,23 +17,23 @@ trait ListableTests[F[ override protected val extraNamespace = namespaceResourceNames.map(_._1).toList - def api(implicit client: KubernetesClient[F]): Listable[F, ResourceList] - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Listable[F, ResourceList] + def api(implicit client: KubernetesClient[IO]): Listable[IO, ResourceList] + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Listable[IO, ResourceList] def createChecked(namespaceName: String, resourceName: String, labels: Map[String, String] = Map.empty)(implicit - client: KubernetesClient[F] - ): F[Resource] + client: KubernetesClient[IO] + ): IO[R] def listContains(namespaceName: String, resourceNames: Set[String], labels: Map[String, String] = Map.empty)(implicit - client: KubernetesClient[F] - ): F[ResourceList] = + client: KubernetesClient[IO] + ): IO[ResourceList] = for { resourceList <- namespacedApi(namespaceName).list(labels) _ = assert(resourceNames.subsetOf(resourceList.items.flatMap(_.metadata.flatMap(_.name)).toSet)) } yield resourceList def listAllContains(resourceNames: Set[String])(implicit - client: KubernetesClient[F] - ): F[ResourceList] = + client: KubernetesClient[IO] + ): IO[ResourceList] = for { resourceList <- api.list() _ = assert(resourceNames.subsetOf(resourceList.items.flatMap(_.metadata.flatMap(_.name)).toSet)) @@ -48,8 +44,8 @@ trait ListableTests[F[ resourceNames: Set[String], labels: Map[String, String] = Map.empty )(implicit - client: KubernetesClient[F] - ): F[ResourceList] = + client: KubernetesClient[IO] + ): IO[ResourceList] = for { resourceList <- namespacedApi(namespaceName).list(labels) names = resourceList.items.flatMap(_.metadata.flatMap(_.name)) @@ -62,8 +58,8 @@ trait ListableTests[F[ test(s"list ${resourceName}s") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("list-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("list-resource") _ <- listNotContains(namespaceName, Set(resourceName)) _ <- createChecked(namespaceName, resourceName) _ <- listContains(namespaceName, Set(resourceName)) @@ -74,10 +70,10 @@ trait ListableTests[F[ test(s"list ${resourceName}s with a label") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - noLabelResourceName <- Applicative[F].pure("no-label-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + noLabelResourceName <- IO.pure("no-label-resource") _ <- createChecked(namespaceName, noLabelResourceName) - withLabelResourceName <- Applicative[F].pure("label-resource") + withLabelResourceName <- IO.pure("label-resource") labels = Map("test" -> "1") _ <- createChecked(namespaceName, withLabelResourceName, labels) _ <- listNotContains(namespaceName, Set(noLabelResourceName), labels) @@ -91,7 +87,7 @@ trait ListableTests[F[ assume(resourceIsNamespaced) for { _ <- namespaceResourceNames.toList.traverse { case (namespaceName, resourceName) => - client.namespaces.deleteTerminated(namespaceName) *> NamespacesApiTest.createChecked[F]( + client.namespaces.deleteTerminated(namespaceName) *> NamespacesApiTest.createChecked( namespaceName ) *> createChecked( namespaceName, diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/MinikubeClientProvider.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/MinikubeClientProvider.scala deleted file mode 100644 index 1600aea9..00000000 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/MinikubeClientProvider.scala +++ /dev/null @@ -1,70 +0,0 @@ -package com.goyeau.kubernetes.client.operation - -import cats.effect.* -import cats.implicits.* -import com.goyeau.kubernetes.client.Utils.retry -import com.goyeau.kubernetes.client.api.NamespacesApiTest -import com.goyeau.kubernetes.client.{KubeConfig, KubernetesClient} -import fs2.io.file.Path -import munit.Suite -import org.typelevel.log4cats.Logger -import io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions - -trait MinikubeClientProvider[F[_]] { - this: Suite => - - implicit def F: Async[F] - implicit def logger: Logger[F] - - def unsafeRunSync[A](f: F[A]): A - - val kubernetesClient: Resource[F, KubernetesClient[F]] = { - val kubeConfig = KubeConfig.fromFile[F]( - Path(s"${System.getProperty("user.home")}/.kube/config"), - sys.env.getOrElse("KUBE_CONTEXT_NAME", "minikube") - ) - KubernetesClient(kubeConfig) - } - - def resourceName: String - - def defaultNamespace: String = resourceName.toLowerCase - - protected val extraNamespace = List.empty[String] - - protected def createNamespace(namespace: String): F[Unit] = kubernetesClient.use { implicit client => - client.namespaces.deleteTerminated(namespace) *> retry( - NamespacesApiTest.createChecked[F](namespace), - actionClue = Some(s"Creating '$namespace' namespace") - ) - }.void - - private def deleteNamespace(namespace: String) = kubernetesClient.use { client => - client.namespaces.delete( - namespace, - DeleteOptions(gracePeriodSeconds = 0L.some, propagationPolicy = "Foreground".some).some - ) - }.void - - protected def createNamespaces(): Unit = { - val ns = defaultNamespace +: extraNamespace - unsafeRunSync( - logger.info(s"Creating namespaces: $ns") *> - ns.traverse_(name => createNamespace(name)) - ) - } - - override def beforeAll(): Unit = - createNamespaces() - - override def afterAll(): Unit = { - val ns = defaultNamespace +: extraNamespace - unsafeRunSync( - logger.info(s"Deleting namespaces: $ns") *> - ns.traverse_(name => deleteNamespace(name)) - ) - } - - def usingMinikube[T](body: KubernetesClient[F] => F[T]): T = - unsafeRunSync(kubernetesClient.use(body)) -} diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ReplaceableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ReplaceableTests.scala index 5bdd2498..97f33f44 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ReplaceableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/ReplaceableTests.scala @@ -1,38 +1,36 @@ package com.goyeau.kubernetes.client.operation -import cats.Applicative -import cats.implicits._ +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* import com.goyeau.kubernetes.client.KubernetesClient import com.goyeau.kubernetes.client.Utils.retry import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status -trait ReplaceableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] - extends FunSuite - with MinikubeClientProvider[F] { +trait ReplaceableTests[R <: { def metadata: Option[ObjectMeta] }] { + self: MinikubeClientProvider => def namespacedApi(namespaceName: String)(implicit - client: KubernetesClient[F] - ): Replaceable[F, Resource] + client: KubernetesClient[IO] + ): Replaceable[IO, R] def createChecked(namespaceName: String, resourceName: String)(implicit - client: KubernetesClient[F] - ): F[Resource] + client: KubernetesClient[IO] + ): IO[R] def getChecked(namespaceName: String, resourceName: String)(implicit - client: KubernetesClient[F] - ): F[Resource] - def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): Resource - def modifyResource(resource: Resource): Resource - def checkUpdated(updatedResource: Resource): Unit + client: KubernetesClient[IO] + ): IO[R] + def sampleResource(resourceName: String, labels: Map[String, String] = Map.empty): R + def modifyResource(resource: R): R + def checkUpdated(updatedResource: R): Unit - def replace(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = + def replace(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]) = for { resource <- getChecked(namespaceName, resourceName) status <- namespacedApi(namespaceName).replace(modifyResource(resource)) _ = assertEquals(status, Status.Ok) } yield () - def replaceWithResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = + def replaceWithResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]) = for { resource <- getChecked(namespaceName, resourceName) replacedResource <- namespacedApi(namespaceName).replaceWithResource(modifyResource(resource)) @@ -41,8 +39,8 @@ trait ReplaceableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"replace a $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("some-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("some-resource") _ <- createChecked(namespaceName, resourceName) _ <- retry(replace(namespaceName, resourceName), actionClue = Some("Replacing resource")) replaced <- getChecked(namespaceName, resourceName) @@ -54,8 +52,8 @@ trait ReplaceableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"replace a $resourceName with resource") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) - resourceName <- Applicative[F].pure("some-with-resource") + namespaceName <- IO.pure(resourceName.toLowerCase) + resourceName <- IO.pure("some-with-resource") _ <- createChecked(namespaceName, resourceName) replacedResource <- retry(replaceWithResource(namespaceName, resourceName), actionClue = Some("Replacing resource with resource")) _ = checkUpdated(replacedResource) @@ -78,7 +76,7 @@ trait ReplaceableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] test(s"fail on non existing $resourceName") { usingMinikube { implicit client => for { - namespaceName <- Applicative[F].pure(resourceName.toLowerCase) + namespaceName <- IO.pure(resourceName.toLowerCase) status <- namespacedApi(namespaceName).replace(sampleResource("non-existing")) _ = assert(Set(Status.NotFound, Status.Created).contains(status)) } yield () diff --git a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala index 425167fa..b0ba93cf 100644 --- a/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala +++ b/kubernetes-client/test/src/com/goyeau/kubernetes/client/operation/WatchableTests.scala @@ -1,60 +1,56 @@ package com.goyeau.kubernetes.client.operation -import cats.Parallel -import cats.effect.Ref -import cats.implicits.* +import com.goyeau.kubernetes.client.MinikubeClientProvider +import cats.effect.* +import cats.syntax.all.* import com.goyeau.kubernetes.client.Utils.retry import com.goyeau.kubernetes.client.api.CustomResourceDefinitionsApiTest import com.goyeau.kubernetes.client.{EventType, KubernetesClient, WatchEvent} import fs2.concurrent.SignallingRef -import fs2.{Pipe, Stream} import io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta -import munit.FunSuite import org.http4s.Status import scala.concurrent.duration.* import scala.language.reflectiveCalls import org.http4s.client.UnexpectedStatus -trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] - extends FunSuite - with MinikubeClientProvider[F] { - implicit def parallel: Parallel[F] +trait WatchableTests[R <: { def metadata: Option[ObjectMeta] }] { + self: MinikubeClientProvider => val watchIsNamespaced = true override protected val extraNamespace = List("anothernamespace-" + defaultNamespace) - def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[F]): Creatable[F, Resource] + def namespacedApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Creatable[IO, R] - def sampleResource(resourceName: String, labels: Map[String, String]): Resource + def sampleResource(resourceName: String, labels: Map[String, String]): R - def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Resource] + def getChecked(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[R] - def modifyResource(resource: Resource): Resource + def modifyResource(resource: R): R - def deleteApi(namespaceName: String)(implicit client: KubernetesClient[F]): Deletable[F] + def deleteApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Deletable[IO] - def watchApi(namespaceName: String)(implicit client: KubernetesClient[F]): Watchable[F, Resource] + def watchApi(namespaceName: String)(implicit client: KubernetesClient[IO]): Watchable[IO, R] - def api(implicit client: KubernetesClient[F]): Watchable[F, Resource] + def api(implicit client: KubernetesClient[IO]): Watchable[IO, R] - def deleteResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]): F[Status] = + def deleteResource(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]): IO[Status] = deleteApi(namespaceName).delete(resourceName) - private def update(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[F]) = + private def update(namespaceName: String, resourceName: String)(implicit client: KubernetesClient[IO]) = for { resource <- getChecked(namespaceName, resourceName) status <- createOrUpdate(namespaceName, modifyResource(resource)) _ = assertEquals(status, Status.Ok) } yield () - private def createOrUpdate(namespaceName: String, resource: Resource)(implicit - client: KubernetesClient[F] - ): F[Status] = + private def createOrUpdate(namespaceName: String, resource: R)(implicit + client: KubernetesClient[IO] + ): IO[Status] = namespacedApi(namespaceName).createOrUpdate(resource) - private def sendEvents(namespace: String, resourceName: String)(implicit client: KubernetesClient[F]) = + private def sendEvents(namespace: String, resourceName: String)(implicit client: KubernetesClient[IO]) = for { _ <- retry( createIfMissing(namespace, resourceName), @@ -66,7 +62,7 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] _ = assertEquals(status, Status.Ok, status.sanitizedReason) } yield () - private def createIfMissing(namespace: String, resourceName: String)(implicit client: KubernetesClient[F]) = + private def createIfMissing(namespace: String, resourceName: String)(implicit client: KubernetesClient[IO]) = getChecked(namespace, resourceName).as(()).recoverWith { case err: UnexpectedStatus if err.status == Status.NotFound => for { @@ -85,49 +81,46 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] watchingNamespace: Option[String], resourceVersion: Option[String] = None )(implicit - client: KubernetesClient[F] + client: KubernetesClient[IO] ) = { - def isExpectedResource(we: WatchEvent[Resource]): Boolean = + def isExpectedResource(we: WatchEvent[R]): Boolean = we.`object`.metadata.exists(_.name.exists { name => name == resourceName || name == CustomResourceDefinitionsApiTest.crdName(resourceName) }) def processEvent( - received: Ref[F, Map[String, Set[EventType]]], - signal: SignallingRef[F, Boolean] - ): Pipe[F, Either[String, WatchEvent[Resource]], Unit] = - _.flatMap { + received: Ref[IO, Map[String, Set[EventType]]], + signal: SignallingRef[IO, Boolean], + event: Either[String, WatchEvent[R]] + ): IO[Unit] = + event match { case Right(we) if isExpectedResource(we) => - Stream.eval { - for { - _ <- received.update(events => - we.`object`.metadata.flatMap(_.namespace) match { - case Some(namespace) => - val updated = events.get(namespace) match { - case Some(namespaceEvents) => namespaceEvents + we.`type` - case _ => Set(we.`type`) - } - events.updated(namespace, updated) - case _ => - val crdNamespace = "customresourcedefinition" - events.updated(crdNamespace, events.getOrElse(crdNamespace, Set.empty) + we.`type`) - } - ) - allReceived <- received.get.map(_ == expected) - _ <- F.whenA(allReceived)(signal.set(true)) - } yield () - } - case _ => Stream.eval(F.unit) + for { + _ <- received.update(events => + we.`object`.metadata.flatMap(_.namespace) match { + case Some(namespace) => + val updated = events.get(namespace) match { + case Some(namespaceEvents) => namespaceEvents + we.`type` + case _ => Set(we.`type`) + } + events.updated(namespace, updated) + case _ => + val crdNamespace = "customresourcedefinition" + events.updated(crdNamespace, events.getOrElse(crdNamespace, Set.empty) + we.`type`) + } + ) + allReceived <- received.get.map(_ == expected) + _ <- IO.whenA(allReceived)(signal.set(true)) + } yield () + case _ => IO.unit } val watchEvents = for { - signal <- SignallingRef[F, Boolean](false) - receivedEvents <- Ref.of(Map.empty[String, Set[EventType]]) + signal <- SignallingRef[IO, Boolean](false) + receivedEvents <- IO.ref(Map.empty[String, Set[EventType]]) watchStream = watchingNamespace - .map(watchApi) - .getOrElse(api) + .fold(api)(watchApi) .watch(resourceVersion = resourceVersion) - .through(processEvent(receivedEvents, signal)) - .evalMap(_ => receivedEvents.get) + .evalTap(processEvent(receivedEvents, signal, _)) .interruptWhen(signal) _ <- watchStream.interruptAfter(60.seconds).compile.drain events <- receivedEvents.get @@ -139,9 +132,9 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] } yield () } - private def sendToAnotherNamespace(name: String)(implicit client: KubernetesClient[F]) = - F.whenA(watchIsNamespaced)( - extraNamespace.map(sendEvents(_, name)).sequence + private def sendToAnotherNamespace(name: String)(implicit client: KubernetesClient[IO]) = + IO.whenA(watchIsNamespaced)( + extraNamespace.traverse_(sendEvents(_, name)) ) test(s"watch $resourceName events in all namespaces") { @@ -156,7 +149,7 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] ( watchEvents(expected, name, None), - F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) + IO.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) ).parTupled } } @@ -169,7 +162,7 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] ( watchEvents(Map(defaultNamespace -> expected), name, Some(defaultNamespace)), - F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) + IO.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) ).parTupled } } @@ -188,7 +181,7 @@ trait WatchableTests[F[_], Resource <: { def metadata: Option[ObjectMeta] }] resourceVersion = resource.metadata.flatMap(_.resourceVersion).get _ <- ( watchEvents(Map(defaultNamespace -> expected), name, Some(defaultNamespace), Some(resourceVersion)), - F.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) + IO.sleep(100.millis) *> sendEvents(defaultNamespace, name) *> sendToAnotherNamespace(name) ).parTupled } yield () } diff --git a/mill b/mill index 02f02ed4..6ce3fa13 100755 --- a/mill +++ b/mill @@ -5,7 +5,7 @@ # If no version is given, it falls back to the value of DEFAULT_MILL_VERSION # # Project page: https://github.com/lefou/millw -# Script Version: 0.4.3 +# Script Version: 0.4.8 # # If you want to improve this script, please also contribute your changes back! # @@ -14,7 +14,12 @@ set -e if [ -z "${DEFAULT_MILL_VERSION}" ] ; then - DEFAULT_MILL_VERSION=0.10.8 + DEFAULT_MILL_VERSION="0.11.1" +fi + + +if [ -z "${GITHUB_RELEASE_CDN}" ] ; then + GITHUB_RELEASE_CDN="" fi @@ -50,10 +55,12 @@ if [ -z "${MILL_VERSION}" ] ; then fi fi -if [ -n "${XDG_CACHE_HOME}" ] ; then - MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download" -else - MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download" +if [ -z "${MILL_DOWNLOAD_PATH}" ] ; then + if [ -n "${XDG_CACHE_HOME}" ] ; then + MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download" + else + MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download" + fi fi # If not already set, try to fetch newest from Github @@ -140,22 +147,34 @@ if [ ! -s "${MILL}" ] ; then if [ -x "${OLD_MILL}" ] ; then MILL="${OLD_MILL}" else - VERSION_PREFIX="$(echo $MILL_VERSION | cut -b -4)" - case $VERSION_PREFIX in - 0.0. | 0.1. | 0.2. | 0.3. | 0.4. ) + case $MILL_VERSION in + 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.* ) DOWNLOAD_SUFFIX="" + DOWNLOAD_FROM_MAVEN=0 + ;; + 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M* ) + DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=0 ;; *) DOWNLOAD_SUFFIX="-assembly" + DOWNLOAD_FROM_MAVEN=1 ;; esac - unset VERSION_PREFIX DOWNLOAD_FILE=$(mktemp mill.XXXXXX) + + if [ "$DOWNLOAD_FROM_MAVEN" = "1" ] ; then + DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/${MILL_VERSION}/mill-dist-${MILL_VERSION}.jar" + else + MILL_VERSION_TAG=$(echo $MILL_VERSION | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') + DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" + unset MILL_VERSION_TAG + fi + # TODO: handle command not found - echo "Downloading mill ${MILL_VERSION} from ${MILL_REPO_URL}/releases ..." 1>&2 - MILL_VERSION_TAG=$(echo $MILL_VERSION | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') - ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" + echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2 + ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${DOWNLOAD_URL}" chmod +x "${DOWNLOAD_FILE}" mkdir -p "${MILL_DOWNLOAD_PATH}" mv "${DOWNLOAD_FILE}" "${MILL}" @@ -165,11 +184,23 @@ if [ ! -s "${MILL}" ] ; then fi fi +if [ -z "$MILL_MAIN_CLI" ] ; then + MILL_MAIN_CLI="${0}" +fi + +MILL_FIRST_ARG="" +if [ "$1" = "--bsp" ] || [ "$1" = "-i" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then + # Need to preserve the first position of those listed options + MILL_FIRST_ARG=$1 + shift +fi + unset MILL_DOWNLOAD_PATH unset MILL_OLD_DOWNLOAD_PATH unset OLD_MILL unset MILL_VERSION -unset MILL_VERSION_TAG unset MILL_REPO_URL -exec "${MILL}" "$@" \ No newline at end of file +# We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes +# shellcheck disable=SC2086 +exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" diff --git a/project/Dependencies.sc b/project/Dependencies.sc index 0074bb29..5f240b9a 100644 --- a/project/Dependencies.sc +++ b/project/Dependencies.sc @@ -5,33 +5,56 @@ object Dependencies { lazy val circe = { val version = "0.14.6" Agg( - ivy"io.circe::circe-core:$version", - ivy"io.circe::circe-generic:$version", - ivy"io.circe::circe-parser:$version" + ivy"io.circe::circe-core::$version", + ivy"io.circe::circe-generic::$version", + ivy"io.circe::circe-parser::$version" ) } - lazy val http4s = { - val version = "0.23.24" - val jdkClientVersion = "0.5.0" + lazy val fs2 = { + val version = "3.9.4" Agg( - ivy"org.http4s::http4s-dsl:$version", - ivy"org.http4s::http4s-circe:$version", - ivy"org.http4s::http4s-jdk-http-client:$jdkClientVersion" + ivy"co.fs2::fs2-core::$version", + ivy"co.fs2::fs2-io::$version" ) } - lazy val circeYaml = Agg(ivy"io.circe::circe-yaml:0.15.1") + object http4s { + // private val version = "0.23.23" + private val version = "0.23.24-104-a1fcba6-SNAPSHOT" + private val jdkClientVersion = "0.9.1" + val core = Agg( + ivy"org.http4s::http4s-dsl::$version", + ivy"org.http4s::http4s-circe::$version", + ivy"org.http4s::http4s-client::$version" + ) + val jdkClient = Agg(ivy"org.http4s::http4s-jdk-http-client::$jdkClientVersion") + val emberClient = Agg(ivy"org.http4s::http4s-ember-client::$version") + } + + lazy val circeYaml = Agg(ivy"com.armanbilge::circe-scala-yaml::0.0.4") lazy val bouncycastle = Agg(ivy"org.bouncycastle:bcpkix-jdk18on:1.77") lazy val collectionCompat = Agg(ivy"org.scala-lang.modules::scala-collection-compat:2.11.0") - lazy val logging = Agg(ivy"org.typelevel::log4cats-slf4j:2.6.0") + object log4cats { + private val version = "2.6.0" + + val core = Agg(ivy"org.typelevel::log4cats-core:2.6.0") - lazy val logback = Agg(ivy"ch.qos.logback:logback-classic:1.4.11") + val logback = Agg( + ivy"org.typelevel::log4cats-slf4j:2.6.0", + ivy"ch.qos.logback:logback-classic:1.4.11" + ) + + val jsConsole = Agg(ivy"org.typelevel::log4cats-js-console::2.6.0") + } - lazy val java8compat = Agg(ivy"org.scala-lang.modules::scala-java8-compat:1.0.2") + lazy val java8compat = Agg(ivy"org.scala-lang.modules::scala-java8-compat::1.0.2") - lazy val tests = Agg(ivy"org.scalameta::munit:0.7.29") + lazy val tests = Agg( + ivy"org.scalameta::munit::1.0.0-M10", + ivy"org.typelevel::munit-cats-effect::2.0.0-M4" + ) } diff --git a/project/SwaggerModelGenerator.sc b/project/SwaggerModelGenerator.sc index bf9e1aea..db9f2f23 100644 --- a/project/SwaggerModelGenerator.sc +++ b/project/SwaggerModelGenerator.sc @@ -4,7 +4,7 @@ import $ivy.`io.circe::circe-generic:0.14.0` import $ivy.`io.circe::circe-parser:0.14.0` import mill._ import mill.api.Logger -import mill.define.Sources +import mill.define.Target import mill.scalalib._ import io.circe._ import io.circe.generic.auto._ @@ -14,7 +14,8 @@ import os._ trait SwaggerModelGenerator extends JavaModule { import SwaggerModelGenerator._ - def swaggerSources: Sources = T.sources(resources().map(resource => PathRef(resource.path / "swagger"))) + def swaggerSources: Target[Seq[mill.api.PathRef]] = + T.sources(resources().map(resource => PathRef(resource.path / "swagger"))) def allSwaggerSourceFiles: T[Seq[PathRef]] = T { def isHiddenFile(path: os.Path) = path.last.startsWith(".") for { @@ -28,7 +29,7 @@ trait SwaggerModelGenerator extends JavaModule { override def generatedSources = T { super.generatedSources() ++ allSwaggerSourceFiles() - .flatMap(swagger => processSwaggerFile(swagger.path, T.ctx.dest, T.ctx.log)) + .flatMap(swagger => processSwaggerFile(swagger.path, T.ctx().dest, T.ctx().log)) .map(PathRef(_)) } } @@ -164,7 +165,7 @@ object SwaggerModelGenerator { if (work.length <= maxLen) { lines.append(work) work = "" - } else { + } else (2 to 20).flatMap { lookBehind => Seq(' ', ',', '.', ';') .flatMap { c => @@ -181,7 +182,6 @@ object SwaggerModelGenerator { lines.append(work) work = "" } - } } lines.toList } @@ -228,6 +228,7 @@ object SwaggerModelGenerator { case (Some(t), None) => swaggerToScalaType(t, property.items.orElse(property.additionalProperties), property.format) case (None, Some(ref)) => sanitizeClassPath(ref) + case other => throw new RuntimeException(s"unexpected property: $other (expected either Some -> None, or None -> Some)") } def swaggerToScalaType(