diff --git a/.github/workflows/release-snapshot-on-push-selected-branches.yml b/.github/workflows/release-snapshot-on-push-selected-branches.yml index cc92ed1c..c0df9b80 100644 --- a/.github/workflows/release-snapshot-on-push-selected-branches.yml +++ b/.github/workflows/release-snapshot-on-push-selected-branches.yml @@ -11,6 +11,6 @@ defaults: jobs: release-snapshot: uses: ./.github/workflows/release-snapshot.yml - secrets: inherit + secrets: inherit with: branch: "${{github.ref}}" diff --git a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleGenerator.java b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleGenerator.java index 038eb9a6..e5b3bace 100644 --- a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleGenerator.java +++ b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/BundleGenerator.java @@ -14,7 +14,9 @@ import io.quarkiverse.operatorsdk.bundle.runtime.CSVMetadataHolder; import io.quarkiverse.operatorsdk.common.ReconcilerAugmentedClassInfo; import io.quarkiverse.operatorsdk.runtime.BuildTimeOperatorConfiguration; +import io.quarkiverse.operatorsdk.runtime.CRDGenerationInfo; import io.quarkiverse.operatorsdk.runtime.CRDInfo; +import io.quarkiverse.operatorsdk.runtime.CRDInfos; import io.quarkiverse.operatorsdk.runtime.Version; import io.quarkus.container.util.PathsUtil; @@ -48,14 +50,16 @@ private BundleGenerator() { public static List prepareGeneration(BundleGenerationConfiguration bundleConfiguration, BuildTimeOperatorConfiguration operatorConfiguration, Version version, - Map> csvGroups, Map crds, - Path outputDirectory, String deploymentName) { + Map> csvGroups, CRDGenerationInfo crds, + CRDInfos unownedCRDs, Path outputDirectory, String deploymentName) { List builders = new ArrayList<>(); + final var mainSourcesRoot = PathsUtil.findMainSourcesRoot(outputDirectory); + final var crdNameToInfoMappings = crds.getCrds().getCRDNameToInfoMappings(); + for (Map.Entry> entry : csvGroups.entrySet()) { final var csvMetadata = entry.getKey(); final var labels = generateBundleLabels(csvMetadata, bundleConfiguration, version); - final var mainSourcesRoot = PathsUtil.findMainSourcesRoot(outputDirectory); final var csvBuilder = new CsvManifestsBuilder(csvMetadata, operatorConfiguration, entry.getValue(), mainSourcesRoot != null ? mainSourcesRoot.getKey() : null, deploymentName); builders.add(csvBuilder); @@ -63,16 +67,20 @@ public static List prepareGeneration(BundleGenerationConfigura builders.add(new BundleDockerfileManifestsBuilder(csvMetadata, labels)); // output owned CRDs in the manifest, fail if we're missing some - var missing = addCRDManifestBuilder(crds, builders, csvMetadata, csvBuilder.getOwnedCRs()); + var missing = addCRDManifestBuilder(crdNameToInfoMappings, builders, csvMetadata, csvBuilder.getOwnedCRs()); if (!missing.isEmpty()) { throw new IllegalStateException( "Missing owned CRD data for resources: " + missing + " for bundle: " + csvMetadata.bundleName); } // output required CRDs in the manifest, output a warning in case we're missing some - missing = addCRDManifestBuilder(crds, builders, csvMetadata, csvBuilder.getRequiredCRs()); + missing = addCRDManifestBuilder(crdNameToInfoMappings, builders, csvMetadata, csvBuilder.getRequiredCRs()); if (!missing.isEmpty()) { log.warnv("Missing required CRD data for resources: {0} for bundle: {1}", missing, csvMetadata.bundleName); } + + // output non-generated CRDs + unownedCRDs.getCRDNameToInfoMappings().values() + .forEach(info -> builders.add(new CustomResourceManifestsBuilder(csvMetadata, info))); } return builders; 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 10ae892f..0e097e4d 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 @@ -12,8 +12,6 @@ import java.util.Map; import java.util.Optional; import java.util.function.BooleanSupplier; -import java.util.function.Function; -import java.util.stream.Collectors; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; @@ -32,11 +30,15 @@ import io.quarkiverse.operatorsdk.bundle.runtime.BundleConfiguration; import io.quarkiverse.operatorsdk.bundle.runtime.BundleGenerationConfiguration; import io.quarkiverse.operatorsdk.bundle.runtime.CSVMetadataHolder; -import io.quarkiverse.operatorsdk.common.*; +import io.quarkiverse.operatorsdk.common.ClassUtils; +import io.quarkiverse.operatorsdk.common.ConfigurationUtils; +import io.quarkiverse.operatorsdk.common.DeserializedKubernetesResourcesBuildItem; +import io.quarkiverse.operatorsdk.common.ReconciledAugmentedClassInfo; +import io.quarkiverse.operatorsdk.common.ReconcilerAugmentedClassInfo; import io.quarkiverse.operatorsdk.deployment.GeneratedCRDInfoBuildItem; +import io.quarkiverse.operatorsdk.deployment.UnownedCRDInfoBuildItem; import io.quarkiverse.operatorsdk.deployment.VersionBuildItem; import io.quarkiverse.operatorsdk.runtime.BuildTimeOperatorConfiguration; -import io.quarkiverse.operatorsdk.runtime.CRDInfo; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.ApplicationInfoBuildItem; @@ -49,21 +51,60 @@ public class BundleProcessor { + public static final String CRD_DISPLAY_NAME = "CRD_DISPLAY_NAME"; + public static final String CRD_DESCRIPTION = "CRD_DESCRIPTION"; private static final Logger log = Logger.getLogger(BundleProcessor.class); private static final DotName SHARED_CSV_METADATA = DotName.createSimple(SharedCSVMetadata.class.getName()); private static final DotName CSV_METADATA = DotName.createSimple(CSVMetadata.class.getName()); private static final String BUNDLE = "bundle"; private static final String DEFAULT_PROVIDER_NAME = System.getProperty("user.name"); - public static final String CRD_DISPLAY_NAME = "CRD_DISPLAY_NAME"; - public static final String CRD_DESCRIPTION = "CRD_DESCRIPTION"; - private static class IsGenerationEnabled implements BooleanSupplier { + private static ReconcilerAugmentedClassInfo augmentReconcilerInfo( + ReconcilerAugmentedClassInfo reconcilerInfo) { + // if primary resource is a CR, check if it is annotated with CSVMetadata and augment it if it is + final ReconciledAugmentedClassInfo primaryCI = reconcilerInfo.associatedResourceInfo(); + augmentResourceInfoIfCR(primaryCI); - private BundleGenerationConfiguration config; + reconcilerInfo.getDependentResourceInfos().forEach(draci -> { + // if the dependent is a CR, check if it is annotated with CSVMetadata and augment it if it is + final ReconciledAugmentedClassInfo reconciledAugmentedClassInfo = draci.associatedResourceInfo(); + augmentResourceInfoIfCR(reconciledAugmentedClassInfo); + }); + return reconcilerInfo; + } - @Override - public boolean getAsBoolean() { - return config.enabled(); + private static void augmentResourceInfoIfCR(ReconciledAugmentedClassInfo reconciledAugmentedClassInfo) { + if (reconciledAugmentedClassInfo.isCR()) { + final var csvMetadata = reconciledAugmentedClassInfo.classInfo().annotation(CSV_METADATA); + if (csvMetadata != null) { + // extract display name and description + final var displayName = ConfigurationUtils.annotationValueOrDefault(csvMetadata, + "displayName", AnnotationValue::asString, + () -> reconciledAugmentedClassInfo.asResourceTargeting().kind()); + reconciledAugmentedClassInfo.setExtendedInfo(CRD_DISPLAY_NAME, displayName); + final var description = ConfigurationUtils.annotationValueOrDefault( + csvMetadata, + "description", AnnotationValue::asString, + () -> null); + if (description != null) { + reconciledAugmentedClassInfo.setExtendedInfo(CRD_DESCRIPTION, description); + } + } + } + } + + private static String getBundleName(AnnotationInstance csvMetadata, String defaultName) { + if (csvMetadata == null) { + return defaultName; + } else { + final var bundleName = csvMetadata.value("bundleName"); + if (bundleName != null) { + return bundleName.asString(); + } else { + return Optional.ofNullable(csvMetadata.value("name")) + .map(AnnotationValue::asString) + .orElse(defaultName); + } } } @@ -150,40 +191,6 @@ CSVMetadataBuildItem gatherCSVMetadata(KubernetesConfig kubernetesConfig, return new CSVMetadataBuildItem(csvGroups); } - private static ReconcilerAugmentedClassInfo augmentReconcilerInfo( - ReconcilerAugmentedClassInfo reconcilerInfo) { - // if primary resource is a CR, check if it is annotated with CSVMetadata and augment it if it is - final ReconciledAugmentedClassInfo primaryCI = reconcilerInfo.associatedResourceInfo(); - augmentResourceInfoIfCR(primaryCI); - - reconcilerInfo.getDependentResourceInfos().forEach(draci -> { - // if the dependent is a CR, check if it is annotated with CSVMetadata and augment it if it is - final ReconciledAugmentedClassInfo reconciledAugmentedClassInfo = draci.associatedResourceInfo(); - augmentResourceInfoIfCR(reconciledAugmentedClassInfo); - }); - return reconcilerInfo; - } - - private static void augmentResourceInfoIfCR(ReconciledAugmentedClassInfo reconciledAugmentedClassInfo) { - if (reconciledAugmentedClassInfo.isCR()) { - final var csvMetadata = reconciledAugmentedClassInfo.classInfo().annotation(CSV_METADATA); - if (csvMetadata != null) { - // extract display name and description - final var displayName = ConfigurationUtils.annotationValueOrDefault(csvMetadata, - "displayName", AnnotationValue::asString, - () -> reconciledAugmentedClassInfo.asResourceTargeting().kind()); - reconciledAugmentedClassInfo.setExtendedInfo(CRD_DISPLAY_NAME, displayName); - final var description = ConfigurationUtils.annotationValueOrDefault( - csvMetadata, - "description", AnnotationValue::asString, - () -> null); - if (description != null) { - reconciledAugmentedClassInfo.setExtendedInfo(CRD_DESCRIPTION, description); - } - } - } - } - private String getMetadataOriginInformation(AnnotationInstance csvMetadataAnnotation, boolean isNameInferred, CSVMetadataHolder metadataHolder) { final var isDefault = csvMetadataAnnotation == null; @@ -204,15 +211,12 @@ void generateBundle(ApplicationInfoBuildItem configuration, BuildTimeOperatorConfiguration operatorConfiguration, OutputTargetBuildItem outputTarget, CSVMetadataBuildItem csvMetadata, + UnownedCRDInfoBuildItem unownedCRDs, VersionBuildItem versionBuildItem, BuildProducer doneGeneratingCSV, GeneratedCRDInfoBuildItem generatedCustomResourcesDefinitions, @SuppressWarnings("OptionalUsedAsFieldOrParameterType") Optional maybeGeneratedKubeResources, BuildProducer generatedCSVs) { - final var crds = generatedCustomResourcesDefinitions.getCRDGenerationInfo().getCrds() - .values().stream() - .flatMap(entry -> entry.values().stream()) - .collect(Collectors.toMap(CRDInfo::getCrdName, Function.identity())); final var outputDir = outputTarget.getOutputDirectory().resolve(BUNDLE); final var serviceAccounts = new LinkedList(); final var clusterRoleBindings = new LinkedList(); @@ -258,22 +262,25 @@ void generateBundle(ApplicationInfoBuildItem configuration, final var deploymentName = ResourceNameUtil.getResourceName(kubernetesConfig, configuration); final var generated = BundleGenerator.prepareGeneration(bundleConfiguration, operatorConfiguration, versionBuildItem.getVersion(), - csvMetadata.getCsvGroups(), crds, outputTarget.getOutputDirectory(), deploymentName); + csvMetadata.getCsvGroups(), generatedCustomResourcesDefinitions.getCRDGenerationInfo(), + unownedCRDs.getCRDs(), + outputTarget.getOutputDirectory(), deploymentName); generated.forEach(manifestBuilder -> { final var fileName = manifestBuilder.getFileName(); + final var name = manifestBuilder.getName(); try { generatedCSVs.produce( new GeneratedFileSystemResourceBuildItem( - Path.of(BUNDLE).resolve(manifestBuilder.getName()).resolve(fileName).toString(), + Path.of(BUNDLE).resolve(name).resolve(fileName).toString(), manifestBuilder.getManifestData(serviceAccounts, clusterRoleBindings, clusterRoles, roleBindings, roles, deployments))); - log.infov("Generating {0} for ''{1}'' controller -> {2}", + log.infov("Processing {0} for ''{1}'' controller -> {2}", manifestBuilder.getManifestType(), - manifestBuilder.getName(), - outputDir.resolve(manifestBuilder.getName()).resolve(fileName)); + name, + outputDir.resolve(name).resolve(fileName)); } catch (IOException e) { - log.errorv("Cannot generate {0} for ''{1}'' controller: {2}", - manifestBuilder.getManifestType(), manifestBuilder.getName(), e.getMessage()); + log.errorv("Cannot process {0} for ''{1}'' controller: {2}", + manifestBuilder.getManifestType(), name, e.getMessage()); } }); doneGeneratingCSV.produce(new GeneratedBundleBuildItem()); @@ -302,21 +309,6 @@ private Map getSharedMetadataHolders(String name, Str return result; } - private static String getBundleName(AnnotationInstance csvMetadata, String defaultName) { - if (csvMetadata == null) { - return defaultName; - } else { - final var bundleName = csvMetadata.value("bundleName"); - if (bundleName != null) { - return bundleName.asString(); - } else { - return Optional.ofNullable(csvMetadata.value("name")) - .map(AnnotationValue::asString) - .orElse(defaultName); - } - } - } - private CSVMetadataHolder createMetadataHolder(AnnotationInstance csvMetadata, CSVMetadataHolder mh, BundleConfiguration bundleConfig, String origin) { if (csvMetadata == null) { @@ -503,4 +495,14 @@ private CSVMetadataHolder createMetadataHolder(AnnotationInstance csvMetadata, C requiredCRDs, origin); } + + private static class IsGenerationEnabled implements BooleanSupplier { + + private BundleGenerationConfiguration config; + + @Override + public boolean getAsBoolean() { + return config.enabled(); + } + } } diff --git a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/builders/CsvManifestsBuilder.java b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/builders/CsvManifestsBuilder.java index 2da9eeba..625eee69 100644 --- a/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/builders/CsvManifestsBuilder.java +++ b/bundle-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/bundle/deployment/builders/CsvManifestsBuilder.java @@ -67,6 +67,11 @@ public class CsvManifestsBuilder extends ManifestsBuilder { private static final Logger LOGGER = Logger.getLogger(CsvManifestsBuilder.class.getName()); private static final String IMAGE_PNG = "image/png"; public static final String OLM_TARGET_NAMESPACES = "metadata.annotations['olm.targetNamespaces']"; + private static final Comparator nullsFirst = Comparator.nullsFirst(String::compareTo); + private static final Comparator gvkComparator = comparing(GroupVersionKind::getGroup, nullsFirst) + .thenComparing(GroupVersionKind::getKind, nullsFirst) + .thenComparing(GroupVersionKind::getVersion, nullsFirst); + private static final Comparator crdDescriptionComparator = comparing(CRDDescription::getName, nullsFirst); private ClusterServiceVersionBuilder csvBuilder; private final Set ownedCRs = new HashSet<>(); private final Set requiredCRs = new HashSet<>(); @@ -225,17 +230,14 @@ public CsvManifestsBuilder(CSVMetadataHolder metadata, BuildTimeOperatorConfigur } // add sorted native APIs - final var nullsFirst = Comparator.nullsFirst(String::compareTo); csvSpecBuilder.addAllToNativeAPIs(nativeApis.stream() .distinct() - .sorted(comparing(GroupVersionKind::getGroup, nullsFirst) - .thenComparing(comparing(GroupVersionKind::getKind, nullsFirst)) - .thenComparing(comparing(GroupVersionKind::getVersion, nullsFirst))) + .sorted(gvkComparator) .toList()); csvSpecBuilder.editOrNewCustomresourcedefinitions() - .addAllToOwned(ownedCRs) - .addAllToRequired(requiredCRs) + .addAllToOwned(ownedCRs.stream().sorted(crdDescriptionComparator).toList()) + .addAllToRequired(requiredCRs.stream().sorted(crdDescriptionComparator).toList()) .endCustomresourcedefinitions() .endSpec(); } diff --git a/bundle-generator/deployment/src/test/external-crds/external.crd.yml b/bundle-generator/deployment/src/test/external-crds/external.crd.yml new file mode 100644 index 00000000..19576b17 --- /dev/null +++ b/bundle-generator/deployment/src/test/external-crds/external.crd.yml @@ -0,0 +1,26 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: externals.halkyon.io +spec: + conversion: + strategy: None + group: halkyon.io + names: + kind: External + listKind: ExternalList + plural: externals + singular: external + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + spec: + type: object + status: + type: object + type: object + served: true + storage: true \ No newline at end of file diff --git a/bundle-generator/deployment/src/test/external-crds/v1beta1spec.crd.yml b/bundle-generator/deployment/src/test/external-crds/v1beta1spec.crd.yml new file mode 100644 index 00000000..98e1ec20 --- /dev/null +++ b/bundle-generator/deployment/src/test/external-crds/v1beta1spec.crd.yml @@ -0,0 +1,27 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: v1beta1s.test.com +spec: + group: test.com + names: + kind: V1Beta1 + plural: v1beta1s + singular: v1beta1 + scope: Namespaced + subresources: + status: { } + validation: + openAPIV3Schema: + properties: + spec: + properties: + value: + type: string + type: object + type: object + versions: + - name: v1 + served: true + storage: true \ No newline at end of file diff --git a/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/ExternalCRDsTest.java b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/ExternalCRDsTest.java new file mode 100644 index 00000000..753508b6 --- /dev/null +++ b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/ExternalCRDsTest.java @@ -0,0 +1,60 @@ +package io.quarkiverse.operatorsdk.bundle; + +import static io.quarkiverse.operatorsdk.bundle.Utils.*; +import static io.quarkiverse.operatorsdk.bundle.Utils.getCRDNameFor; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.quarkiverse.operatorsdk.bundle.sources.External; +import io.quarkiverse.operatorsdk.bundle.sources.ExternalDependentResource; +import io.quarkiverse.operatorsdk.bundle.sources.First; +import io.quarkiverse.operatorsdk.bundle.sources.ReconcilerWithExternalCR; +import io.quarkiverse.operatorsdk.bundle.sources.V1Beta1CRD; +import io.quarkiverse.operatorsdk.runtime.CRDUtils; +import io.quarkus.test.LogCollectingTestResource; +import io.quarkus.test.ProdBuildResults; +import io.quarkus.test.ProdModeTestResults; +import io.quarkus.test.QuarkusProdModeTest; + +public class ExternalCRDsTest { + + private static final String APP = "reconciler-with-external-crds"; + @RegisterExtension + static final QuarkusProdModeTest config = new QuarkusProdModeTest() + .setApplicationName(APP) + .setLogRecordPredicate(r -> r.getLoggerName().equals(CRDUtils.class.getName())) + .withApplicationRoot((jar) -> jar + .addClasses(First.class, External.class, ExternalDependentResource.class, + ReconcilerWithExternalCR.class)) + .overrideConfigKey("quarkus.operator-sdk.crd.external-crd-locations", + "src/test/external-crds/v1beta1spec.crd.yml, src/test/external-crds/external.crd.yml"); + + @ProdBuildResults + private ProdModeTestResults prodModeTestResults; + + @Test + public void shouldProcessExternalCRDsWhenPresentAndOutputWarningsAsNeeded() throws IOException { + final var bundle = prodModeTestResults.getBuildDir().resolve(BUNDLE); + assertTrue(Files.exists(bundle.resolve(APP))); + final var manifests = bundle.resolve(APP).resolve("manifests"); + assertFileExistsIn(manifests.resolve(getCRDNameFor(External.class)), manifests); + assertFileExistsIn(manifests.resolve(getCRDNameFor(V1Beta1CRD.class)), manifests); + + final var csv = getCSVFor(bundle, APP); + final var externalCRD = csv.getSpec().getCustomresourcedefinitions().getRequired().get(0); + assertEquals(HasMetadata.getFullResourceName(External.class), externalCRD.getName()); + final var v1beta1 = csv.getSpec().getCustomresourcedefinitions().getRequired().get(1); + assertEquals(HasMetadata.getFullResourceName(V1Beta1CRD.class), v1beta1.getName()); + + assertEquals(1, prodModeTestResults.getRetainedBuildLogRecords().stream() + .map(LogCollectingTestResource::format) + .filter(logRecord -> logRecord.contains("src/test/external-crds/v1beta1spec.crd.yml")).count()); + } + +} diff --git a/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/MultipleOperatorsBundleTest.java b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/MultipleOperatorsBundleTest.java index fd3f921b..49910b88 100644 --- a/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/MultipleOperatorsBundleTest.java +++ b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/MultipleOperatorsBundleTest.java @@ -98,11 +98,11 @@ public void shouldWriteBundleForTheOperators() throws IOException { assertEquals(Third.DISPLAY, thirdCRD.getDisplayName()); assertEquals(Third.DESCRIPTION, thirdCRD.getDescription()); // CRDs should be alphabetically ordered - final var externalCRD = crds.getRequired().get(0); + final var externalCRD = crds.getRequired().get(1); assertEquals(HasMetadata.getFullResourceName(External.class), externalCRD.getName()); assertEquals(External.DISPLAY_NAME, externalCRD.getDisplayName()); assertEquals(External.DESCRIPTION, externalCRD.getDescription()); - assertEquals(HasMetadata.getFullResourceName(SecondExternal.class), crds.getRequired().get(1).getName()); + assertEquals(HasMetadata.getFullResourceName(SecondExternal.class), crds.getRequired().get(0).getName()); // should list native APIs as well final var spec = csv.getSpec(); final var nativeAPIs = spec.getNativeAPIs(); diff --git a/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/ReconcilerWithExternalCR.java b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/ReconcilerWithExternalCR.java new file mode 100644 index 00000000..57e32cb2 --- /dev/null +++ b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/ReconcilerWithExternalCR.java @@ -0,0 +1,17 @@ +package io.quarkiverse.operatorsdk.bundle.sources; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +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.dependent.Dependent; +import io.quarkiverse.operatorsdk.annotations.CSVMetadata; + +@ControllerConfiguration(dependents = @Dependent(type = ExternalDependentResource.class)) +@CSVMetadata(requiredCRDs = @CSVMetadata.RequiredCRD(kind = V1Beta1CRD.KIND, name = V1Beta1CRD.CR_NAME, version = V1Beta1CRD.VERSION)) +public class ReconcilerWithExternalCR implements Reconciler { + @Override + public UpdateControl reconcile(First first, Context context) throws Exception { + return null; + } +} diff --git a/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/V1Beta1CRD.java b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/V1Beta1CRD.java new file mode 100644 index 00000000..b5b60ce5 --- /dev/null +++ b/bundle-generator/deployment/src/test/java/io/quarkiverse/operatorsdk/bundle/sources/V1Beta1CRD.java @@ -0,0 +1,20 @@ +package io.quarkiverse.operatorsdk.bundle.sources; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group(V1Beta1CRD.GROUP) +@Version(V1Beta1CRD.VERSION) +@Kind(V1Beta1CRD.KIND) +public class V1Beta1CRD extends CustomResource { + + public static final String GROUP = "test.com"; + public static final String VERSION = "v2"; + public static final String KIND = "V1Beta1"; + public static final String CR_NAME = "v1beta1s." + GROUP; + + public record Spec(String value) { + } +} diff --git a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/CustomResourceAugmentedClassInfo.java b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/CustomResourceAugmentedClassInfo.java index 3478f65f..8862a1dd 100644 --- a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/CustomResourceAugmentedClassInfo.java +++ b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/CustomResourceAugmentedClassInfo.java @@ -2,7 +2,7 @@ import java.util.Map; import java.util.Optional; -import java.util.Set; +import java.util.function.Function; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.IndexView; @@ -12,7 +12,7 @@ public class CustomResourceAugmentedClassInfo extends ReconciledResourceAugmentedClassInfo> { - public static final String EXISTING_CRDS_KEY = "existing-crds-key"; + public static final String KEEP_CR_PREDICATE_KEY = "keep-cr-predicate"; protected CustomResourceAugmentedClassInfo(ClassInfo classInfo, String associatedReconcilerName) { super(classInfo, Constants.CUSTOM_RESOURCE, 2, associatedReconcilerName); @@ -21,15 +21,16 @@ protected CustomResourceAugmentedClassInfo(ClassInfo classInfo, String associate @Override protected boolean doKeep(IndexView index, Logger log, Map context) { // only keep the information if the associated CRD hasn't already been generated - return Optional.ofNullable(context.get(EXISTING_CRDS_KEY)) - .map(value -> { - @SuppressWarnings("unchecked") - Set generated = (Set) value; - return !generated.contains(id()); - }) + return Optional.ofNullable(predicateFromContext(context)) + .map(predicate -> predicate.apply(this)) .orElse(true); } + @SuppressWarnings("unchecked") + private Function predicateFromContext(Map context) { + return (Function) context.get(KEEP_CR_PREDICATE_KEY); + } + @Override protected void doAugment(IndexView index, Logger log, Map context) { super.doAugment(index, log, context); diff --git a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/ReconciledResourceAugmentedClassInfo.java b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/ReconciledResourceAugmentedClassInfo.java index 2754ea06..21a33fe1 100644 --- a/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/ReconciledResourceAugmentedClassInfo.java +++ b/common-deployment/src/main/java/io/quarkiverse/operatorsdk/common/ReconciledResourceAugmentedClassInfo.java @@ -14,17 +14,20 @@ public class ReconciledResourceAugmentedClassInfo extends public static final String STATUS = "status"; protected boolean hasStatus; - private final String id; + private final Id id; + + public record Id(String fullResourceName, String version) { + } protected ReconciledResourceAugmentedClassInfo(ClassInfo classInfo, DotName extendedOrImplementedClass, int expectedParameterTypesCardinality, String associatedReconcilerName) { super(classInfo, extendedOrImplementedClass, expectedParameterTypesCardinality, associatedReconcilerName); - id = fullResourceName() + version(); + id = new Id(fullResourceName(), version()); } - public String id() { + public Id id() { return id; } 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 f8b423e7..59c48251 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 @@ -4,7 +4,6 @@ import java.nio.file.Path; import java.nio.file.Paths; -import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Optional; @@ -20,18 +19,18 @@ import io.quarkiverse.operatorsdk.runtime.CRDConfiguration; import io.quarkiverse.operatorsdk.runtime.CRDGenerationInfo; import io.quarkiverse.operatorsdk.runtime.CRDInfo; +import io.quarkiverse.operatorsdk.runtime.CRDInfos; import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; import io.quarkus.runtime.LaunchMode; class CRDGeneration { private static final Logger log = Logger.getLogger(CRDGeneration.class.getName()); - private CRDGenerator generator; private final LaunchMode mode; private final CRDConfiguration crdConfiguration; + private CRDGenerator generator; private boolean needGeneration; - private final ResourceControllerMapping crMappings = new ResourceControllerMapping(); - public CRDGeneration(CRDConfiguration crdConfig, LaunchMode mode) { + CRDGeneration(CRDConfiguration crdConfig, LaunchMode mode) { this.crdConfiguration = crdConfig; this.mode = mode; } @@ -66,9 +65,9 @@ boolean shouldApply() { * @return a {@link CRDGenerationInfo} detailing information about the CRD generation */ CRDGenerationInfo generate(OutputTargetBuildItem outputTarget, - boolean validateCustomResources, Map> existing) { + boolean validateCustomResources, CRDInfos existing) { // initialize CRDInfo with existing data to always have a full view even if we don't generate anything - final var converted = new HashMap<>(existing); + final var converted = new CRDInfos(existing); // record which CRDs got generated so that we only apply the changed ones final var generated = new HashSet(); @@ -87,14 +86,12 @@ CRDGenerationInfo generate(OutputTargetBuildItem outputTarget, log.infov("Generated {0} CRD:", crdName); generated.add(crdName); - final var versions = crMappings.getResourceInfos(crdName); - final var versionToCRDInfo = converted.computeIfAbsent(crdName, s -> new HashMap<>()); initialVersionToCRDInfoMap - .forEach((version, crdInfo) -> { + .forEach((crdSpecVersion, crdInfo) -> { final var filePath = crdInfo.getFilePath(); - log.infov(" - {0} -> {1}", version, filePath); - versionToCRDInfo.put(version, new CRDInfo(crdInfo.getCrdName(), - version, filePath, crdInfo.getDependentClassNames(), versions)); + log.infov(" - {0} -> {1}", crdSpecVersion, filePath); + converted.addCRDInfo(new CRDInfo(crdName, + crdSpecVersion, filePath, crdInfo.getDependentClassNames())); }); }); } @@ -143,13 +140,7 @@ boolean scheduleForGenerationIfNeeded(CustomResourceAugmentedClassInfo crInfo, return scheduleCurrent; } - public void withCustomResource(Class> crClass, String associatedControllerName) { - // first check if the CR is not filtered out - if (crdConfiguration.excludeResources().map(excluded -> excluded.contains(crClass.getName())).orElse(false)) { - log.infov("CRD generation was skipped for ''{0}'' because it was excluded from generation", crClass.getName()); - return; - } - + void withCustomResource(Class> crClass, String associatedControllerName) { try { // generator MUST be initialized before we start processing classes as initializing it // will reset the types information held by the generator @@ -157,7 +148,6 @@ public void withCustomResource(Class> crClass, St generator = new CRDGenerator().withParallelGenerationEnabled(crdConfiguration.generateInParallel()); } final var info = CustomResourceInfo.fromClass(crClass); - crMappings.add(info, associatedControllerName); generator.customResources(info); needGeneration = true; } catch (Exception e) { diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGenerationBuildStep.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGenerationBuildStep.java index 6c3cdd99..1273572a 100644 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGenerationBuildStep.java +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/CRDGenerationBuildStep.java @@ -2,7 +2,12 @@ import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; import org.jboss.logging.Logger; @@ -10,24 +15,29 @@ import io.quarkiverse.operatorsdk.runtime.BuildTimeOperatorConfiguration; import io.quarkiverse.operatorsdk.runtime.CRDGenerationInfo; import io.quarkiverse.operatorsdk.runtime.CRDInfo; +import io.quarkiverse.operatorsdk.runtime.CRDInfos; +import io.quarkiverse.operatorsdk.runtime.CRDUtils; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LiveReloadBuildItem; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; class CRDGenerationBuildStep { static final Logger log = Logger.getLogger(CRDGenerationBuildStep.class.getName()); - - private BuildTimeOperatorConfiguration operatorConfiguration; + private static final String excludedCause = "it was explicitly excluded from generation"; + private static final String externalCause = "it is associated with an externally provided CRD"; @BuildStep GeneratedCRDInfoBuildItem generateCRDs( + BuildTimeOperatorConfiguration operatorConfiguration, ReconcilerInfosBuildItem reconcilers, LaunchModeBuildItem launchModeBuildItem, LiveReloadBuildItem liveReload, OutputTargetBuildItem outputTarget, - CombinedIndexBuildItem combinedIndexBuildItem) { + CombinedIndexBuildItem combinedIndexBuildItem, + UnownedCRDInfoBuildItem unownedCRDInfo) { final var crdConfig = operatorConfiguration.crd(); final boolean validateCustomResources = ConfigurationUtils.shouldValidateCustomResources(crdConfig.validate()); @@ -36,48 +46,61 @@ GeneratedCRDInfoBuildItem generateCRDs( final var crdGeneration = new CRDGeneration(crdConfig, launchMode); // retrieve the known CRD information to make sure we always have a full view - var stored = liveReload.getContextObject(ContextStoredCRDInfos.class); + var stored = liveReload.getContextObject(CRDInfos.class); if (stored == null) { - stored = new ContextStoredCRDInfos(); + stored = new CRDInfos(); } final var generate = CRDGeneration.shouldGenerate(crdConfig.generate(), crdConfig.apply(), launchMode); final var storedCRDInfos = stored; final var changedClasses = ConfigurationUtils.getChangedClasses(liveReload); - final var scheduledForGeneration = new HashSet(7); + final var scheduledForGeneration = new HashSet(7); - if (generate) { - reconcilers.getReconcilers().values().forEach(raci -> { - // add associated primary resource for CRD generation if it's a CR and it's owned by the reconciler - final ReconciledAugmentedClassInfo associatedResource = raci.associatedResourceInfo(); - if (associatedResource.isCR()) { - final var crInfo = associatedResource.asResourceTargeting(); - final String crId = crInfo.id(); - - // if the primary resource is unowned, mark it as "scheduled" (i.e. already handled) so that it doesn't get considered if all CRDs generation is requested - if (!operatorConfiguration.isControllerOwningPrimary(raci.nameOrFailIfUnset())) { - scheduledForGeneration.add(crId); - } else { - // When we have a live reload, check if we need to regenerate the associated CRD - Map crdInfos = Collections.emptyMap(); - if (liveReload.isLiveReload()) { - crdInfos = storedCRDInfos.getCRDInfosFor(crId); - } + final var excludedResourceClasses = crdConfig.excludeResources().map(Set::copyOf).orElseGet(Collections::emptySet); + final var externalCRDs = unownedCRDInfo.getCRDs(); + // predicate to decide whether or not to consider a given resource for generation + Function keepResourcePredicate = ( + CustomResourceAugmentedClassInfo crInfo) -> !isExcluded(crInfo, externalCRDs, excludedResourceClasses); - // schedule the generation of associated primary resource CRD if required - if (crdGeneration.scheduleForGenerationIfNeeded((CustomResourceAugmentedClassInfo) crInfo, crdInfos, - changedClasses)) { + if (generate) { + reconcilers.getReconcilers().values().stream() + .map(ResourceAssociatedAugmentedClassInfo::associatedResourceInfo) + .filter(ReconciledAugmentedClassInfo::isCR) // only keep CRs + .map(CustomResourceAugmentedClassInfo.class::cast) + .filter(keepResourcePredicate::apply) + .forEach(associatedResource -> { + final var crInfo = associatedResource.asResourceTargeting(); + final var crId = crInfo.id(); + + // if the primary resource is unowned, mark it as "scheduled" (i.e. already handled) so that it doesn't get considered if all CRDs generation is requested + if (!operatorConfiguration + .isControllerOwningPrimary(associatedResource.getAssociatedReconcilerName().orElseThrow())) { scheduledForGeneration.add(crId); + } else { + // When we have a live reload, check if we need to regenerate the associated CRD + Map crdInfos = Collections.emptyMap(); + if (liveReload.isLiveReload()) { + crdInfos = storedCRDInfos.getOrCreateCRDSpecVersionToInfoMapping(crInfo.fullResourceName()); + } + + // schedule the generation of associated primary resource CRD if required + if (crdGeneration.scheduleForGenerationIfNeeded((CustomResourceAugmentedClassInfo) crInfo, crdInfos, + changedClasses)) { + scheduledForGeneration.add(crId); + } } - } - } - }); + }); // generate non-reconciler associated CRDs if requested if (crdConfig.generateAll()) { + // only process CRs that haven't been already considered and are not excluded + keepResourcePredicate = ( + CustomResourceAugmentedClassInfo crInfo) -> !scheduledForGeneration.contains(crInfo.id()) + && !isExcluded(crInfo, externalCRDs, excludedResourceClasses); + final Map context = Map.of(CustomResourceAugmentedClassInfo.KEEP_CR_PREDICATE_KEY, + keepResourcePredicate); ClassUtils.getProcessableSubClassesOf(Constants.CUSTOM_RESOURCE, combinedIndexBuildItem.getIndex(), log, - // pass already generated CRD names so that we can only keep the unhandled ones - Map.of(CustomResourceAugmentedClassInfo.EXISTING_CRDS_KEY, scheduledForGeneration)) + context) .map(CustomResourceAugmentedClassInfo.class::cast) .forEach(cr -> { crdGeneration.withCustomResource(cr.loadAssociatedClass(), null); @@ -87,13 +110,49 @@ GeneratedCRDInfoBuildItem generateCRDs( } // perform "generation" even if not requested to ensure we always produce the needed build item for other steps - CRDGenerationInfo crdInfo = crdGeneration.generate(outputTarget, validateCustomResources, storedCRDInfos.getExisting()); + CRDGenerationInfo crdInfo = crdGeneration.generate(outputTarget, validateCustomResources, storedCRDInfos); // record CRD generation info in context for future use - Map> generatedCRDs = crdInfo.getCrds(); - storedCRDInfos.putAll(generatedCRDs); - liveReload.setContextObject(ContextStoredCRDInfos.class, storedCRDInfos); + liveReload.setContextObject(CRDInfos.class, storedCRDInfos); return new GeneratedCRDInfoBuildItem(crdInfo); } + + @BuildStep + UnownedCRDInfoBuildItem unownedCRDInfo(BuildTimeOperatorConfiguration operatorConfiguration, + CurateOutcomeBuildItem appInfoBuildItem) { + final Optional> maybeExternalCRDs = operatorConfiguration.crd().externalCRDLocations(); + final var crds = new CRDInfos(); + if (maybeExternalCRDs.isPresent()) { + final var moduleRoot = appInfoBuildItem.getApplicationModel().getApplicationModule().getModuleDir().toPath(); + maybeExternalCRDs.get().parallelStream() + .filter(Predicate.not(String::isBlank)) + .map(String::trim) + .forEach(crdLocation -> { + final var crdPath = moduleRoot.resolve(crdLocation); + final var crd = CRDUtils.loadFromAsCRDInfo(crdPath); + crds.addCRDInfo(crd); + }); + } + return new UnownedCRDInfoBuildItem(crds); + } + + /** + * Exclude all resources that shouldn't be generated because either they've been explicitly excluded or because they're + * supposed to be loaded directly from a specified CRD file + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean isExcluded(CustomResourceAugmentedClassInfo crInfo, CRDInfos externalCRDs, + Set excludedResourceClassNames) { + final var crClassName = crInfo.classInfo().name().toString(); + final var excluded = excludedResourceClassNames.contains(crClassName); + final var external = externalCRDs.contains(crInfo.fullResourceName()); + if (excluded || external) { + log.infov("CRD generation was skipped for ''{0}'' because {1}", crClassName, + external ? externalCause : excludedCause); + return true; + } else { + return false; + } + } } diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ContextStoredCRDInfos.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ContextStoredCRDInfos.java deleted file mode 100644 index c8c31023..00000000 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ContextStoredCRDInfos.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.quarkiverse.operatorsdk.deployment; - -import java.util.HashMap; -import java.util.Map; - -import io.quarkiverse.operatorsdk.runtime.CRDInfo; - -public class ContextStoredCRDInfos { - private final Map> infos = new HashMap<>(); - - Map getCRDInfosFor(String crdName) { - return infos.computeIfAbsent(crdName, k -> new HashMap<>()); - } - - public Map> getExisting() { - return infos; - } - - void putAll(Map> toAdd) { - infos.putAll(toAdd); - } -} diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ResourceControllerMapping.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ResourceControllerMapping.java deleted file mode 100644 index a9ed3798..00000000 --- a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/ResourceControllerMapping.java +++ /dev/null @@ -1,53 +0,0 @@ -package io.quarkiverse.operatorsdk.deployment; - -import java.util.HashMap; -import java.util.Map; - -import io.quarkiverse.operatorsdk.common.ClassUtils; -import io.quarkiverse.operatorsdk.runtime.ResourceInfo; - -public class ResourceControllerMapping { - private final Map> resourceFullNameToVersionToInfos = new HashMap<>(7); - - public Map getResourceInfos(String resourceFullName) { - final var infos = resourceFullNameToVersionToInfos.get(resourceFullName); - if (infos == null) { - throw new IllegalStateException("Should have information associated with '" + resourceFullName + "'"); - } - return infos; - } - - public void add(io.fabric8.crdv2.generator.CustomResourceInfo info, String associatedControllerName) { - final var version = info.version(); - final var crdName = info.crdName(); - final var versionsForCR = resourceFullNameToVersionToInfos.computeIfAbsent(crdName, s -> new HashMap<>()); - final var cri = versionsForCR.get(version); - if (cri != null) { - String msg = "Cannot add CustomResource '" + crdName + "' with version " - + version + " for processing"; - if (associatedControllerName != null) { - msg += " by " + associatedControllerName; - } - - msg += " because it's already been added previously"; - final String existing = cri.getControllerName(); - if (existing != null) { - msg += " to be processed by the controller named '" + existing + "'"; - } - - throw new IllegalStateException(msg); - } - - final var converted = augment(info, associatedControllerName); - versionsForCR.put(version, converted); - } - - private static ResourceInfo augment(io.fabric8.crdv2.generator.CustomResourceInfo info, String associatedControllerName) { - return new ResourceInfo( - info.group(), info.version(), info.kind(), info.singular(), info.plural(), info.shortNames(), - info.storage(), - info.served(), info.scope(), info.crClassName(), - info.statusClassName().map(ClassUtils::isStatusNotVoid).orElse(false), info.crdName(), - associatedControllerName); - } -} diff --git a/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/UnownedCRDInfoBuildItem.java b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/UnownedCRDInfoBuildItem.java new file mode 100644 index 00000000..d6a1b939 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/operatorsdk/deployment/UnownedCRDInfoBuildItem.java @@ -0,0 +1,16 @@ +package io.quarkiverse.operatorsdk.deployment; + +import io.quarkiverse.operatorsdk.runtime.CRDInfos; +import io.quarkus.builder.item.SimpleBuildItem; + +public final class UnownedCRDInfoBuildItem extends SimpleBuildItem { + private final CRDInfos crds; + + public UnownedCRDInfoBuildItem(CRDInfos crds) { + this.crds = crds; + } + + public CRDInfos getCRDs() { + return crds; + } +} 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 index 822257f2..8a79189b 100644 --- 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 @@ -239,9 +239,7 @@ void addGeneratedDeployment(HelmTargetDirectoryBuildItem helmDirBI, @BuildStep @Produce(ArtifactResultBuildItem.class) private void addCRDs(HelmTargetDirectoryBuildItem helmDirBI, GeneratedCRDInfoBuildItem generatedCRDInfoBuildItem) { - var crdInfos = generatedCRDInfoBuildItem.getCRDGenerationInfo().getCrds().values().stream() - .flatMap(m -> m.values().stream()) - .toList(); + var crdInfos = generatedCRDInfoBuildItem.getCRDGenerationInfo().getCrds().getCRDNameToInfoMappings().values(); final var crdDir = helmDirBI.getPathToHelmDir().resolve(CRD_DIR); crdInfos.forEach(crdInfo -> { diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDConfiguration.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDConfiguration.java index 90faee8d..bd0ca5e9 100644 --- a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDConfiguration.java +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDConfiguration.java @@ -64,4 +64,18 @@ public interface CRDConfiguration { * process. */ Optional> excludeResources(); + + /** + * A comma-separated list of paths where external CRDs that need to be referenced for non-generated custom resources. + * Typical use cases where this might be needed include when custom resource implementations are located in a different + * module than the controller implementation or when the CRDs are not generated at all (e.g. in integration cases where your + * operator needs to deal with 3rd party custom resources). + * + *

+ * Paths can be either absolute or relative, in which case they will be resolved from the current module root directory. + *

+ * + * @since 6.8.4 + */ + Optional> externalCRDLocations(); } diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDGenerationInfo.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDGenerationInfo.java index 2f6f0d7d..2fb24a9d 100644 --- a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDGenerationInfo.java +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDGenerationInfo.java @@ -1,6 +1,5 @@ package io.quarkiverse.operatorsdk.runtime; -import java.util.Collections; import java.util.Map; import java.util.Set; @@ -10,20 +9,20 @@ public class CRDGenerationInfo { private final boolean applyCRDs; private final boolean validateCRDs; - private final Map> crds; + private final CRDInfos crds; private final Set generated; @RecordableConstructor // constructor needs to be recordable for the class to be passed around by Quarkus - public CRDGenerationInfo(boolean applyCRDs, boolean validateCRDs, Map> crds, + public CRDGenerationInfo(boolean applyCRDs, boolean validateCRDs, CRDInfos crds, Set generated) { this.applyCRDs = applyCRDs; this.validateCRDs = validateCRDs; - this.crds = Collections.unmodifiableMap(crds); + this.crds = crds; this.generated = generated; } // Needed by Quarkus: if this method isn't present, state is not properly set - public Map> getCrds() { + public CRDInfos getCrds() { return crds; } @@ -37,13 +36,9 @@ public boolean isApplyCRDs() { return applyCRDs; } - public boolean shouldApplyCRD(String name) { - return generated.contains(name); - } - @IgnoreProperty public Map getCRDInfosFor(String crdName) { - return crds.get(crdName); + return crds.getOrCreateCRDSpecVersionToInfoMapping(crdName); } public boolean isValidateCRDs() { diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfo.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfo.java index 16720b3c..3e7acb6b 100644 --- a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfo.java +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfo.java @@ -1,25 +1,21 @@ package io.quarkiverse.operatorsdk.runtime; -import java.util.Map; import java.util.Set; import io.quarkus.runtime.annotations.RecordableConstructor; public class CRDInfo { - private final Map versions; private final String crdName; private final String crdSpecVersion; private final String filePath; private final Set dependentClassNames; @RecordableConstructor // constructor needs to be recordable for the class to be passed around by Quarkus - public CRDInfo(String crdName, String crdSpecVersion, String filePath, Set dependentClassNames, - Map versions) { + public CRDInfo(String crdName, String crdSpecVersion, String filePath, Set dependentClassNames) { this.crdName = crdName; this.crdSpecVersion = crdSpecVersion; this.filePath = filePath; this.dependentClassNames = dependentClassNames; - this.versions = versions; } public String getCrdName() { @@ -37,9 +33,4 @@ public String getFilePath() { public Set getDependentClassNames() { return this.dependentClassNames; } - - public Map getVersions() { - return versions; - } - } diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfos.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfos.java new file mode 100644 index 00000000..b912e738 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDInfos.java @@ -0,0 +1,56 @@ +package io.quarkiverse.operatorsdk.runtime; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.quarkus.runtime.annotations.IgnoreProperty; +import io.quarkus.runtime.annotations.RecordableConstructor; + +public class CRDInfos { + private final Map> infos; + + public CRDInfos() { + this(new ConcurrentHashMap<>()); + } + + public CRDInfos(CRDInfos other) { + this(new ConcurrentHashMap<>(other.infos)); + } + + @RecordableConstructor // constructor needs to be recordable for the class to be passed around by Quarkus + private CRDInfos(Map> infos) { + this.infos = infos; + } + + @IgnoreProperty + public Map getOrCreateCRDSpecVersionToInfoMapping(String crdName) { + return infos.computeIfAbsent(crdName, k -> new HashMap<>()); + } + + @IgnoreProperty + public Map getCRDNameToInfoMappings() { + return infos + .values().stream() + // only keep CRD v1 infos + .flatMap(entry -> entry.values().stream() + .filter(crdInfo -> CRDUtils.DEFAULT_CRD_SPEC_VERSION.equals(crdInfo.getCrdSpecVersion()))) + .collect(Collectors.toMap(CRDInfo::getCrdName, Function.identity())); + } + + public void addCRDInfo(CRDInfo crdInfo) { + getOrCreateCRDSpecVersionToInfoMapping(crdInfo.getCrdName()).put(crdInfo.getCrdSpecVersion(), crdInfo); + } + + // Needed by Quarkus: if this method isn't present, state is not properly set + @SuppressWarnings("unused") + public Map> getInfos() { + return infos; + } + + public boolean contains(String crdId) { + return infos.containsKey(crdId); + } +} diff --git a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDUtils.java b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDUtils.java index ee52b60c..e50c9428 100644 --- a/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDUtils.java +++ b/core/runtime/src/main/java/io/quarkiverse/operatorsdk/runtime/CRDUtils.java @@ -3,15 +3,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import org.jboss.logging.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; + import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; public final class CRDUtils { private static final Logger LOGGER = Logger.getLogger(CRDUtils.class.getName()); + private static final KubernetesSerialization SERIALIZATION = new KubernetesSerialization(new ObjectMapper(), false); + public final static String DEFAULT_CRD_SPEC_VERSION = "v1"; + public static final String V1BETA1_CRD_SPEC_VERSION = "v1beta1"; private CRDUtils() { } @@ -21,8 +28,7 @@ public static void applyCRD(KubernetesClient client, CRDGenerationInfo crdInfo, crdInfo.getCRDInfosFor(crdName).forEach((crdVersion, info) -> { final var filePath = Path.of(info.getFilePath()); try { - final var crd = client.getKubernetesSerialization() - .unmarshal(Files.newInputStream(filePath), getCRDClassFor(crdVersion)); + final var crd = loadFrom(filePath, client.getKubernetesSerialization(), getCRDClassFor(crdVersion)); apply(client, crdVersion, crd); LOGGER.infov("Applied {0} CRD named ''{1}'' from {2}", crdVersion, crdName, filePath); } catch (IOException ex) { @@ -35,9 +41,35 @@ public static void applyCRD(KubernetesClient client, CRDGenerationInfo crdInfo, } } + private static T loadFrom(Path crdPath, KubernetesSerialization serialization, Class crdClass) throws IOException { + serialization = serialization == null ? SERIALIZATION : serialization; + return serialization.unmarshal(Files.newInputStream(crdPath), crdClass); + } + + public static CustomResourceDefinition loadFrom(Path crdPath) throws IOException { + final var crd = loadFrom(crdPath, null, CustomResourceDefinition.class); + final var crdVersion = crd.getApiVersion().split("/")[1]; + if (!DEFAULT_CRD_SPEC_VERSION.equals(crdVersion)) { + LOGGER.warnv( + "CRD at {0} was loaded as a {1} CRD but is defined as using {2} CRD spec version. While things might still work as expected, we recommend that you only use CRDs using the {1} CRD spec version.", + crdPath, DEFAULT_CRD_SPEC_VERSION, crdVersion); + } + return crd; + } + + public static CRDInfo loadFromAsCRDInfo(Path crdPath) { + try { + final var crd = loadFrom(crdPath); + final var crdName = crd.getMetadata().getName(); + return new CRDInfo(crdName, DEFAULT_CRD_SPEC_VERSION, crdPath.toFile().getAbsolutePath(), Collections.emptySet()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private static void apply(KubernetesClient client, String v, Object crd) { switch (v) { - case "v1": + case DEFAULT_CRD_SPEC_VERSION: final var resource = client.apiextensions().v1().customResourceDefinitions() .resource((CustomResourceDefinition) crd); if (resource.get() != null) { @@ -46,7 +78,7 @@ private static void apply(KubernetesClient client, String v, Object crd) { resource.create(); } break; - case "v1beta1": + case V1BETA1_CRD_SPEC_VERSION: final var legacyResource = client.apiextensions().v1beta1().customResourceDefinitions() .resource((io.fabric8.kubernetes.api.model.apiextensions.v1beta1.CustomResourceDefinition) crd); if (legacyResource.get() != null) { @@ -62,8 +94,9 @@ private static void apply(KubernetesClient client, String v, Object crd) { private static Class getCRDClassFor(String v) { return switch (v) { - case "v1" -> CustomResourceDefinition.class; - case "v1beta1" -> io.fabric8.kubernetes.api.model.apiextensions.v1beta1.CustomResourceDefinition.class; + case DEFAULT_CRD_SPEC_VERSION -> CustomResourceDefinition.class; + case V1BETA1_CRD_SPEC_VERSION -> + io.fabric8.kubernetes.api.model.apiextensions.v1beta1.CustomResourceDefinition.class; default -> throw new IllegalArgumentException("Unknown CRD version: " + v); }; } diff --git a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc index 2b0d73bc..2fffe08d 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk.adoc @@ -143,6 +143,25 @@ endif::add-copy-button-to-env-var[] |list of string | +a|icon:lock[title=Fixed at build time] [[quarkus-operator-sdk_quarkus-operator-sdk-crd-external-crd-locations]] [.property-path]##link:#quarkus-operator-sdk_quarkus-operator-sdk-crd-external-crd-locations[`quarkus.operator-sdk.crd.external-crd-locations`]## + +[.description] +-- +A comma-separated list of paths where external CRDs that need to be referenced for non-generated custom resources. Typical use cases where this might be needed include when custom resource implementations are located in a different module than the controller implementation or when the CRDs are not generated at all (e.g. in integration cases where your operator needs to deal with 3rd party custom resources). + +Paths can be either absolute or relative, in which case they will be resolved from the current module root directory. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_OPERATOR_SDK_CRD_EXTERNAL_CRD_LOCATIONS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_OPERATOR_SDK_CRD_EXTERNAL_CRD_LOCATIONS+++` +endif::add-copy-button-to-env-var[] +-- +|list of string +| + a|icon:lock[title=Fixed at build time] [[quarkus-operator-sdk_quarkus-operator-sdk-generation-aware]] [.property-path]##link:#quarkus-operator-sdk_quarkus-operator-sdk-generation-aware[`quarkus.operator-sdk.generation-aware`]## [.description] diff --git a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk_quarkus.operator-sdk.adoc b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk_quarkus.operator-sdk.adoc index 2b0d73bc..2fffe08d 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-operator-sdk_quarkus.operator-sdk.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-operator-sdk_quarkus.operator-sdk.adoc @@ -143,6 +143,25 @@ endif::add-copy-button-to-env-var[] |list of string | +a|icon:lock[title=Fixed at build time] [[quarkus-operator-sdk_quarkus-operator-sdk-crd-external-crd-locations]] [.property-path]##link:#quarkus-operator-sdk_quarkus-operator-sdk-crd-external-crd-locations[`quarkus.operator-sdk.crd.external-crd-locations`]## + +[.description] +-- +A comma-separated list of paths where external CRDs that need to be referenced for non-generated custom resources. Typical use cases where this might be needed include when custom resource implementations are located in a different module than the controller implementation or when the CRDs are not generated at all (e.g. in integration cases where your operator needs to deal with 3rd party custom resources). + +Paths can be either absolute or relative, in which case they will be resolved from the current module root directory. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_OPERATOR_SDK_CRD_EXTERNAL_CRD_LOCATIONS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_OPERATOR_SDK_CRD_EXTERNAL_CRD_LOCATIONS+++` +endif::add-copy-button-to-env-var[] +-- +|list of string +| + a|icon:lock[title=Fixed at build time] [[quarkus-operator-sdk_quarkus-operator-sdk-generation-aware]] [.property-path]##link:#quarkus-operator-sdk_quarkus-operator-sdk-generation-aware[`quarkus.operator-sdk.generation-aware`]## [.description]