diff --git a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleProcessor.java b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleProcessor.java index 9f3cb9c99..029a17091 100644 --- a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleProcessor.java +++ b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleProcessor.java @@ -2,7 +2,6 @@ import static io.quarkiverse.operatorsdk.deployment.AddClusterRolesDecorator.ALL_VERBS; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -22,7 +21,6 @@ import org.jboss.jandex.IndexView; import org.jboss.logging.Logger; -import io.dekorate.utils.Serialization; import io.fabric8.kubernetes.api.model.ServiceAccount; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.rbac.ClusterRole; @@ -34,10 +32,7 @@ import io.quarkiverse.operatorsdk.bundle.runtime.CSVMetadata.Icon; import io.quarkiverse.operatorsdk.bundle.runtime.CSVMetadataHolder; import io.quarkiverse.operatorsdk.bundle.runtime.SharedCSVMetadata; -import io.quarkiverse.operatorsdk.common.ClassUtils; -import io.quarkiverse.operatorsdk.common.ConfigurationUtils; -import io.quarkiverse.operatorsdk.common.ReconciledAugmentedClassInfo; -import io.quarkiverse.operatorsdk.common.ReconcilerAugmentedClassInfo; +import io.quarkiverse.operatorsdk.common.*; import io.quarkiverse.operatorsdk.deployment.GeneratedCRDInfoBuildItem; import io.quarkiverse.operatorsdk.deployment.VersionBuildItem; import io.quarkiverse.operatorsdk.runtime.CRDInfo; @@ -190,44 +185,37 @@ void generateBundle(ApplicationInfoBuildItem configuration, final var roles = new LinkedList(); final var deployments = new LinkedList(); - generatedKubernetesManifests.stream() - .filter(bi -> bi.getName().equals("kubernetes.yml")) - .findAny() - .ifPresent( - bi -> { - final var resources = Serialization - .unmarshalAsList(new ByteArrayInputStream(bi.getContent())); - resources.getItems().forEach(r -> { - if (r instanceof ServiceAccount) { - serviceAccounts.add((ServiceAccount) r); - return; - } - - if (r instanceof ClusterRoleBinding) { - clusterRoleBindings.add((ClusterRoleBinding) r); - return; - } - - if (r instanceof ClusterRole) { - clusterRoles.add((ClusterRole) r); - return; - } - - if (r instanceof RoleBinding) { - roleBindings.add((RoleBinding) r); - return; - } - - if (r instanceof Role) { - roles.add((Role) r); - return; - } - - if (r instanceof Deployment) { - deployments.add((Deployment) r); - } - }); - }); + final var resources = GeneratedResourcesUtils.loadFrom(generatedKubernetesManifests); + resources.forEach(r -> { + if (r instanceof ServiceAccount) { + serviceAccounts.add((ServiceAccount) r); + return; + } + + if (r instanceof ClusterRoleBinding) { + clusterRoleBindings.add((ClusterRoleBinding) r); + return; + } + + if (r instanceof ClusterRole) { + clusterRoles.add((ClusterRole) r); + return; + } + + if (r instanceof RoleBinding) { + roleBindings.add((RoleBinding) r); + return; + } + + if (r instanceof Role) { + roles.add((Role) r); + return; + } + + if (r instanceof Deployment) { + deployments.add((Deployment) r); + } + }); final var generated = BundleGenerator.prepareGeneration(bundleConfiguration, versionBuildItem.getVersion(), csvMetadata.getCsvGroups(), crds, outputTarget.getOutputDirectory()); generated.forEach(manifestBuilder -> { diff --git a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/FileUtils.java b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/FileUtils.java new file mode 100644 index 000000000..e43fbd374 --- /dev/null +++ b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/FileUtils.java @@ -0,0 +1,28 @@ +package io.quarkiverse.operatorsdk.common; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.util.List; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; + +public class FileUtils { + private final static KubernetesSerialization serializer = new KubernetesSerialization(); + + public static void ensureDirectoryExists(File dir) { + if (!dir.exists()) { + if (!dir.mkdirs()) { + throw new IllegalArgumentException("Couldn't create " + dir.getAbsolutePath()); + } + } + } + + public static List unmarshalFrom(byte[] yamlOrJson) { + return serializer.unmarshal(new ByteArrayInputStream(yamlOrJson)); + } + + public static String asYaml(Object toSerialize) { + return serializer.asYaml(toSerialize); + } +} diff --git a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/GeneratedResourcesUtils.java b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/GeneratedResourcesUtils.java new file mode 100644 index 000000000..7cb439fb9 --- /dev/null +++ b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/GeneratedResourcesUtils.java @@ -0,0 +1,34 @@ +package io.quarkiverse.operatorsdk.common; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.quarkus.kubernetes.spi.GeneratedKubernetesResourceBuildItem; + +public class GeneratedResourcesUtils { + public static final String KUBERNETES_YAML = "kubernetes.yml"; + private static final Logger log = Logger.getLogger(GeneratedResourcesUtils.class.getName()); + + public static List loadFrom(List generatedResources, + String resourceName) { + if (generatedResources.isEmpty()) { + log.debugv("Couldn't load resource {0} because no resources were generated", resourceName); + return Collections.emptyList(); + } + var buildItem = generatedResources.stream() + .filter(r -> resourceName.equals(r.getName())) + .findAny(); + return buildItem.map(bi -> FileUtils.unmarshalFrom(bi.getContent())) + .orElseThrow(() -> new IllegalArgumentException("Couldn't find resource " + resourceName + + " in generated resources: " + generatedResources.stream() + .map(GeneratedKubernetesResourceBuildItem::getName).collect(Collectors.toSet()))); + } + + public static List loadFrom(List generatedResources) { + return loadFrom(generatedResources, KUBERNETES_YAML); + } +} diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml index cbfb23826..15a467c46 100644 --- a/core/deployment/pom.xml +++ b/core/deployment/pom.xml @@ -51,6 +51,21 @@ semver4j 5.1.0 + + io.dekorate + helm-annotations + noapt + + + io.sundr + * + + + com.sun + tools + + + io.dekorate kubernetes-annotations diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/AddClusterRolesDecorator.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/AddClusterRolesDecorator.java index 0ab0bdc97..69517a421 100644 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/AddClusterRolesDecorator.java +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/AddClusterRolesDecorator.java @@ -51,59 +51,8 @@ public AddClusterRolesDecorator(Collection confi @Override public void visit(KubernetesListBuilder list) { configs.forEach(cri -> { - final var rule = new PolicyRuleBuilder(); - final var resourceClass = cri.getResourceClass(); - final var plural = HasMetadata.getPlural(resourceClass); - rule.addToResources(plural); - - // if the resource has a non-Void status, also add the status resource - if (cri.isStatusPresentAndNotVoid()) { - rule.addToResources(plural + "/status"); - } - - // add finalizers sub-resource because it's used in several contexts, even in the absence of finalizers - // see: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#ownerreferencespermissionenforcement - rule.addToResources(plural + "/finalizers"); - - rule.addToApiGroups(HasMetadata.getGroup(resourceClass)) - .addToVerbs(ALL_VERBS) - .build(); - - final var clusterRoleBuilder = new ClusterRoleBuilder() - .withNewMetadata() - .withName(getClusterRoleName(cri.getName())) - .endMetadata() - .addToRules(rule.build()); - - @SuppressWarnings({ "rawtypes", "unchecked" }) - final Map dependentsMetadata = cri.getDependentsMetadata(); - dependentsMetadata.forEach((name, spec) -> { - final var dependentResourceClass = spec.getDependentResourceClass(); - final var associatedResourceClass = spec.getDependentType(); - - // only process Kubernetes dependents - if (HasMetadata.class.isAssignableFrom(associatedResourceClass)) { - final var dependentRule = new PolicyRuleBuilder() - .addToApiGroups(HasMetadata.getGroup(associatedResourceClass)) - .addToResources(HasMetadata.getPlural(associatedResourceClass)) - .addToVerbs(READ_VERBS); - if (Updater.class.isAssignableFrom(dependentResourceClass)) { - dependentRule.addToVerbs(UPDATE_VERBS); - } - if (Deleter.class.isAssignableFrom(dependentResourceClass)) { - dependentRule.addToVerbs(DELETE_VERB); - } - if (Creator.class.isAssignableFrom(dependentResourceClass)) { - dependentRule.addToVerbs(CREATE_VERB); - if (!dependentRule.getVerbs().contains(PATCH_VERB)) { - dependentRule.addToVerbs(PATCH_VERB); - } - } - clusterRoleBuilder.addToRules(dependentRule.build()); - } - }); - - list.addToItems(clusterRoleBuilder.build()); + var clusterRole = createClusterRole(cri); + list.addToItems(clusterRole); }); // if we're asking to validate the CRDs, also add CRDs permissions, once @@ -121,6 +70,61 @@ public void visit(KubernetesListBuilder list) { } } + public static ClusterRole createClusterRole(QuarkusControllerConfiguration cri) { + final var rule = new PolicyRuleBuilder(); + final var resourceClass = cri.getResourceClass(); + final var plural = HasMetadata.getPlural(resourceClass); + rule.addToResources(plural); + + // if the resource has a non-Void status, also add the status resource + if (cri.isStatusPresentAndNotVoid()) { + rule.addToResources(plural + "/status"); + } + + // add finalizers sub-resource because it's used in several contexts, even in the absence of finalizers + // see: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#ownerreferencespermissionenforcement + rule.addToResources(plural + "/finalizers"); + + rule.addToApiGroups(HasMetadata.getGroup(resourceClass)) + .addToVerbs(ALL_VERBS) + .build(); + + final var clusterRoleBuilder = new ClusterRoleBuilder() + .withNewMetadata() + .withName(getClusterRoleName(cri.getName())) + .endMetadata() + .addToRules(rule.build()); + + final Map> dependentsMetadata = cri.getDependentsMetadata(); + dependentsMetadata.forEach((name, spec) -> { + final var dependentResourceClass = spec.getDependentResourceClass(); + final var associatedResourceClass = spec.getDependentType(); + + // only process Kubernetes dependents + if (HasMetadata.class.isAssignableFrom(associatedResourceClass)) { + final var dependentRule = new PolicyRuleBuilder() + .addToApiGroups(HasMetadata.getGroup(associatedResourceClass)) + .addToResources(HasMetadata.getPlural(associatedResourceClass)) + .addToVerbs(READ_VERBS); + if (Updater.class.isAssignableFrom(dependentResourceClass)) { + dependentRule.addToVerbs(UPDATE_VERBS); + } + if (Deleter.class.isAssignableFrom(dependentResourceClass)) { + dependentRule.addToVerbs(DELETE_VERB); + } + if (Creator.class.isAssignableFrom(dependentResourceClass)) { + dependentRule.addToVerbs(CREATE_VERB); + if (!dependentRule.getVerbs().contains(PATCH_VERB)) { + dependentRule.addToVerbs(PATCH_VERB); + } + } + clusterRoleBuilder.addToRules(dependentRule.build()); + } + + }); + return clusterRoleBuilder.build(); + } + public static String getClusterRoleName(String controller) { return controller + "-cluster-role"; } diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGeneration.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGeneration.java index 2a85bb898..697fcc2df 100644 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGeneration.java +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGeneration.java @@ -16,6 +16,7 @@ import io.fabric8.crd.generator.CustomResourceInfo; import io.fabric8.kubernetes.client.CustomResource; import io.quarkiverse.operatorsdk.common.CustomResourceAugmentedClassInfo; +import io.quarkiverse.operatorsdk.common.FileUtils; import io.quarkiverse.operatorsdk.runtime.CRDConfiguration; import io.quarkiverse.operatorsdk.runtime.CRDGenerationInfo; import io.quarkiverse.operatorsdk.runtime.CRDInfo; @@ -76,11 +77,7 @@ CRDGenerationInfo generate(OutputTargetBuildItem outputTarget, .map(d -> Paths.get("").toAbsolutePath().resolve(d)) .orElse(outputTarget.getOutputDirectory().resolve(KUBERNETES)); final var outputDir = targetDirectory.toFile(); - if (!outputDir.exists()) { - if (!outputDir.mkdirs()) { - throw new IllegalArgumentException("Couldn't create " + outputDir.getAbsolutePath()); - } - } + FileUtils.ensureDirectoryExists(outputDir); // generate CRDs with detailed information final var info = generator.forCRDVersions(crdConfiguration.versions).inOutputDir(outputDir).detailedGenerate(); diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ReconcilerInfosBuildItem.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ReconcilerInfosBuildItem.java index b1f1b7042..e009692ca 100644 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ReconcilerInfosBuildItem.java +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ReconcilerInfosBuildItem.java @@ -5,7 +5,7 @@ import io.quarkiverse.operatorsdk.common.ReconcilerAugmentedClassInfo; import io.quarkus.builder.item.SimpleBuildItem; -final class ReconcilerInfosBuildItem extends SimpleBuildItem { +public final class ReconcilerInfosBuildItem extends SimpleBuildItem { private final Map reconcilers; public ReconcilerInfosBuildItem(Map reconcilers) { diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/DisableDefaultHelmListener.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/DisableDefaultHelmListener.java new file mode 100644 index 000000000..fd62b189c --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/DisableDefaultHelmListener.java @@ -0,0 +1,25 @@ +package io.quarkiverse.operatorsdk.deployment.helm; + +import java.util.HashMap; +import java.util.Map; + +import io.dekorate.WithSession; +import io.dekorate.kubernetes.config.BaseConfigFluent; +import io.dekorate.kubernetes.config.Configurator; + +/** + * Used to disable default Dekorate Helm chart generator, which would get automatically triggered by depending on the Dekorate + * Helm annotations and the Quarkus Kubernetes extension. + */ +public class DisableDefaultHelmListener extends Configurator> implements WithSession { + @Override + public void visit(BaseConfigFluent baseConfigFluent) { + Map helmConfig = new HashMap<>(); + helmConfig.put("enabled", "false"); + + Map config = new HashMap<>(); + config.put("helm", helmConfig); + + WithSession.super.getSession().addPropertyConfiguration(config); + } +} diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/HelmChartProcessor.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/HelmChartProcessor.java new file mode 100644 index 000000000..535382c05 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/HelmChartProcessor.java @@ -0,0 +1,219 @@ +package io.quarkiverse.operatorsdk.deployment.helm; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; + +import io.dekorate.helm.model.Chart; +import io.dekorate.utils.Serialization; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.quarkiverse.operatorsdk.common.FileUtils; +import io.quarkiverse.operatorsdk.common.GeneratedResourcesUtils; +import io.quarkiverse.operatorsdk.deployment.AddClusterRolesDecorator; +import io.quarkiverse.operatorsdk.deployment.ControllerConfigurationsBuildItem; +import io.quarkiverse.operatorsdk.deployment.GeneratedCRDInfoBuildItem; +import io.quarkiverse.operatorsdk.runtime.BuildTimeOperatorConfiguration; +import io.quarkiverse.operatorsdk.runtime.QuarkusControllerConfiguration; +import io.quarkus.container.spi.ContainerImageInfoBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; +import io.quarkus.deployment.pkg.builditem.ArtifactResultBuildItem; +import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; +import io.quarkus.kubernetes.spi.ConfiguratorBuildItem; +import io.quarkus.kubernetes.spi.GeneratedKubernetesResourceBuildItem; +import io.quarkus.qute.Qute; + +public class HelmChartProcessor { + + private static final Logger log = Logger.getLogger(HelmChartProcessor.class); + + private static final String TEMPLATES_DIR = "templates"; + private static final String HELM_TEMPLATES_STATIC_DIR = "/helm/static/"; + private static final String[] TEMPLATE_FILES = new String[] { + "generic-crd-cluster-role.yaml", + "generic-crd-cluster-role-binding.yaml", + "service.yaml", + "serviceaccount.yaml" + }; + public static final String CHART_YAML_FILENAME = "Chart.yaml"; + public static final String VALUES_YAML_FILENAME = "values.yaml"; + public static final String CRD_DIR = "crds"; + public static final String CRD_ROLE_BINDING_TEMPLATE_PATH = "/helm/crd-role-binding-template.yaml"; + + @BuildStep + public void handleHelmCharts( + // to make it produce a build item, so it gets executed + @SuppressWarnings("unused") BuildProducer dummy, + List generatedResources, + ControllerConfigurationsBuildItem controllerConfigurations, + BuildTimeOperatorConfiguration buildTimeConfiguration, + GeneratedCRDInfoBuildItem generatedCRDInfoBuildItem, + OutputTargetBuildItem outputTarget, + ApplicationInfoBuildItem appInfo, + ContainerImageInfoBuildItem containerImageInfoBuildItem) { + + if (buildTimeConfiguration.helm.enabled) { + final var helmDir = outputTarget.getOutputDirectory().resolve("helm").toFile(); + log.infov("Generating helm chart to {0}", helmDir); + var controllerConfigs = controllerConfigurations.getControllerConfigs().values(); + + createRelatedDirectories(helmDir); + copyTemplates(helmDir); + addClusterRolesForReconcilers(helmDir, controllerConfigs); + addPrimaryClusterRoleBindings(helmDir, controllerConfigs); + addGeneratedDeployment(helmDir, generatedResources, controllerConfigurations); + addChartYaml(helmDir, appInfo.getName(), appInfo.getVersion()); + addValuesYaml(helmDir, containerImageInfoBuildItem.getTag()); + addCRDs(new File(helmDir, CRD_DIR), generatedCRDInfoBuildItem); + } else { + log.debug("Generating helm chart is disabled"); + } + } + + private void addGeneratedDeployment(File helmDir, List generatedResources, + ControllerConfigurationsBuildItem controllerConfigurations) { + final var resources = GeneratedResourcesUtils.loadFrom(generatedResources); + Deployment deployment = (Deployment) resources.stream() + .filter(r -> r instanceof Deployment).findFirst() + .orElseThrow(); + addActualNamespaceConfigPlaceholderToDeployment(deployment, controllerConfigurations); + var template = FileUtils.asYaml(deployment); + // a bit solution to get the exact placeholder without brackets + String res = template.replace("\"{watchNamespaces}\"", "{{ .Values.watchNamespaces }}"); + try { + Files.writeString(Path.of(helmDir.getPath(), TEMPLATES_DIR, "deployment.yaml"), res); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private void addActualNamespaceConfigPlaceholderToDeployment(Deployment deployment, + ControllerConfigurationsBuildItem controllerConfigurations) { + controllerConfigurations.getControllerConfigs().values().forEach(c -> { + var envs = deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + envs.add(new EnvVar("QUARKUS_OPERATOR_SDK_CONTROLLERS_" + c.getName().toUpperCase() + "_NAMESPACES", + "{watchNamespaces}", null)); + // use this when the global variable issue is fixed + // envs.add(new EnvVar("QUARKUS_OPERATOR_SDK_NAMESPACES", "{watchNamespaces}", null)); + }); + + } + + @SuppressWarnings("rawtypes") + private void addPrimaryClusterRoleBindings(File helmDir, Collection controllerConfigs) { + try (InputStream file = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(CRD_ROLE_BINDING_TEMPLATE_PATH)) { + if (file == null) { + throw new IllegalArgumentException("Template file " + CRD_ROLE_BINDING_TEMPLATE_PATH + " doesn't exist"); + } + String template = new String(file.readAllBytes(), StandardCharsets.UTF_8); + controllerConfigs.forEach(config -> { + try { + final var name = config.getName(); + String res = Qute.fmt(template, Map.of("reconciler-name", name)); + Files.writeString(Path.of(helmDir.getPath(), TEMPLATES_DIR, + name + "-crd-role-binding.yaml"), res); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("rawtypes") + private void addClusterRolesForReconcilers(File helmDir, + Collection controllerConfigurations) { + controllerConfigurations.forEach(cc -> { + try { + var clusterRole = AddClusterRolesDecorator.createClusterRole(cc); + var yaml = io.fabric8.kubernetes.client.utils.Serialization.asYaml(clusterRole); + Files.writeString(Path.of(helmDir.getPath(), TEMPLATES_DIR, cc.getName() + "-crd-cluster-role.yaml"), + yaml); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } + + private void addCRDs(File crdDir, GeneratedCRDInfoBuildItem generatedCRDInfoBuildItem) { + var crdInfos = generatedCRDInfoBuildItem.getCRDGenerationInfo().getCrds().values().stream() + .flatMap(m -> m.values().stream()) + .collect(Collectors.toList()); + + crdInfos.forEach(crdInfo -> { + try { + var generateCrdPath = Path.of(crdInfo.getFilePath()); + // replace needed since tests might generate files multiple times + Files.copy(generateCrdPath, Path.of(crdDir.getPath(), generateCrdPath.getFileName().toString()), + REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } + + private void addValuesYaml(File helmDir, String tag) { + try { + var values = new Values(); + values.setVersion(tag); + var valuesYaml = FileUtils.asYaml(values); + Files.writeString(Path.of(helmDir.getPath(), VALUES_YAML_FILENAME), valuesYaml); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private void addChartYaml(File helmDir, String name, String version) { + try { + Chart chart = new Chart(); + chart.setName(name); + chart.setVersion(version); + chart.setApiVersion("v2"); + var chartYaml = FileUtils.asYaml(chart); + Files.writeString(Path.of(helmDir.getPath(), CHART_YAML_FILENAME), chartYaml); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private void copyTemplates(File helmDir) { + final var destinationDir = helmDir.toPath().resolve(TEMPLATES_DIR); + for (String template : TEMPLATE_FILES) { + try (InputStream is = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(HELM_TEMPLATES_STATIC_DIR + template)) { + if (is == null) { + throw new IllegalArgumentException("Template file " + template + " doesn't exist"); + } + Files.copy(is, destinationDir.resolve(template), REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } + + private void createRelatedDirectories(File helmDir) { + FileUtils.ensureDirectoryExists(helmDir); + FileUtils.ensureDirectoryExists(new File(helmDir, TEMPLATES_DIR)); + FileUtils.ensureDirectoryExists(new File(helmDir, CRD_DIR)); + } + + @BuildStep + void disableDefaultHelmListener(BuildProducer helmConfiguration) { + helmConfiguration.produce(new ConfiguratorBuildItem(new DisableDefaultHelmListener())); + } +} diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/Values.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/Values.java new file mode 100644 index 000000000..3be68cc72 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/helm/Values.java @@ -0,0 +1,27 @@ +package io.quarkiverse.operatorsdk.deployment.helm; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES; + +public class Values { + + private String watchNamespaces = WATCH_ALL_NAMESPACES; + private String version; + + public String getWatchNamespaces() { + return watchNamespaces; + } + + public Values setWatchNamespaces(String watchNamespaces) { + this.watchNamespaces = watchNamespaces; + return this; + } + + public String getVersion() { + return version; + } + + public Values setVersion(String version) { + this.version = version; + return this; + } +} diff --git a/core/deployment/src/main/resources/helm/crd-role-binding-template.yaml b/core/deployment/src/main/resources/helm/crd-role-binding-template.yaml new file mode 100644 index 000000000..80da5a771 --- /dev/null +++ b/core/deployment/src/main/resources/helm/crd-role-binding-template.yaml @@ -0,0 +1,43 @@ +{{ if eq $.Values.watchNamespaces "JOSDK_WATCH_CURRENT" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {reconciler-name}-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {reconciler-name}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ $.Chart.Name }} +{{ else if eq $.Values.watchNamespaces "JOSDK_ALL_NAMESPACES" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {reconciler-name}-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {reconciler-name}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ $.Chart.Name }} + namespace: {{ $.Release.Namespace }} +{{ else }} +{{ range $anamespace := ( split "," $.Values.watchNamespaces ) }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {reconciler-name}-role-binding + namespace: {{ $anamespace }} +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {reconciler-name}-cluster-role +subjects: + - kind: ServiceAccount + name: {{ $.Chart.Name }} + namespace: {{ $.Release.Namespace }} +--- +{{- end }} +{{- end }} diff --git a/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role-binding.yaml b/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role-binding.yaml new file mode 100644 index 000000000..c68fe50b6 --- /dev/null +++ b/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Chart.Name }}-crd-validating-role-binding +roleRef: + kind: ClusterRole + apiGroup: rbac.authorization.k8s.io + name: {{ .Chart.Name }}-crd-validating-cluster-role +subjects: + - kind: ServiceAccount + name: {{ .Chart.Name }} + namespace: {{ .Release.Namespace }} diff --git a/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role.yaml b/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role.yaml new file mode 100644 index 000000000..a01e8f6ec --- /dev/null +++ b/core/deployment/src/main/resources/helm/static/generic-crd-cluster-role.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Chart.Name }}-crd-validating-cluster-role +rules: + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list diff --git a/core/deployment/src/main/resources/helm/static/service.yaml b/core/deployment/src/main/resources/helm/static/service.yaml new file mode 100644 index 000000000..8dd496c0d --- /dev/null +++ b/core/deployment/src/main/resources/helm/static/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + app.quarkus.io/commit-id: 77d1e2545ac9dd0e6599bffdec4f892cd148984b + app.quarkus.io/build-timestamp: 2023-07-20 - 11:57:20 +0000 + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/version: {{ .Values.version }} + app.kubernetes.io/managed-by: quarkus + name: {{ .Chart.Name }} +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: 8080 + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/version: {{ .Values.version }} + type: ClusterIP diff --git a/core/deployment/src/main/resources/helm/static/serviceaccount.yaml b/core/deployment/src/main/resources/helm/static/serviceaccount.yaml new file mode 100644 index 000000000..094776a21 --- /dev/null +++ b/core/deployment/src/main/resources/helm/static/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/managed-by: quarkus + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/version: {{ .Values.version }} + name: {{ .Chart.Name }} diff --git a/core/deployment/src/test/java/io/quarkiverse/operatorsdk/test/HelmChartGeneratorTest.java b/core/deployment/src/test/java/io/quarkiverse/operatorsdk/test/HelmChartGeneratorTest.java new file mode 100644 index 000000000..d90d60dcd --- /dev/null +++ b/core/deployment/src/test/java/io/quarkiverse/operatorsdk/test/HelmChartGeneratorTest.java @@ -0,0 +1,45 @@ +package io.quarkiverse.operatorsdk.test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.io.File; +import java.nio.file.Path; +import java.util.Objects; + +import org.hamcrest.io.FileMatchers; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkiverse.operatorsdk.test.sources.SimpleCR; +import io.quarkiverse.operatorsdk.test.sources.SimpleReconciler; +import io.quarkiverse.operatorsdk.test.sources.SimpleSpec; +import io.quarkiverse.operatorsdk.test.sources.SimpleStatus; +import io.quarkus.test.ProdBuildResults; +import io.quarkus.test.ProdModeTestResults; +import io.quarkus.test.QuarkusProdModeTest; + +class HelmChartGeneratorTest { + + @RegisterExtension + static final QuarkusProdModeTest config = new QuarkusProdModeTest() + .setApplicationName("helm-chart-test") + .overrideConfigKey("quarkus.operator-sdk.helm.enabled", "true") + .withApplicationRoot( + (jar) -> jar.addClasses(SimpleReconciler.class, SimpleCR.class, SimpleSpec.class, SimpleStatus.class)); + + @ProdBuildResults + private ProdModeTestResults prodModeTestResults; + + @Test + void generatesHelmChart() { + Path buildDir = prodModeTestResults.getBuildDir(); + var helmDir = new File(buildDir.toFile(), "helm"); + + assertThat(new File(helmDir, "Chart.yaml"), FileMatchers.anExistingFile()); + assertThat(new File(helmDir, "values.yaml"), FileMatchers.anExistingFile()); + assertThat(Objects.requireNonNull(new File(helmDir, "templates").listFiles()).length, + greaterThanOrEqualTo(7)); + } + +} diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/BuildTimeOperatorConfiguration.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/BuildTimeOperatorConfiguration.java index fea231201..68e6f3c9c 100644 --- a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/BuildTimeOperatorConfiguration.java +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/BuildTimeOperatorConfiguration.java @@ -92,4 +92,10 @@ public class BuildTimeOperatorConfiguration { */ @ConfigItem public Optional> generateWithWatchedNamespaces; + + /** + * Helm Chart related configurations. + */ + @ConfigItem + public HelmConfiguration helm; } diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/HelmConfiguration.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/HelmConfiguration.java new file mode 100644 index 000000000..5d06b8d00 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/HelmConfiguration.java @@ -0,0 +1,15 @@ +package io.quarkiverse.operatorsdk.runtime; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class HelmConfiguration { + + /** + * Can be used to disable helm chart generation. + */ + @ConfigItem(defaultValue = "false") + public Boolean enabled; + +} diff --git a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc index bd9011e7a..76836895a 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc @@ -305,6 +305,23 @@ endif::add-copy-button-to-env-var[] | +a|icon:lock[title=Fixed at build time] [[quarkus-operator-sdk_quarkus.operator-sdk.helm.enabled]]`link:#quarkus-operator-sdk_quarkus.operator-sdk.helm.enabled[quarkus.operator-sdk.helm.enabled]` + + +[.description] +-- +Can be used to disable helm chart generation. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_OPERATOR_SDK_HELM_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_OPERATOR_SDK_HELM_ENABLED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`false` + + a| [[quarkus-operator-sdk_quarkus.operator-sdk.concurrent-reconciliation-threads]]`link:#quarkus-operator-sdk_quarkus.operator-sdk.concurrent-reconciliation-threads[quarkus.operator-sdk.concurrent-reconciliation-threads]` diff --git a/samples/exposedapp/pom.xml b/samples/exposedapp/pom.xml index c0896c43a..aa86fd7c6 100644 --- a/samples/exposedapp/pom.xml +++ b/samples/exposedapp/pom.xml @@ -58,6 +58,10 @@ awaitility test + + io.quarkus + quarkus-container-image-jib + diff --git a/samples/exposedapp/src/main/java/io/halkyon/ExposedAppReconciler.java b/samples/exposedapp/src/main/java/io/halkyon/ExposedAppReconciler.java index 52ad93721..021894bc2 100644 --- a/samples/exposedapp/src/main/java/io/halkyon/ExposedAppReconciler.java +++ b/samples/exposedapp/src/main/java/io/halkyon/ExposedAppReconciler.java @@ -11,17 +11,13 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.javaoperatorsdk.operator.api.reconciler.Context; -import io.javaoperatorsdk.operator.api.reconciler.ContextInitializer; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; @ControllerConfiguration(namespaces = WATCH_CURRENT_NAMESPACE, name = "exposedapp", dependents = { @Dependent(type = DeploymentDependent.class), @Dependent(name = "service", type = ServiceDependent.class), - @Dependent(type = IngressDependent.class, dependsOn = "service", readyPostcondition = IngressDependent.class) + @Dependent(type = IngressDependent.class, readyPostcondition = IngressDependent.class) }) public class ExposedAppReconciler implements Reconciler, ContextInitializer { @@ -46,6 +42,7 @@ public UpdateControl reconcile(ExposedApp exposedApp, Context { if (wrs.allDependentResourcesReady()) { + final var url = IngressDependent.getExposedURL( context.getSecondaryResource(Ingress.class).orElseThrow()); exposedApp.setStatus(new ExposedAppStatus(url, exposedApp.getSpec().getEndpoint())); diff --git a/samples/exposedapp/src/main/resources/app.yml b/samples/exposedapp/src/main/resources/app.yml index e0a9a81bf..5145eebe3 100644 --- a/samples/exposedapp/src/main/resources/app.yml +++ b/samples/exposedapp/src/main/resources/app.yml @@ -3,4 +3,4 @@ kind: ExposedApp metadata: name: hello-quarkus spec: - imageRef: 'quay.io/metacosm/hello:1.0.0-SNAPSHOT' \ No newline at end of file + imageRef: 'nginx:1.14.2' \ No newline at end of file diff --git a/samples/exposedapp/src/main/resources/application.properties b/samples/exposedapp/src/main/resources/application.properties index 9ceef3a41..879ea9867 100644 --- a/samples/exposedapp/src/main/resources/application.properties +++ b/samples/exposedapp/src/main/resources/application.properties @@ -1,4 +1,6 @@ #quarkus.container-image.build=true +quarkus.container-image.builder=jib +quarkus.kubernetes.image-pull-policy=IfNotPresent #quarkus.container-image.group= #quarkus.container-image.name=expose-operator # set to true to automatically apply CRDs to the cluster when they get regenerated @@ -7,4 +9,7 @@ quarkus.kubernetes-client.devservices.override-kubeconfig=true %test.quarkus.operator-sdk.close-client-on-stop=false %test.quarkus.operator-sdk.start-operator=true quarkus.kubernetes-client.devservices.flavor=k3s -quarkus.http.test-port=0 \ No newline at end of file +quarkus.http.test-port=0 + +quarkus.kubernetes-client.devservices.enabled=true +quarkus.operator-sdk.helm.enabled=true \ No newline at end of file diff --git a/samples/exposedapp/src/test/java/io/halkyon/HelmDeploymentE2ETest.java b/samples/exposedapp/src/test/java/io/halkyon/HelmDeploymentE2ETest.java new file mode 100644 index 000000000..7330007b4 --- /dev/null +++ b/samples/exposedapp/src/test/java/io/halkyon/HelmDeploymentE2ETest.java @@ -0,0 +1,194 @@ +package io.halkyon; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.jboss.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.Namespace; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.quarkus.test.junit.QuarkusTest; + +// currently there is a bug with kind testcontainer that blocks the e2e tests done properly +// will finish this test when it gets resolved: +// https://github.com/dajudge/kindcontainer/issues/235 +@Disabled +@QuarkusTest +class HelmDeploymentE2ETest { + + final static Logger log = Logger.getLogger(HelmDeploymentE2ETest.class); + public static final String TEST_RESOURCE = "test1"; + public static final String DEPLOYMENT_NAME = "quarkus-operator-sdk-samples-exposedapp"; + public static final String DEFAULT_NAMESPACE = "default"; + public static final String WATCH_NAMESPACES_KEY = "watchNamespaces"; + + @Inject + KubernetesClient client; + + // operator is not started if this injected, what we need in this situation + @Inject + Operator operator; + + private String namespace; + + @AfterEach + void cleanup() { + deleteHelmDeployment(); + } + + @Test + void testClusterWideDeployment() { + deployWithHelm(); + + namespace = "clusterscopetest"; + createNamespace(namespace); + client.resource(testResource(namespace)).create(); + + checkResourceProcessed(namespace); + + client.resource(testResource(namespace)).delete(); + } + + @Test + void testWatchingCurrentNamespace() { + deployWithHelm(WATCH_NAMESPACES_KEY, Constants.WATCH_CURRENT_NAMESPACE); + + namespace = "own-ns-test"; + createNamespace(namespace); + client.resource(testResource(namespace)).create(); + + checkResourceNotProcessed(namespace); + + // resource is reconciled in default namespace where controller runs + client.resource(testResource(DEFAULT_NAMESPACE)).create(); + checkResourceProcessed(DEFAULT_NAMESPACE); + + client.resource(testResource(namespace)).delete(); + client.resource(testResource(DEFAULT_NAMESPACE)).delete(); + } + + private void checkResourceNotProcessed(String namespace) { + await("Resource not reconciled in other namespace") + .pollDelay(Duration.ofSeconds(5)).untilAsserted(() -> { + var exposedApp = client.resources(ExposedApp.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE).get(); + assertThat(exposedApp, is(notNullValue())); + assertThat(exposedApp.getStatus().getMessage(), equalTo("processing")); + }); + } + + @Test + void testWatchingSetOfNamespaces() { + String excludedNS = "excludedns1"; + String ns1 = "testns1"; + String ns2 = "testns2"; + createNamespace(excludedNS); + createNamespace(ns1); + createNamespace(ns2); + + deployWithHelm(WATCH_NAMESPACES_KEY, ns1 + "\\," + ns2); + + client.resource(testResource(excludedNS)).create(); + checkResourceNotProcessed(excludedNS); + + client.resource(testResource(ns1)).create(); + client.resource(testResource(ns2)).create(); + checkResourceProcessed(ns1); + checkResourceProcessed(ns2); + + client.resource(testResource(ns1)).delete(); + client.resource(testResource(ns2)).delete(); + client.resource(testResource(excludedNS)).delete(); + } + + private void checkResourceProcessed(String namespace) { + await().atMost(15, TimeUnit.SECONDS).untilAsserted(() -> { + var exposedApp = client.resources(ExposedApp.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE).get(); + assertThat(exposedApp, is(notNullValue())); + assertThat(exposedApp.getStatus().getMessage(), equalTo("exposed")); + }); + } + + ExposedApp testResource(String namespace) { + var app = new ExposedApp(); + app.setMetadata(new ObjectMetaBuilder() + .withName(TEST_RESOURCE) + .withNamespace(namespace) + .build()); + app.setSpec(new ExposedAppSpec()); + app.getSpec().setImageRef("nginx:1.14.2"); + return app; + } + + private void createNamespace(String namespace) { + var ns = new Namespace(); + ns.setMetadata(new ObjectMetaBuilder() + .withName(namespace) + .build()); + if (client.namespaces().resource(ns).get() == null) { + client.namespaces().resource(ns).create(); + } + } + + private void deployWithHelm(String... values) { + File kubeConfigFile = KubeUtils.generateConfigFromClient(client); + + var command = "helm install exposedapp target/helm"; + command += " --kubeconfig " + kubeConfigFile.getPath(); + for (int i = 0; i < values.length; i = i + 2) { + command += " --set " + values[i] + "=" + values[i + 1]; + } + + execHelmCommand(command); + client.apps().deployments().inNamespace(DEFAULT_NAMESPACE).withName(DEPLOYMENT_NAME) + .waitUntilReady(30, TimeUnit.SECONDS); + } + + private void deleteHelmDeployment() { + execHelmCommand("helm delete exposedapp"); + await().untilAsserted(() -> { + var deployment = client.apps().deployments().inNamespace(DEFAULT_NAMESPACE).withName(DEPLOYMENT_NAME).get(); + assertThat(deployment, is(nullValue())); + }); + } + + private static void execHelmCommand(String command) { + log.infof("Executing command: %s", command); + execHelmCommand(command, false); + } + + private static void execHelmCommand(String command, boolean silent) { + try { + var process = Runtime.getRuntime().exec(command); + var exitCode = process.waitFor(); + if (exitCode != 0) { + log.infof("Error with helm: %s", new String(process.getErrorStream().readAllBytes())); + if (!silent) { + throw new IllegalStateException("Helm exit code: " + exitCode); + } + } + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/samples/exposedapp/src/test/java/io/halkyon/KubeUtils.java b/samples/exposedapp/src/test/java/io/halkyon/KubeUtils.java new file mode 100644 index 000000000..df400a940 --- /dev/null +++ b/samples/exposedapp/src/test/java/io/halkyon/KubeUtils.java @@ -0,0 +1,68 @@ +package io.halkyon; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.testcontainers.shaded.com.google.common.io.Files; + +import io.fabric8.kubernetes.api.model.*; +import io.fabric8.kubernetes.client.KubernetesClient; + +public class KubeUtils { + + public static final String HELM_TEST = "helmtest"; + + public static File generateConfigFromClient(KubernetesClient client) { + try { + var actualConfig = client.getConfiguration(); + Config res = new Config(); + res.setApiVersion("v1"); + res.setKind("Config"); + res.setClusters(createCluster(actualConfig)); + res.setContexts(createContext(actualConfig)); + res.setUsers(createUser(actualConfig)); + res.setCurrentContext(HELM_TEST); + + File targetFile = new File("target", "devservice-kubeconfig.yaml"); + String yaml = client.getKubernetesSerialization().asYaml(res); + + Files.write(yaml, targetFile, StandardCharsets.UTF_8); + return targetFile; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static List createUser(io.fabric8.kubernetes.client.Config actualConfig) { + var user = new NamedAuthInfo(); + user.setName(HELM_TEST); + user.setUser(new AuthInfo()); + user.getUser().setClientCertificateData(actualConfig.getClientCertData()); + user.getUser().setClientKeyData(actualConfig.getClientKeyData()); + user.getUser().setPassword(actualConfig.getPassword()); + return List.of(user); + } + + private static List createContext(io.fabric8.kubernetes.client.Config actualConfig) { + var context = new NamedContext(); + context.setName(HELM_TEST); + context.setContext(new Context()); + context.getContext().setCluster(HELM_TEST); + context.getContext().setUser(HELM_TEST); + context.getContext().setNamespace(actualConfig.getNamespace()); + + return List.of(context); + } + + private static List createCluster(io.fabric8.kubernetes.client.Config actualConfig) { + var cluster = new NamedCluster(); + cluster.setName(HELM_TEST); + cluster.setCluster(new Cluster()); + cluster.getCluster().setServer(actualConfig.getMasterUrl()); + cluster.getCluster().setCertificateAuthorityData(actualConfig.getCaCertData()); + return List.of(cluster); + } + +} diff --git a/samples/mysql-schema/src/main/resources/application.properties b/samples/mysql-schema/src/main/resources/application.properties index 25d409902..bc3d399d1 100644 --- a/samples/mysql-schema/src/main/resources/application.properties +++ b/samples/mysql-schema/src/main/resources/application.properties @@ -4,4 +4,4 @@ quarkus.native.additional-build-args=--initialize-at-run-time=org.apache.commons quarkus.datasource.username=root quarkus.datasource.devservices.image-name=mariadb:10.7 quarkus.kubernetes-client.devservices.override-kubeconfig=true -quarkus.http.test-port=0 \ No newline at end of file +quarkus.http.test-port=0 diff --git a/samples/mysql-schema/src/test/java/io/quarkiverse/operatorsdk/samples/mysqlschema/MySQLSchemaOperatorE2ETest.java b/samples/mysql-schema/src/test/java/io/quarkiverse/operatorsdk/samples/mysqlschema/MySQLSchemaOperatorE2ETest.java index dd1899361..1af4203ae 100644 --- a/samples/mysql-schema/src/test/java/io/quarkiverse/operatorsdk/samples/mysqlschema/MySQLSchemaOperatorE2ETest.java +++ b/samples/mysql-schema/src/test/java/io/quarkiverse/operatorsdk/samples/mysqlschema/MySQLSchemaOperatorE2ETest.java @@ -16,6 +16,8 @@ import jakarta.inject.Inject; import org.jboss.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; @@ -39,9 +41,18 @@ class MySQLSchemaOperatorE2ETest { @Inject KubernetesClient client; + @BeforeEach + void startOperator() { + operator.start(); + } + + @AfterEach + void stopOperator() { + operator.stop(); + } + @Test void test() { - operator.start(); MySQLSchema testSchema = new MySQLSchema(); testSchema.setMetadata(new ObjectMetaBuilder()