diff --git a/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVGenerator.java b/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVGenerator.java index d93886fe4..127c2ac6f 100644 --- a/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVGenerator.java +++ b/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVGenerator.java @@ -1,9 +1,6 @@ package io.quarkiverse.operatorsdk.csv.deployment; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.file.Path; import java.util.Base64; import java.util.HashMap; import java.util.HashSet; @@ -11,7 +8,9 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.jboss.logging.Logger; @@ -27,7 +26,9 @@ import io.fabric8.kubernetes.api.model.rbac.Role; import io.fabric8.openshift.api.model.operatorhub.v1alpha1.ClusterServiceVersionBuilder; import io.fabric8.openshift.api.model.operatorhub.v1alpha1.ClusterServiceVersionFluent; +import io.fabric8.openshift.api.model.operatorhub.v1alpha1.ClusterServiceVersionFluent.SpecNested; import io.fabric8.openshift.api.model.operatorhub.v1alpha1.ClusterServiceVersionSpecFluent; +import io.fabric8.openshift.api.model.operatorhub.v1alpha1.ClusterServiceVersionSpecFluent.InstallNested; import io.fabric8.openshift.api.model.operatorhub.v1alpha1.NamedInstallStrategyFluent; import io.quarkiverse.operatorsdk.common.CustomResourceInfo; import io.quarkiverse.operatorsdk.csv.runtime.CSVMetadataHolder; @@ -45,22 +46,69 @@ public class CSVGenerator { YAML_MAPPER.configure(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS, false); } - public static void generate(Path outputDir, Map info, - Map csvMetadata, - String serviceAccountName, ClusterRole clusterRole, Role role, Deployment deployment) { - // load generated manifests + public static Set prepareGeneration(Map info, + Map csvMetadata) { + final var csvBuilders = new HashMap(7); + return info.values().parallelStream() + .map(cri -> csvBuilders.computeIfAbsent(cri.getCsvGroupName(), + s -> new NamedCSVBuilder(cri, csvMetadata))) + .collect(Collectors.toSet()); + } - final var controllerToCSVBuilders = new HashMap(7); - final var groupToCRInfo = new HashMap>(7); - info.forEach((crdName, cri) -> { - // record group name and associated CustomResourceInfos + private static Boolean hasMatchingClusterPermission(CustomResourceInfo cri, + NamedInstallStrategyFluent.SpecNested>> installSpec, + Integer[] ruleIndex, Integer[] clusterPermissionIndex) { + final var hasMatchingClusterPermission = installSpec + .hasMatchingClusterPermission(cp -> { + int i = 0; + for (PolicyRule rule : cp.getRules()) { + if (rule.getApiGroups().contains(cri.getGroup())) { + ruleIndex[0] = i; + return true; + } + i++; + } + clusterPermissionIndex[0]++; + return false; + }); + return hasMatchingClusterPermission; + } + + static class NamedCSVBuilder { + private final String csvGroupName; + private final String controllerName; + private final ClusterServiceVersionBuilder csvBuilder; + final static Map> groupToCRInfo = new ConcurrentHashMap<>(7); + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + NamedCSVBuilder that = (NamedCSVBuilder) o; + + return csvGroupName.equals(that.csvGroupName); + } + + @Override + public int hashCode() { + return csvGroupName.hashCode(); + } + + public NamedCSVBuilder(AugmentedCustomResourceInfo cri, Map csvMetadata) { + // record group to CRI mapping groupToCRInfo.computeIfAbsent(cri.getGroup(), s -> new HashSet<>()).add(cri); - final var csvGroupName = cri.getCsvGroupName(); + controllerName = cri.getControllerName(); + csvGroupName = cri.getCsvGroupName(); final var metadata = csvMetadata.get(csvGroupName); - final var csvSpecBuilder = controllerToCSVBuilders - .computeIfAbsent(csvGroupName, s -> new ClusterServiceVersionBuilder() - .withNewMetadata().withName(s).endMetadata()) + csvBuilder = new ClusterServiceVersionBuilder() + .withNewMetadata().withName(csvGroupName).endMetadata(); + final var csvSpecBuilder = csvBuilder .editOrNewSpec() .withDescription(metadata.description) .withDisplayName(metadata.displayName) @@ -85,7 +133,7 @@ public static void generate(Path outputDir, Map { - final File file = new File(outputDir.toFile(), controllerName + ".csv.yml"); + public String getFileName() { + return csvGroupName + ".csv.yml"; + } + + private String getIconName() { + return csvGroupName + ".icon.png"; + } + + byte[] getYAMLData(String serviceAccountName, ClusterRole clusterRole, Role role, + Deployment deployment) throws IOException { + final var csvSpecBuilder = csvBuilder + .editOrNewSpec(); - final var csvSpec = csvBuilder.editOrNewSpec(); // deal with icon try (var iconAsStream = Thread.currentThread().getContextClassLoader() - .getResourceAsStream(controllerName + ".icon.png"); - var outputStream = new FileOutputStream(file)) { + .getResourceAsStream(getIconName())) { if (iconAsStream != null) { - final byte[] iconAsBase64 = Base64.getEncoder().encode(iconAsStream.readAllBytes()); - csvSpec.addNewIcon() + final byte[] iconAsBase64 = Base64.getEncoder() + .encode(iconAsStream.readAllBytes()); + csvSpecBuilder.addNewIcon() .withBase64data(new String(iconAsBase64)) .withMediatype("image/png") .endIcon(); } + } catch (IOException e) { + // ignore + } - final var installSpec = csvSpec.editOrNewInstall() - .editOrNewSpec(); - if (clusterRole != null) { - // check if we have our CR group in the cluster role fragment and remove the one we added - // before since we presume that if the user defined a fragment for permissions associated with their - // CR they want that fragment to take precedence over automatically generated code - final var rules = clusterRole.getRules(); - final var clusterPermissions = installSpec.buildClusterPermissions(); - groupToCRInfo.forEach((group, infos) -> { - - final Predicate hasGroup = pr -> pr.getApiGroups().contains(group); - final var nowEmptyPermissions = new LinkedList(); - final var permissionPosition = new Integer[] { 0 }; - if (rules.stream().anyMatch(hasGroup)) { - clusterPermissions.forEach(p -> { - // record the position of all rules that match the group - Integer[] index = new Integer[] { 0 }; - List matchingRuleIndices = new LinkedList<>(); - p.getRules().forEach(r -> { - if (hasGroup.test(r)) { - matchingRuleIndices.add(index[0]); - } - index[0]++; - }); - - // remove the group from all matching rules - matchingRuleIndices.forEach(i -> { - final var groups = p.getRules().get(i).getApiGroups(); - groups.remove(group); - // if the rule doesn't have any groups anymore, remove it - if (groups.isEmpty()) { - p.getRules().remove(i.intValue()); - // if the permission doesn't have any rules anymore, mark it for removal - if (p.getRules().isEmpty()) { - nowEmptyPermissions.add(permissionPosition[0]); - } - } - }); + final var installSpec = csvSpecBuilder.editOrNewInstall().editOrNewSpec(); + handleClusterRole(serviceAccountName, clusterRole, groupToCRInfo, installSpec); - permissionPosition[0]++; - }); + handlerRole(serviceAccountName, role, installSpec); - // remove now empty permissions - nowEmptyPermissions.forEach(i -> clusterPermissions.remove(i.intValue())); - installSpec.addAllToClusterPermissions(clusterPermissions); - } - }); - installSpec - .addNewClusterPermission() - .withServiceAccountName(serviceAccountName) - .addAllToRules(rules) - .endClusterPermission(); - } + handleDeployment(deployment, installSpec); - if (role != null) { - installSpec - .addNewPermission() - .withServiceAccountName(serviceAccountName) - .addAllToRules(role.getRules()) - .endPermission(); - } + // do not forget to end the elements!! + installSpec.endSpec().endInstall(); + csvSpecBuilder.endSpec(); - if (deployment != null) { - installSpec.addNewDeployment() - .withName(deployment.getMetadata().getName()) - .withSpec(deployment.getSpec()) - .endDeployment(); - } + final var csv = csvBuilder.build(); + return YAML_MAPPER.writeValueAsBytes(csv); - // do not forget to end the elements!! - installSpec.endSpec().endInstall(); - csvSpec.endSpec(); + } - final var csv = csvBuilder.build(); - YAML_MAPPER.writeValue(outputStream, csv); - log.infov("Generated CSV for {0} controller -> {1}", controllerName, file); - } catch (IOException e) { - e.printStackTrace(); + private void handleDeployment(Deployment deployment, + NamedInstallStrategyFluent.SpecNested>> installSpec) { + if (deployment != null) { + installSpec.addNewDeployment() + .withName(deployment.getMetadata().getName()) + .withSpec(deployment.getSpec()) + .endDeployment(); } - }); - } + } - private static Boolean hasMatchingClusterPermission(CustomResourceInfo cri, - NamedInstallStrategyFluent.SpecNested>> installSpec, - Integer[] ruleIndex, Integer[] clusterPermissionIndex) { - final var hasMatchingClusterPermission = installSpec - .hasMatchingClusterPermission(cp -> { - int i = 0; - for (PolicyRule rule : cp.getRules()) { - if (rule.getApiGroups().contains(cri.getGroup())) { - ruleIndex[0] = i; - return true; - } - i++; + private void handlerRole(String serviceAccountName, Role role, + NamedInstallStrategyFluent.SpecNested>> installSpec) { + if (role != null) { + installSpec + .addNewPermission() + .withServiceAccountName(serviceAccountName) + .addAllToRules(role.getRules()) + .endPermission(); + } + } + + private void handleClusterRole(String serviceAccountName, ClusterRole clusterRole, + Map> groupToCRInfo, + NamedInstallStrategyFluent.SpecNested>> installSpec) { + if (clusterRole != null) { + // check if we have our CR group in the cluster role fragment and remove the one we added + // before since we presume that if the user defined a fragment for permissions associated with their + // CR they want that fragment to take precedence over automatically generated code + final var rules = clusterRole.getRules(); + final var clusterPermissions = installSpec.buildClusterPermissions(); + groupToCRInfo.forEach((group, infos) -> { + + final Predicate hasGroup = pr -> pr.getApiGroups() + .contains(group); + final var nowEmptyPermissions = new LinkedList(); + final var permissionPosition = new Integer[] { 0 }; + if (rules.stream().anyMatch(hasGroup)) { + clusterPermissions.forEach(p -> { + // record the position of all rules that match the group + Integer[] index = new Integer[] { 0 }; + List matchingRuleIndices = new LinkedList<>(); + p.getRules().forEach(r -> { + if (hasGroup.test(r)) { + matchingRuleIndices.add(index[0]); + } + index[0]++; + }); + + // remove the group from all matching rules + matchingRuleIndices.forEach(i -> { + final var groups = p.getRules().get(i).getApiGroups(); + groups.remove(group); + // if the rule doesn't have any groups anymore, remove it + if (groups.isEmpty()) { + p.getRules().remove(i.intValue()); + // if the permission doesn't have any rules anymore, mark it for removal + if (p.getRules().isEmpty()) { + nowEmptyPermissions.add(permissionPosition[0]); + } + } + }); + + permissionPosition[0]++; + }); + + // remove now empty permissions + nowEmptyPermissions.forEach( + i -> clusterPermissions.remove(i.intValue())); + installSpec.addAllToClusterPermissions(clusterPermissions); } - clusterPermissionIndex[0]++; - return false; }); - return hasMatchingClusterPermission; + installSpec + .addNewClusterPermission() + .withServiceAccountName(serviceAccountName) + .addAllToRules(rules) + .endClusterPermission(); + } + } + + public String getControllerName() { + return controllerName; + } } } diff --git a/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVProcessor.java b/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVProcessor.java index 8c078af83..95360959a 100644 --- a/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVProcessor.java +++ b/csv-generator/deployment/src/main/java/io/quarkiverse/operatorsdk/csv/deployment/CSVProcessor.java @@ -3,6 +3,8 @@ import static io.quarkus.kubernetes.deployment.Constants.KUBERNETES; import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Path; import java.util.HashMap; import java.util.List; @@ -28,7 +30,7 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.CombinedIndexBuildItem; -import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.GeneratedFileSystemResourceBuildItem; import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; import io.quarkus.kubernetes.spi.GeneratedKubernetesResourceBuildItem; @@ -64,10 +66,11 @@ CSVMetadataBuildItem gatherCSVMetadata(CombinedIndexBuildItem combinedIndexBuild } @BuildStep - FeatureBuildItem generateCSV(OutputTargetBuildItem outputTarget, + void generateCSV(OutputTargetBuildItem outputTarget, CSVMetadataBuildItem csvMetadata, - BuildProducer ignored, - List generatedKubernetesManifests) { + BuildProducer doneGeneratingCSV, + List generatedKubernetesManifests, + BuildProducer generatedCSVs) { if (configuration.generateCSV.orElse(false)) { try { final var outputDir = outputTarget.getOutputDirectory().resolve(KUBERNETES); @@ -105,16 +108,27 @@ FeatureBuildItem generateCSV(OutputTargetBuildItem outputTarget, } }); }); - CSVGenerator.generate(outputDir, csvMetadata.getAugmentedCustomResourceInfos(), csvMetadata.getCSVMetadata(), - serviceAccountName[0], clusterRole[0], role[0], deployment[0]); - ignored.produce(new GeneratedCSVBuildItem()); + final var generated = CSVGenerator.prepareGeneration( + csvMetadata.getAugmentedCustomResourceInfos(), csvMetadata.getCSVMetadata()); + generated.forEach(namedCSVBuilder -> { + final var fileName = namedCSVBuilder.getFileName(); + try { + generatedCSVs.produce( + new GeneratedFileSystemResourceBuildItem( + Path.of(KUBERNETES, fileName).toString(), + namedCSVBuilder.getYAMLData(serviceAccountName[0], + clusterRole[0], role[0], deployment[0]))); + log.infov("Generating CSV for {0} controller -> {1}", namedCSVBuilder.getControllerName(), + outputDir.resolve(fileName)); + } catch (IOException e) { + log.errorv("Cannot generate CSV for {0}: {1}", namedCSVBuilder.getControllerName(), e.getMessage()); + } + }); + doneGeneratingCSV.produce(new GeneratedCSVBuildItem()); } catch (Exception e) { log.infov(e, "Couldn't generate CSV:"); } } - - // generating a feature is a sure way to make sure this step will be executed by Quarkus - return new FeatureBuildItem("CSVGeneration"); } private CSVMetadataHolder getCSVMetadata(ClassInfo info, String controllerName, IndexView index) { diff --git a/samples/pom.xml b/samples/pom.xml index b8f40def2..d4f4567de 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -38,6 +38,10 @@ io.quarkus quarkus-rest-client-jackson + + io.quarkus + quarkus-openshift + io.quarkiverse.operatorsdk diff --git a/samples/src/main/resources/application.properties b/samples/src/main/resources/application.properties index 3c54a5909..d2750bec3 100644 --- a/samples/src/main/resources/application.properties +++ b/samples/src/main/resources/application.properties @@ -1,4 +1,5 @@ joke-api/mp-rest/url=https://v2.jokeapi.dev/joke joke-api/mp-rest/scope=javax.inject.Singleton quarkus.operator-sdk.crd.apply=true -quarkus.operator-sdk.generate-csv=true \ No newline at end of file +quarkus.operator-sdk.generate-csv=true +quarkus.container-image.builder=jib \ No newline at end of file