diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c416b602b..daa533320 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ managed-apache-http-core5 = "5.3.1" micronaut-gradle-plugin = "4.4.4" micronaut-groovy = "4.5.0" micronaut-kotlin = "4.5.0" +micronaut-kubernetes = "6.2.1" micronaut-micrometer = "5.9.3" micronaut-reactor = "3.6.0" micronaut-rxjava2 = "2.6.0" @@ -41,6 +42,7 @@ micronaut-core = { module = 'io.micronaut:micronaut-core-bom', version.ref = 'mi # micronaut boms micronaut-groovy = { module = "io.micronaut.groovy:micronaut-groovy-bom", version.ref = "micronaut-groovy" } micronaut-kotlin = { module = "io.micronaut.kotlin:micronaut-kotlin-bom", version.ref = "micronaut-kotlin" } +micronaut-kubernetes = { module = "io.micronaut.kubernetes:micronaut-kubernetes-bom", version.ref = "micronaut-kubernetes" } micronaut-micrometer = { module = "io.micronaut.micrometer:micronaut-micrometer-bom", version.ref = "micronaut-micrometer" } micronaut-reactor = { module = "io.micronaut.reactor:micronaut-reactor-bom", version.ref = "micronaut-reactor" } micronaut-rxjava2 = { module = "io.micronaut.rxjava2:micronaut-rxjava2-bom", version.ref = "micronaut-rxjava2" } diff --git a/oraclecloud-oke-kubernetes-client/build.gradle b/oraclecloud-oke-kubernetes-client/build.gradle new file mode 100644 index 000000000..dfde638a2 --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'io.micronaut.build.internal.oraclecloud-module' +} + +dependencies { + api mnKubernetes.micronaut.kubernetes.client.openapi.common + implementation mnValidation.validation + implementation projects.micronautOraclecloudBmcContainerengine + + testImplementation mnKubernetes.micronaut.kubernetes.client.openapi + api projects.micronautOraclecloudCommon + testImplementation mn.micronaut.context + testAnnotationProcessor mn.micronaut.inject.java + testImplementation mn.micronaut.inject.java + testImplementation mn.micronaut.inject.groovy + testImplementation mn.micronaut.inject.groovy.test + testImplementation mn.micronaut.http.server.netty +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() +} + +micronautBuild { + binaryCompatibility { + enabled = false + } +} diff --git a/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubeConfigLoader.java b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubeConfigLoader.java new file mode 100644 index 000000000..4690ef83a --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubeConfigLoader.java @@ -0,0 +1,113 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.oraclecloud.oke.kubernetes.client; + +import com.oracle.bmc.containerengine.ContainerEngineClient; +import com.oracle.bmc.containerengine.model.CreateClusterKubeconfigContentDetails; +import com.oracle.bmc.containerengine.model.CreateClusterKubeconfigContentDetails.Endpoint; +import com.oracle.bmc.containerengine.requests.CreateKubeconfigRequest; +import com.oracle.bmc.containerengine.responses.CreateKubeconfigResponse; +import io.micronaut.context.annotation.BootstrapContextCompatible; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.io.ResourceResolver; +import io.micronaut.kubernetes.client.openapi.config.AbstractKubeConfigLoader; +import io.micronaut.kubernetes.client.openapi.config.KubeConfig; +import io.micronaut.kubernetes.client.openapi.config.KubeConfigLoader; +import jakarta.inject.Singleton; +import java.io.IOException; +import java.io.InputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A class for loading a kubeconfig from OKE. It replaces the default kube config loader, which + * reads it from a file. + * + * @since 4.4.x + * @author Andriy Dmytruk + */ +@Singleton +@BootstrapContextCompatible +@Replaces(KubeConfigLoader.class) +@Requires(beans = OkeKubernetesClientConfig.class) +final class OkeKubeConfigLoader extends AbstractKubeConfigLoader { + + private static final String TOKEN_VERSION = "2.0.0"; + + private static final Logger LOG = LoggerFactory.getLogger(OkeKubeConfigLoader.class); + private final ContainerEngineClient client; + private final OkeKubernetesClientConfig config; + + /** + * Create Kubeconfig loader. + * + * @param containerEngineClient The client to use to get kubeconfig + * @param config The configuration + * @param resourceResolver The resource resolver. + */ + OkeKubeConfigLoader( + ContainerEngineClient containerEngineClient, + OkeKubernetesClientConfig config, + ResourceResolver resourceResolver + ) { + super(resourceResolver); + this.client = containerEngineClient; + this.config = config; + } + + @Override + protected @Nullable KubeConfig loadKubeConfig() { + return createCubeConfig(config.clusterId(), config.endpointType()); + } + + /** + * A method that uses the container engine client to create a kube config and write it to the + * file specified by the environment variable. + * + * @param okeClusterId The cluster ID + * @param endpointType The endpoint type + * @return The kubeconfig + */ + protected KubeConfig createCubeConfig(String okeClusterId, Endpoint endpointType) { + LOG.info("Creating remote kubeconfig for cluster id {}", okeClusterId); + CreateClusterKubeconfigContentDetails body = CreateClusterKubeconfigContentDetails.builder() + .tokenVersion(TOKEN_VERSION) + .endpoint(endpointType) + .build(); + CreateKubeconfigRequest kubeConfigRequest = CreateKubeconfigRequest.builder() + .clusterId(okeClusterId) + .createClusterKubeconfigContentDetails(body) + .build(); + + CreateKubeconfigResponse response; + try { + response = client.createKubeconfig(kubeConfigRequest); + } catch (Exception e) { + LOG.error("Caught exception when creating kubeconfig for cluster: {}", okeClusterId, e); + throw new IllegalStateException("Unable to create KubeConfig", e); + } + LOG.info("Successfully received kubeconfig response for cluster id {}", okeClusterId); + try (InputStream kubeConfig = response.getInputStream()) { + return loadKubeConfigFromInputStream(kubeConfig); + } catch (IOException e) { + LOG.error("Caught exception when reading kubeconfig for cluster {}", okeClusterId, e); + throw new RuntimeException("Unable to create kubeClient", e); + } + } + +} diff --git a/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesClientConfig.java b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesClientConfig.java new file mode 100644 index 000000000..9d4adc063 --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesClientConfig.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.oraclecloud.oke.kubernetes.client; + +import com.oracle.bmc.containerengine.model.CreateClusterKubeconfigContentDetails.Endpoint; +import io.micronaut.context.annotation.BootstrapContextCompatible; +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.bind.annotation.Bindable; +import io.micronaut.core.util.StringUtils; +import jakarta.validation.constraints.NotBlank; + +/** + * Configuration for the OKE kubernetes client. + * If enabled, the client will retrieve a kubeconfig from the cluster and sign tokens + * with the OCI SDK authentication. This is not required on kubernetes nodes, as there + * kubeconfig should already be present. + * + * @param enabled Whether the client is enabled + * @param clusterId The OKE cluster ID + * @param endpointType Define which endpoint type to use. One of {@code PublicEndpoint}, + * {@code PrivateEndpoint}, {@code VcnHostname} and {@code LegacyKubernetes}. + * Default is {@code PublicEndpoint}. + * + * @since 4.4.x + * @author Andriy Dmytruk + */ +@Requires(property = OkeKubernetesClientConfig.ENABLED, value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Requires(property = OkeKubernetesClientConfig.CLUSTER_ID) +@ConfigurationProperties(OkeKubernetesClientConfig.PREFIX) +@BootstrapContextCompatible +public record OkeKubernetesClientConfig( + @Bindable(defaultValue = StringUtils.TRUE) + boolean enabled, + @NonNull @NotBlank + String clusterId, + @Nullable @Bindable(defaultValue = "PublicEndpoint") + Endpoint endpointType +) { + + public static final String PREFIX = "oci.oke.kubernetes.client"; + public static final String ENABLED = PREFIX + ".enabled"; + public static final String CLUSTER_ID = PREFIX + ".cluster-id"; + +} diff --git a/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesCredentialLoader.java b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesCredentialLoader.java new file mode 100644 index 000000000..5a864b8fa --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/main/java/io/micronaut/oraclecloud/oke/kubernetes/client/OkeKubernetesCredentialLoader.java @@ -0,0 +1,248 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.oraclecloud.oke.kubernetes.client; + +import com.oracle.bmc.auth.AbstractAuthenticationDetailsProvider; +import com.oracle.bmc.containerengine.ContainerEngine; +import com.oracle.bmc.http.signing.RequestSigner; +import com.oracle.bmc.http.signing.RequestSignerFactory; +import com.oracle.bmc.http.signing.SigningStrategy; +import com.oracle.bmc.http.signing.internal.DefaultRequestSignerFactory; +import io.micronaut.context.annotation.BootstrapContextCompatible; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.uri.UriBuilder; +import io.micronaut.kubernetes.client.openapi.config.KubeConfig; +import io.micronaut.kubernetes.client.openapi.config.KubeConfigLoader; +import io.micronaut.kubernetes.client.openapi.config.model.ExecConfig; +import io.micronaut.kubernetes.client.openapi.credential.KubernetesTokenLoader; +import io.micronaut.kubernetes.client.openapi.credential.model.ExecCredential; +import io.micronaut.kubernetes.client.openapi.credential.model.ExecCredentialStatus; +import jakarta.inject.Singleton; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A kubernetes credential loader which creates the credentials using a kubeconfig fetched + * from OKE with {@link OkeKubeConfigLoader}. It uses + * {@link AbstractAuthenticationDetailsProvider} or {@link RequestSigner} to create + * the OKE Kubernetes client token. + * + * @since 4.4.x + * @author Andriy Dmytruk + */ +@Singleton +@BootstrapContextCompatible +@Requires(beans = { + AbstractAuthenticationDetailsProvider.class, + OkeKubernetesClientConfig.class +}) +@Internal +public class OkeKubernetesCredentialLoader implements KubernetesTokenLoader { + + private static final String EXPECTED_COMMAND = "oci"; + private static final String[] EXPECTED_ARGS = {"ce", "cluster", "generate-token"}; + private static final String CLUSTER_ID_ARG = "--cluster-id"; + private static final String REGION_ARG = "--region"; + + private static final String DELEGATION_TOKEN_HEADER = "opc-obo-token"; + private static final String AUTHORIZATION_HEADER = "authorization"; + private static final String DATE_HEADER = "date"; + + private static final String TOKEN_URL_FORMAT = "%s/cluster_request/%s"; + private static final String EXEC_CREDENTIAL_API_VERSION = "client.authentication.k8s.io/v1beta1"; + private static final String EXEC_CREDENTIAL_KIND = "ExecCredential"; + + private static final Logger LOG = LoggerFactory.getLogger(OkeKubernetesCredentialLoader.class); + + /** + * Higher precedence than for ExecCommandCredentialLoader. + */ + private static final int ORDER = 5; + + private static final Duration BUFFER = Duration.ofSeconds(60); + + private final String containerEngineEndpoint; + private final RequestSigner requestSigner; + private final KubeConfig kubeConfig; + + private volatile ExecCredential execCredential; + + OkeKubernetesCredentialLoader( + @Nullable RequestSignerFactory requestSignerFactory, + @NonNull AbstractAuthenticationDetailsProvider authProvider, + KubeConfigLoader kubeConfigLoader, + @NonNull ContainerEngine containerEngine + ) { + containerEngineEndpoint = containerEngine.getEndpoint(); + if (requestSignerFactory == null) { + requestSignerFactory = new DefaultRequestSignerFactory(SigningStrategy.STANDARD); + } + this.requestSigner = requestSignerFactory.createRequestSigner(null, authProvider); + this.kubeConfig = kubeConfigLoader.getKubeConfig(); + } + + @Override + public String getToken() { + setExecCredential(); + return execCredential == null ? null : execCredential.status().token(); + } + + @Override + public int getOrder() { + return ORDER; + } + + /** + * Inner method that refreshes the credential if required. + * It parses parameters from kubeconfig exec command and refreshes token. + */ + private void setExecCredential() { + if (kubeConfig == null || kubeConfig.getUser() == null) { + return; + } + ParsedExecCommand command = parseCommand(kubeConfig.getUser().exec()); + if (command == null) { + return; + } + if (shouldLoadCredential()) { + synchronized (this) { + if (shouldLoadCredential()) { + try { + execCredential = loadCredential(command); + } catch (Exception e) { + LOG.error("Failed to load exec credential", e); + } + } + } + } + } + + /** + * Parse the exec command provided in the kubeconfig to get the required parameters. + * + * @param execConfig The config + * @return The command + */ + private ParsedExecCommand parseCommand(ExecConfig execConfig) { + if (execConfig == null) { + return null; + } + if (!EXPECTED_COMMAND.equals(execConfig.command())) { + return null; + } + List args = execConfig.args(); + for (int i = 0; i < EXPECTED_ARGS.length; i++) { + if (!EXPECTED_ARGS[i].equals(args.get(i))) { + return null; + } + } + String clusterId = null; + String region = null; + for (int i = EXPECTED_ARGS.length; i < args.size() - 1; i++) { + if (CLUSTER_ID_ARG.equals(args.get(i))) { + ++i; + clusterId = args.get(i); + } + if (REGION_ARG.equals(args.get(i))) { + ++i; + region = args.get(i); + } + } + if (clusterId == null) { + throw new IllegalStateException("Cluster ID is required, but was not found in the kubeconfig exec command"); + } + return new ParsedExecCommand(region, clusterId); + } + + private boolean shouldLoadCredential() { + if (execCredential == null) { + return true; + } + ZonedDateTime expiration = execCredential.status().expirationTimestamp(); + if (expiration == null) { + return false; + } + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC")); + LOG.debug("Check whether credential loading needed, now={}, buffer={}, expiration={}", now, BUFFER, expiration); + return expiration.isBefore(now.plusSeconds(BUFFER.toSeconds())); + } + + /** + * Based on + * containerengine_cli_extended.py + * . + * + * @param command The parsed exec command + * @return The credential + */ + private ExecCredential loadCredential(ParsedExecCommand command) { + LOG.debug("Creating OKE kubernetes client credential"); + URI uri = URI.create(String.format(TOKEN_URL_FORMAT, containerEngineEndpoint, command.clusterId)); + Map headers = requestSigner.signRequest(uri, "GET", Collections.emptyMap(), null); + + // The authentication headers are formatted inside the URI + UriBuilder builder = UriBuilder.of(uri) + .queryParam(AUTHORIZATION_HEADER, headers.get(AUTHORIZATION_HEADER)) + .queryParam(DATE_HEADER, headers.get(DATE_HEADER)); + if (headers.containsKey(DELEGATION_TOKEN_HEADER)) { + builder.queryParam(DELEGATION_TOKEN_HEADER, headers.get(DELEGATION_TOKEN_HEADER)); + } + + return new ExecCredential( + EXEC_CREDENTIAL_API_VERSION, + EXEC_CREDENTIAL_KIND, + new ExecCredentialStatus( + base64Encode(builder.toString()), + null, + null, + ZonedDateTime.now().plusMinutes(4) + ) + ); + } + + private String base64Encode(String url) { + ByteBuffer urlBytes = ByteBuffer.wrap(url.getBytes(StandardCharsets.UTF_8)); + // Must have padding + ByteBuffer encoded = Base64.getUrlEncoder().encode(urlBytes); + return StandardCharsets.UTF_8.decode(encoded).toString(); + } + + /** + * A utility data record for the parsed exec command from kube config. + * + * @param region The region + * @param clusterId The cluster id + */ + private record ParsedExecCommand( + String region, + String clusterId + ) { + } + +} diff --git a/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/MockKubernetesSpec.groovy b/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/MockKubernetesSpec.groovy new file mode 100644 index 000000000..b5d9bcae5 --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/MockKubernetesSpec.groovy @@ -0,0 +1,175 @@ +package io.micronaut.oraclecloud.oke.kubernetes.client + +import com.oracle.bmc.Region +import com.oracle.bmc.Service +import com.oracle.bmc.auth.AbstractAuthenticationDetailsProvider +import com.oracle.bmc.auth.AuthCachingPolicy +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider +import com.oracle.bmc.auth.RegionProvider +import com.oracle.bmc.containerengine.ContainerEngineClient +import com.oracle.bmc.http.signing.RequestSigner +import com.oracle.bmc.http.signing.RequestSignerFactory +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.context.event.BeanCreatedEvent +import io.micronaut.context.event.BeanCreatedEventListener +import io.micronaut.core.annotation.NonNull +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Header +import io.micronaut.http.annotation.Post +import io.micronaut.http.annotation.Produces +import io.micronaut.http.server.exceptions.HttpServerException +import io.micronaut.kubernetes.client.openapi.api.CoreV1Api +import io.micronaut.kubernetes.client.openapi.model.V1Namespace +import io.micronaut.kubernetes.client.openapi.model.V1NamespaceList +import io.micronaut.kubernetes.client.openapi.model.V1ObjectMeta +import io.micronaut.runtime.server.EmbeddedServer +import jakarta.inject.Singleton +import spock.lang.AutoCleanup +import spock.lang.Specification + +import java.nio.charset.StandardCharsets + +class MockKubernetesSpec extends Specification { + + @AutoCleanup + EmbeddedServer server = ApplicationContext.run(EmbeddedServer, [ + 'spec.name': 'MockKubernetesSpec-Server', + 'kubernetes.client.enabled': false + ]) + + void "test kubernetes request"() { + given: + var clientContext = ApplicationContext.run([ + "oci.oke.kubernetes.client.cluster-id": 'test-id', + 'spec.name': 'MockKubernetesSpec', + 'oci.config.enabled': 'false', + "spec.server.url": server.URI + ]) + var coreV1Api = clientContext.getBean(CoreV1Api) + + when: + V1NamespaceList list = coreV1Api.listNamespace( + null, null, null, null, + null, null, null, null, + null, null, null + ) + + then: + list.getItems().size() == 1 + list.getItems()[0].metadata.name == 'my-test-name-1' + + cleanup: + clientContext.close() + } + + @Controller + @Requires(property = 'spec.name', value = 'MockKubernetesSpec-Server') + static class KubernetesController { + + static final String KUBE_CONFIG = """\ +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: %s +users: + - name: test-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: oci + args: + - ce + - cluster + - generate-token + - --cluster-id + - test-cluster-id + - --region + - us-phoenix-1 +contexts: + - name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + + private EmbeddedServer server + + KubernetesController(EmbeddedServer server) { + this.server = server + } + + // The container engine endpoint + @Post("/20180222/clusters/test-id/kubeconfig/content") + @Produces("application/x-yaml") + String getKubeConfig() { + return String.format(KUBE_CONFIG, server.getURI()) + } + + // The kubernetes endpoint + @Get("/api/v1/namespaces") + V1NamespaceList namespaces(@Header("authorization") auth) { + var content = new String(Base64.getUrlDecoder().decode(auth.substring("Bearer ".length())), StandardCharsets.UTF_8) + String expectedAuth = server.getURI().toString() + "/cluster_request/test-cluster-id?authorization=test&date=2024-02-12" + if (content != expectedAuth) { + throw new HttpServerException("Incorrect auth: " + content + ". Expected: " + expectedAuth) + } + return new V1NamespaceList([ + new V1Namespace().metadata(new V1ObjectMeta().name("my-test-name-1")) + ]) + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'MockKubernetesSpec') + static class ClientConfigurator implements BeanCreatedEventListener { + + private String serverUrl + + ClientConfigurator(@Property(name = "spec.server.url") String serverUrl) { + this.serverUrl = serverUrl + } + + @Override + ContainerEngineClient.Builder onCreated(@NonNull BeanCreatedEvent event) { + event.getBean().endpoint(serverUrl) + return event.getBean() + } + } + + @AuthCachingPolicy(cacheKeyId = false, cachePrivateKey = false) + @Singleton + @Replaces(ConfigFileAuthenticationDetailsProvider.class) + @Requires(property = 'spec.name', value = 'MockKubernetesSpec') + static class MockAuthenticationDetailsProvider implements AbstractAuthenticationDetailsProvider { + + } + + @Singleton + @Requires(property = 'spec.name', value = 'MockKubernetesSpec') + static class MockRequestSignerFactory implements RequestSignerFactory { + @Override + RequestSigner createRequestSigner(Service service, AbstractAuthenticationDetailsProvider abstractAuthenticationDetailsProvider) { + return (URI uri, String s, Map> map, Object o) -> [ + 'authorization': 'test', + 'date': '2024-02-12' + ] + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'MockKubernetesSpec') + static class MockRegionProvider implements RegionProvider { + @Override + Region getRegion() { + return null + } + } + +} diff --git a/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/ProductionKubernetesSpec.groovy b/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/ProductionKubernetesSpec.groovy new file mode 100644 index 000000000..ac8f125b3 --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/test/groovy/io/micronaut/oraclecloud/oke/kubernetes/client/ProductionKubernetesSpec.groovy @@ -0,0 +1,38 @@ +package io.micronaut.oraclecloud.oke.kubernetes.client + +import com.oracle.bmc.auth.AuthenticationDetailsProvider +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.kubernetes.client.openapi.api.CoreV1Api +import io.micronaut.kubernetes.client.openapi.model.V1NamespaceList +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +/** + * This is a test that can be run with an actual OKE cluster. + * Set {@code oci.oke.kubernetes.client.cluster-id} property and make sure you have a valid OCI configuration to run it. + */ +@Requires(bean = AuthenticationDetailsProvider.class) +@Requires(property = "oci.oke.kubernetes.client.cluster-id") +@Property(name = "oci.oke.kubernetes.client.endpoint-type", value = 'PublicEndpoint') +@MicronautTest +class ProductionKubernetesSpec extends Specification { + + @Inject + CoreV1Api coreV1Api + + void "test kubernetes request"() { + when: + V1NamespaceList list = coreV1Api.listNamespace( + null, null, null, null, + null, null, null, null, + null, null, null + ) + + then: + list.getItems().size() >= 4 + list.getItems().collect { it.metadata.name }.toSorted() == ["default", "kube-node-lease", "kube-public", "kube-system"] + } + +} diff --git a/oraclecloud-oke-kubernetes-client/src/test/resources/logback.xml b/oraclecloud-oke-kubernetes-client/src/test/resources/logback.xml new file mode 100644 index 000000000..8f8d9e97c --- /dev/null +++ b/oraclecloud-oke-kubernetes-client/src/test/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b3f6ec1a..7b0f36481 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ include("oraclecloud-httpclient-netty") include("oraclecloud-serde-processor") include("oraclecloud-logging") include("oraclecloud-micrometer") +include("oraclecloud-oke-kubernetes-client") include("oraclecloud-oke-workload-identity") include("oraclecloud-sdk-base") include("oraclecloud-sdk") @@ -82,6 +83,7 @@ configure { importMicronautCatalog("micronaut-sql") importMicronautCatalog("micronaut-validation") importMicronautCatalog("micronaut-discovery-client") + importMicronautCatalog("micronaut-kubernetes") } val libs = Toml.parse(File(rootProject.projectDir.absoluteFile, "gradle/libs.versions.toml").toPath())!! diff --git a/src/main/docs/guide/okeKubernetesClient.adoc b/src/main/docs/guide/okeKubernetesClient.adoc new file mode 100644 index 000000000..26dbc682d --- /dev/null +++ b/src/main/docs/guide/okeKubernetesClient.adoc @@ -0,0 +1,28 @@ +The `micronaut-oraclecloud-oke-kubernetes-client` module integrates https://micronaut-projects.github.io/micronaut-kubernetes/latest/guide/#kubernetes-client-openapi[Micronaut Kubernetes Client] with the OKE and OCI Container Engine service. + +When the module is enabled, the client will retrieve a kubeconfig from the OKE cluster and provide authentication +for requests to the OKE API server. This module is not needed for Kubernetes nodes that do not connect to +the Kubernetes API server. Instead, this is useful for sending requests to manage the OKE cluster. + +NOTE: If you want to use an OCI client inside an OKE node, use the `micronaut-oraclecloud-oke-workload-identity` module instead. + +To begin, add a dependency on the `micronaut-oraclecloud-oke-kubernetes-client` module: + +dependency:io.micronaut.oraclecloud:micronaut-oraclecloud-oke-kubernetes-client[scope="runtime"] + +Add a dependency on one of the Kubernetes OpenAPI clients (there is a blocking and a reactor version): + +dependency:io.micronaut.kubernetes:micronaut-kubernetes-client-openapi[scope="implementation"] + +Then configure the OKE kubernetes client cluster id: + +[configuration,title="Configuring cluster id"] +---- +oci: + oke: + kubernetes: + client: + cluster-id: 'ocid..my-cluster-id' +---- + +See all available configuration properties in https://micronaut-projects.github.io/micronaut-oracle-cloud/snapshot/guide/configurationreference.html#io.micronaut.oraclecloud.oke.kubernetes.client.OkeKubernetesClientConfig[OkeKubernetesClientConfig]. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index aae9358c8..e38a420cb 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -15,5 +15,6 @@ logging: OCI Logging service vault: Secure Distributed Configuration with Oracle Cloud Vault cloudStorage: Oracle Cloud Storage Support certificates: OCI Certificate SSL Support +okeKubernetesClient: OCI OKE Kubernetes Client oracleCloudGuides: Guides repository: Repository