diff --git a/docs/src/main/asciidoc/deploying-to-kubernetes.adoc b/docs/src/main/asciidoc/deploying-to-kubernetes.adoc index 6d6de098f75d3..9186346d280e8 100644 --- a/docs/src/main/asciidoc/deploying-to-kubernetes.adoc +++ b/docs/src/main/asciidoc/deploying-to-kubernetes.adoc @@ -169,6 +169,48 @@ quarkus.container-image.tag=1.0 #optional, defaults to the application ver The image that will be used in the generated manifests will be `quarkus/demo-app:1.0` +=== Generating idempotent resources + +When generating the Kubernetes manifests, Quarkus automatically adds some labels and annotations to give extra information about the generation date or versions. For example: + +[source,yaml] +---- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + app.quarkus.io/commit-id: 0f8b87788bc446a9347a7961bea8a60889fe1494 + app.quarkus.io/build-timestamp: 2023-02-10 - 13:07:51 +0000 + labels: + app.kubernetes.io/managed-by: quarkus + app.kubernetes.io/version: 0.0.1-SNAPSHOT + app.kubernetes.io/name: example + name: example +spec: + ... +---- + +The `app.quarkus.io/commit-id`, `app.quarkus.io/build-timestamp` labels and the `app.kubernetes.io/version` annotation might change every time we re-build the Kubernetes manifests which can be problematic when we want to deploy these resources using a Git-Ops tool (because these tools will detect differences and hence will perform a re-deployment). + +To make the generated resources Git-Ops friendly and only produce idempotent resources (resources that won't change every time we build the sources), we need to add the following property: + +[source,properties] +---- +quarkus.kubernetes.idempotent=true +---- + +Moreover, by default the directory where the generated resources are created is `target/kubernetes`, to change it, we need to use: + +[source,properties] +---- +quarkus.kubernetes.output-directory=target/kubernetes-with-idempotent +---- + +[NOTE] +==== +Note that the property `quarkus.kubernetes.output-directory` is relative to the current project location. +==== + === Changing the generated deployment resource Besides generating a `Deployment` resource, you can also choose to generate either a `StatefulSet`, or a `Job`, or a `CronJob` resource instead via `application.properties`: diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KnativeConfig.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KnativeConfig.java index 2aeed852725cd..67920150ee0ac 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KnativeConfig.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KnativeConfig.java @@ -228,6 +228,13 @@ public class KnativeConfig implements PlatformConfiguration { @ConfigItem(defaultValue = "true") boolean addNameToLabelSelectors; + /** + * Switch used to control whether non-idempotent fields are included in generated kubernetes resources to improve + * git-ops compatibility + */ + @ConfigItem(defaultValue = "false") + boolean idempotent; + public Optional getPartOf() { return partOf; } @@ -493,4 +500,9 @@ public Optional getAppConfigMap() { public SecurityContextConfig getSecurityContext() { return securityContext; } + + @Override + public boolean isIdempotent() { + return idempotent; + } } diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesCommonHelper.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesCommonHelper.java index 959864deab4c1..26e87d10a89f1 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesCommonHelper.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesCommonHelper.java @@ -59,6 +59,7 @@ import io.dekorate.kubernetes.decorator.RemoveAnnotationDecorator; import io.dekorate.kubernetes.decorator.RemoveFromMatchingLabelsDecorator; import io.dekorate.kubernetes.decorator.RemoveFromSelectorDecorator; +import io.dekorate.kubernetes.decorator.RemoveLabelDecorator; import io.dekorate.project.BuildInfo; import io.dekorate.project.FileProjectFactory; import io.dekorate.project.Project; @@ -239,12 +240,17 @@ private static Collection createLabelDecorators(Optional createAnnotationDecorators(Optional createAnnotationDecorators(Optional outputDirectory; + public Optional getPartOf() { return partOf; } @@ -536,6 +549,11 @@ public SecurityContextConfig getSecurityContext() { return securityContext; } + @Override + public boolean isIdempotent() { + return idempotent; + } + public KubernetesConfig.DeploymentResourceKind getDeploymentResourceKind(Capabilities capabilities) { if (deploymentKind.isPresent()) { return deploymentKind.get(); diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesProcessor.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesProcessor.java index ec65140a97419..b19c991481ee6 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesProcessor.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/KubernetesProcessor.java @@ -187,6 +187,9 @@ public void build(ApplicationInfoBuildItem applicationInfo, } }); + Path targetDirectory = kubernetesConfig.outputDirectory.map(d -> Paths.get("").toAbsolutePath().resolve(d)) + .orElse(outputTarget.getOutputDirectory().resolve(KUBERNETES)); + // write the generated resources to the filesystem generatedResourcesMap = session.close(); List generatedFiles = new ArrayList<>(generatedResourcesMap.size()); @@ -198,7 +201,7 @@ public void build(ApplicationInfoBuildItem applicationInfo, continue; } String fileName = path.toFile().getName(); - Path targetPath = outputTarget.getOutputDirectory().resolve(KUBERNETES).resolve(fileName); + Path targetPath = targetDirectory.resolve(fileName); String relativePath = targetPath.toAbsolutePath().toString().replace(root.toAbsolutePath().toString(), ""); generatedKubernetesResourceProducer.produce(new GeneratedKubernetesResourceBuildItem(fileName, diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/OpenshiftConfig.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/OpenshiftConfig.java index 28d4c6653762f..46a81b67f3bab 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/OpenshiftConfig.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/OpenshiftConfig.java @@ -560,6 +560,13 @@ public EnvVarsConfig getEnv() { @ConfigItem(defaultValue = "true") boolean externalizeInit; + /** + * Switch used to control whether non-idempotent fields are included in generated kubernetes resources to improve + * git-ops compatibility + */ + @ConfigItem(defaultValue = "false") + boolean idempotent; + public Optional getAppSecret() { return this.appSecret; } @@ -573,6 +580,11 @@ public SecurityContextConfig getSecurityContext() { return securityContext; } + @Override + public boolean isIdempotent() { + return idempotent; + } + public static boolean isOpenshiftBuildEnabled(ContainerImageConfig containerImageConfig, Capabilities capabilities) { boolean implicitlyEnabled = ContainerImageCapabilitiesUtil.getActiveContainerImageCapability(capabilities) .filter(c -> c.contains(OPENSHIFT) || c.contains(S2I)).isPresent(); diff --git a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PlatformConfiguration.java b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PlatformConfiguration.java index 02358b76cbfe2..239b7b4694474 100644 --- a/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PlatformConfiguration.java +++ b/extensions/kubernetes/vanilla/deployment/src/main/java/io/quarkus/kubernetes/deployment/PlatformConfiguration.java @@ -88,4 +88,5 @@ default String getConfigName() { SecurityContextConfig getSecurityContext(); + boolean isIdempotent(); } diff --git a/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithIdempotentTest.java b/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithIdempotentTest.java new file mode 100644 index 0000000000000..bb9eb39b57916 --- /dev/null +++ b/integration-tests/kubernetes/quarkus-standard-way/src/test/java/io/quarkus/it/kubernetes/KubernetesWithIdempotentTest.java @@ -0,0 +1,57 @@ + +package io.quarkus.it.kubernetes; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.dekorate.utils.Labels; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.quarkus.builder.Version; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusProdModeTest; + +public class KubernetesWithIdempotentTest { + + private static final String APP_NAME = "kubernetes-with-idempotent"; + + @RegisterExtension + static final QuarkusProdModeTest config = new QuarkusProdModeTest() + .withApplicationRoot((jar) -> jar.addClasses(GreetingResource.class)) + .setApplicationName(APP_NAME) + .setApplicationVersion("0.1-SNAPSHOT") + .withConfigurationResource(APP_NAME + ".properties") + .setLogFileName("k8s.log") + .setForcedDependencies(List.of(Dependency.of("io.quarkus", "quarkus-kubernetes", Version.getVersion()))); + + @Test + public void assertGeneratedResources() throws IOException { + final Path kubernetesDir = Paths.get("target").resolve(APP_NAME); + assertThat(kubernetesDir) + .isDirectoryContaining(p -> p.getFileName().endsWith("kubernetes.json")) + .isDirectoryContaining(p -> p.getFileName().endsWith("kubernetes.yml")); + List kubernetesList = DeserializationUtil + .deserializeAsList(kubernetesDir.resolve("kubernetes.yml")); + + assertThat(kubernetesList).allSatisfy(resource -> { + assertThat(resource.getMetadata()).satisfies(m -> { + assertThat(m.getName()).isEqualTo(APP_NAME); + assertThat(m.getAnnotations().get("app.quarkus.io/commit-id")).isNull(); + assertThat(m.getAnnotations().get("app.quarkus.io/build-timestamp")).isNull(); + }); + + if (resource instanceof Deployment) { + Deployment deployment = (Deployment) resource; + assertThat(deployment.getSpec().getSelector().getMatchLabels()).doesNotContainKey(Labels.VERSION); + assertThat(deployment.getSpec().getTemplate().getMetadata().getLabels()).doesNotContainKey(Labels.VERSION); + } + }); + } +} diff --git a/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-idempotent.properties b/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-idempotent.properties new file mode 100644 index 0000000000000..484aaac9dc65d --- /dev/null +++ b/integration-tests/kubernetes/quarkus-standard-way/src/test/resources/kubernetes-with-idempotent.properties @@ -0,0 +1,2 @@ +quarkus.kubernetes.idempotent=true +quarkus.kubernetes.output-directory=target/kubernetes-with-idempotent \ No newline at end of file