diff --git a/operator/controller/src/main/deploy/rbac/cluster/cluster-role.yaml b/operator/controller/src/main/deploy/rbac/cluster/cluster-role.yaml index 5db5b96b24..68e8b52844 100644 --- a/operator/controller/src/main/deploy/rbac/cluster/cluster-role.yaml +++ b/operator/controller/src/main/deploy/rbac/cluster/cluster-role.yaml @@ -29,7 +29,7 @@ rules: - '*' - apiGroups: - - "" + - '' resources: - pods - services @@ -48,4 +48,11 @@ rules: resources: - ingresses verbs: - - "*" + - '*' + + - apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - '*' diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/ApicurioRegistry3Reconciler.java b/operator/controller/src/main/java/io/apicurio/registry/operator/ApicurioRegistry3Reconciler.java index e841261a7b..0356ceadb2 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/ApicurioRegistry3Reconciler.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/ApicurioRegistry3Reconciler.java @@ -4,24 +4,51 @@ import io.apicurio.registry.operator.resource.LabelDiscriminators.AppDeploymentDiscriminator; import io.apicurio.registry.operator.resource.app.AppDeploymentResource; import io.apicurio.registry.operator.resource.app.AppIngressResource; +import io.apicurio.registry.operator.resource.app.AppPodDisruptionBudgetResource; import io.apicurio.registry.operator.resource.app.AppServiceResource; import io.apicurio.registry.operator.resource.studioui.StudioUIDeploymentResource; import io.apicurio.registry.operator.resource.studioui.StudioUIIngressResource; +import io.apicurio.registry.operator.resource.studioui.StudioUIPodDisruptionBudgetResource; import io.apicurio.registry.operator.resource.studioui.StudioUIServiceResource; import io.apicurio.registry.operator.resource.ui.UIDeploymentResource; import io.apicurio.registry.operator.resource.ui.UIIngressResource; +import io.apicurio.registry.operator.resource.ui.UIPodDisruptionBudgetResource; import io.apicurio.registry.operator.resource.ui.UIServiceResource; import io.apicurio.registry.operator.updater.IngressCRUpdater; import io.apicurio.registry.operator.updater.KafkaSqlCRUpdater; import io.apicurio.registry.operator.updater.SqlCRUpdater; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusHandler; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static io.apicurio.registry.operator.resource.ActivationConditions.*; -import static io.apicurio.registry.operator.resource.ResourceKey.*; +import static io.apicurio.registry.operator.resource.ActivationConditions.AppIngressActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.AppPodDisruptionBudgetActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.StudioUIDeploymentActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.StudioUIIngressActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.StudioUIPodDisruptionBudgetActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.UIIngressActivationCondition; +import static io.apicurio.registry.operator.resource.ActivationConditions.UIPodDisruptionBudgetActivationCondition; +import static io.apicurio.registry.operator.resource.ResourceKey.APP_DEPLOYMENT_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.APP_INGRESS_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.APP_POD_DISRUPTION_BUDGET_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.APP_SERVICE_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.STUDIO_UI_DEPLOYMENT_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.STUDIO_UI_INGRESS_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.STUDIO_UI_POD_DISRUPTION_BUDGET_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.STUDIO_UI_SERVICE_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.UI_DEPLOYMENT_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.UI_INGRESS_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.UI_POD_DISRUPTION_BUDGET_ID; +import static io.apicurio.registry.operator.resource.ResourceKey.UI_SERVICE_ID; @ControllerConfiguration( dependents = { @@ -41,6 +68,12 @@ dependsOn = {APP_SERVICE_ID}, activationCondition = AppIngressActivationCondition.class ), + @Dependent( + type = AppPodDisruptionBudgetResource.class, + name = APP_POD_DISRUPTION_BUDGET_ID, + dependsOn = {APP_DEPLOYMENT_ID}, + activationCondition = AppPodDisruptionBudgetActivationCondition.class + ), // ===== Registry UI @Dependent( type = UIDeploymentResource.class, @@ -57,6 +90,12 @@ dependsOn = {UI_SERVICE_ID}, activationCondition = UIIngressActivationCondition.class ), + @Dependent( + type = UIPodDisruptionBudgetResource.class, + name = UI_POD_DISRUPTION_BUDGET_ID, + dependsOn = {UI_DEPLOYMENT_ID}, + activationCondition = UIPodDisruptionBudgetActivationCondition.class + ), // ===== Studio UI @Dependent( type = StudioUIDeploymentResource.class, @@ -73,7 +112,13 @@ name = STUDIO_UI_INGRESS_ID, dependsOn = {STUDIO_UI_SERVICE_ID}, activationCondition = StudioUIIngressActivationCondition.class - ) + ), + @Dependent( + type = StudioUIPodDisruptionBudgetResource.class, + name = STUDIO_UI_POD_DISRUPTION_BUDGET_ID, + dependsOn = {STUDIO_UI_DEPLOYMENT_ID}, + activationCondition = StudioUIPodDisruptionBudgetActivationCondition.class + ), } ) // TODO: When renaming, do not forget to update application.properties (until we have a test for this). diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ActivationConditions.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ActivationConditions.java index 476c110bec..69f6dabc84 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ActivationConditions.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ActivationConditions.java @@ -3,15 +3,21 @@ import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; import io.apicurio.registry.operator.api.v1.ApicurioRegistry3Spec; import io.apicurio.registry.operator.api.v1.spec.AppSpec; +import io.apicurio.registry.operator.api.v1.spec.ComponentSpec; import io.apicurio.registry.operator.api.v1.spec.IngressSpec; +import io.apicurio.registry.operator.api.v1.spec.PodDisruptionSpec; import io.apicurio.registry.operator.api.v1.spec.StudioUiSpec; import io.apicurio.registry.operator.api.v1.spec.UiSpec; import io.apicurio.registry.operator.resource.app.AppIngressResource; +import io.apicurio.registry.operator.resource.app.AppPodDisruptionBudgetResource; import io.apicurio.registry.operator.resource.studioui.StudioUIDeploymentResource; import io.apicurio.registry.operator.resource.studioui.StudioUIIngressResource; +import io.apicurio.registry.operator.resource.studioui.StudioUIPodDisruptionBudgetResource; import io.apicurio.registry.operator.resource.ui.UIIngressResource; +import io.apicurio.registry.operator.resource.ui.UIPodDisruptionBudgetResource; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; @@ -42,6 +48,25 @@ public boolean isMet(DependentResource resource, } } + public static class AppPodDisruptionBudgetActivationCondition + implements Condition { + @Override + public boolean isMet(DependentResource resource, + ApicurioRegistry3 primary, Context context) { + boolean isEnabled = ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp) + .map(ComponentSpec::getPodDisruptionBudget).map(PodDisruptionSpec::getEnabled) + .orElse(Boolean.TRUE); + int numReplicas = ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp) + .map(ComponentSpec::getReplicas).orElse(1); + + boolean isManaged = isEnabled && numReplicas > 1; + if (!isManaged) { + ((AppPodDisruptionBudgetResource) resource).delete(primary, context); + } + return isManaged; + } + } + // ===== Registry UI public static class UIIngressActivationCondition implements Condition { @@ -60,6 +85,21 @@ public boolean isMet(DependentResource resource, } } + public static class UIPodDisruptionBudgetActivationCondition + implements Condition { + @Override + public boolean isMet(DependentResource resource, + ApicurioRegistry3 primary, Context context) { + boolean isManaged = ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getUi) + .map(ComponentSpec::getPodDisruptionBudget).map(PodDisruptionSpec::getEnabled) + .orElse(Boolean.TRUE); + if (!isManaged) { + ((UIPodDisruptionBudgetResource) resource).delete(primary, context); + } + return isManaged; + } + } + // ===== Studio UI public static class StudioUIDeploymentActivationCondition @@ -93,4 +133,20 @@ public boolean isMet(DependentResource resource, return enabled; } } + + public static class StudioUIPodDisruptionBudgetActivationCondition + implements Condition { + @Override + public boolean isMet(DependentResource resource, + ApicurioRegistry3 primary, Context context) { + boolean isManaged = ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getStudioUi) + .map(ComponentSpec::getPodDisruptionBudget).map(PodDisruptionSpec::getEnabled) + .orElse(Boolean.TRUE); + if (!isManaged) { + ((StudioUIPodDisruptionBudgetResource) resource).delete(primary, context); + } + return isManaged; + } + } + } diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/LabelDiscriminators.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/LabelDiscriminators.java index 0f1f01d36c..7cda1f3c6e 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/LabelDiscriminators.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/LabelDiscriminators.java @@ -4,6 +4,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator; import java.util.Map; @@ -53,6 +54,20 @@ public AppIngressDiscriminator() { } } + public static class AppPodDisruptionBudgetDiscriminator extends LabelDiscriminator { + + public static final ResourceDiscriminator INSTANCE = new AppPodDisruptionBudgetDiscriminator(); + + public AppPodDisruptionBudgetDiscriminator() { + // spotless:off + super(Map.of( + "app.kubernetes.io/name", "apicurio-registry", + "app.kubernetes.io/component", COMPONENT_APP + )); + // spotless:on + } + } + // ===== Registry UI public static class UIDeploymentDiscriminator extends LabelDiscriminator { @@ -91,6 +106,20 @@ public UIIngressDiscriminator() { } } + public static class UiPodDisruptionBudgetDiscriminator extends LabelDiscriminator { + + public static final ResourceDiscriminator INSTANCE = new AppPodDisruptionBudgetDiscriminator(); + + public UiPodDisruptionBudgetDiscriminator() { + // spotless:off + super(Map.of( + "app.kubernetes.io/name", "apicurio-registry", + "app.kubernetes.io/component", COMPONENT_UI + )); + // spotless:on + } + } + // ===== Studio UI public static class StudioUIDeploymentDiscriminator extends LabelDiscriminator { @@ -128,4 +157,20 @@ public StudioUIIngressDiscriminator() { )); } } + + public static class StudioUiPodDisruptionBudgetDiscriminator + extends LabelDiscriminator { + + public static final ResourceDiscriminator INSTANCE = new AppPodDisruptionBudgetDiscriminator(); + + public StudioUiPodDisruptionBudgetDiscriminator() { + // spotless:off + super(Map.of( + "app.kubernetes.io/name", "apicurio-registry", + "app.kubernetes.io/component", COMPONENT_STUDIO_UI + )); + // spotless:on + } + } + } diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java index 55402bd322..3a56806ef1 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java @@ -12,6 +12,7 @@ import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; import java.nio.charset.Charset; import java.util.ArrayList; @@ -37,6 +38,7 @@ public class ResourceFactory { public static final String RESOURCE_TYPE_DEPLOYMENT = "deployment"; public static final String RESOURCE_TYPE_SERVICE = "service"; public static final String RESOURCE_TYPE_INGRESS = "ingress"; + public static final String RESOURCE_TYPE_POD_DISRUPTION_BUDGET = "poddisruptionbudget"; public Deployment getDefaultAppDeployment(ApicurioRegistry3 primary) { var r = initDefaultDeployment(primary, COMPONENT_APP, @@ -223,6 +225,30 @@ public Ingress getDefaultStudioUIIngress(ApicurioRegistry3 primary) { return r; } + public PodDisruptionBudget getDefaultAppPodDisruptionBudget(ApicurioRegistry3 primary) { + var pdb = getDefaultResource(primary, PodDisruptionBudget.class, RESOURCE_TYPE_POD_DISRUPTION_BUDGET, + COMPONENT_APP); + pdb.getSpec().getSelector().getMatchLabels().put("app.kubernetes.io/instance", + primary.getMetadata().getName()); + return pdb; + } + + public PodDisruptionBudget getDefaultUIPodDisruptionBudget(ApicurioRegistry3 primary) { + var pdb = getDefaultResource(primary, PodDisruptionBudget.class, RESOURCE_TYPE_POD_DISRUPTION_BUDGET, + COMPONENT_UI); + pdb.getSpec().getSelector().getMatchLabels().put("app.kubernetes.io/instance", + primary.getMetadata().getName()); + return pdb; + } + + public PodDisruptionBudget getDefaultStudioUIPodDisruptionBudget(ApicurioRegistry3 primary) { + var pdb = getDefaultResource(primary, PodDisruptionBudget.class, RESOURCE_TYPE_POD_DISRUPTION_BUDGET, + COMPONENT_STUDIO_UI); + pdb.getSpec().getSelector().getMatchLabels().put("app.kubernetes.io/instance", + primary.getMetadata().getName()); + return pdb; + } + private T getDefaultResource(ApicurioRegistry3 primary, Class klass, String resourceType, String component) { var r = deserialize("/k8s/default/" + component + "." + resourceType + ".yaml", klass); diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceKey.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceKey.java index 786e4053f9..c3699a8d7c 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceKey.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceKey.java @@ -6,6 +6,7 @@ import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; import io.javaoperatorsdk.operator.api.reconciler.ResourceDiscriminator; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -27,14 +28,17 @@ public class ResourceKey { public static final String APP_DEPLOYMENT_ID = "AppDeploymentResource"; public static final String APP_SERVICE_ID = "AppServiceResource"; public static final String APP_INGRESS_ID = "AppIngressResource"; + public static final String APP_POD_DISRUPTION_BUDGET_ID = "AppPodDisruptionBudgetResource"; public static final String UI_DEPLOYMENT_ID = "UIDeploymentResource"; public static final String UI_SERVICE_ID = "UIServiceResource"; public static final String UI_INGRESS_ID = "UIIngressResource"; + public static final String UI_POD_DISRUPTION_BUDGET_ID = "UIPodDisruptionBudgetResource"; public static final String STUDIO_UI_DEPLOYMENT_ID = "StudioUIDeploymentResource"; public static final String STUDIO_UI_SERVICE_ID = "StudioUIServiceResource"; public static final String STUDIO_UI_INGRESS_ID = "StudioUIIngressResource"; + public static final String STUDIO_UI_POD_DISRUPTION_BUDGET_ID = "StudioUIPodDisruptionBudgetResource"; public static final ResourceKey REGISTRY_KEY = new ResourceKey<>( REGISTRY_ID, ApicurioRegistry3.class, @@ -58,6 +62,11 @@ public class ResourceKey { AppIngressDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultAppIngress ); + public static final ResourceKey APP_POD_DISRUPTION_BUDGET_KEY = new ResourceKey<>( + APP_POD_DISRUPTION_BUDGET_ID, PodDisruptionBudget.class, + AppPodDisruptionBudgetDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultAppPodDisruptionBudget + ); + // ===== Registry UI public static final ResourceKey UI_DEPLOYMENT_KEY = new ResourceKey<>( @@ -75,6 +84,11 @@ public class ResourceKey { UIIngressDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultUIIngress ); + public static final ResourceKey UI_POD_DISRUPTION_BUDGET_KEY = new ResourceKey<>( + UI_POD_DISRUPTION_BUDGET_ID, PodDisruptionBudget.class, + UiPodDisruptionBudgetDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultUIPodDisruptionBudget + ); + // ===== Studio UI public static final ResourceKey STUDIO_UI_DEPLOYMENT_KEY = new ResourceKey<>( @@ -92,6 +106,10 @@ public class ResourceKey { StudioUIIngressDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultStudioUIIngress ); + public static final ResourceKey STUDIO_UI_POD_DISRUPTION_BUDGET_KEY = new ResourceKey<>( + STUDIO_UI_POD_DISRUPTION_BUDGET_ID, PodDisruptionBudget.class, + StudioUiPodDisruptionBudgetDiscriminator.INSTANCE, ResourceFactory.INSTANCE::getDefaultStudioUIPodDisruptionBudget + ); @EqualsAndHashCode.Include @ToString.Include diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppPodDisruptionBudgetResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppPodDisruptionBudgetResource.java new file mode 100644 index 0000000000..8d865b396a --- /dev/null +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppPodDisruptionBudgetResource.java @@ -0,0 +1,31 @@ +package io.apicurio.registry.operator.resource.app; + +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.resource.LabelDiscriminators; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_APP; +import static io.apicurio.registry.operator.resource.ResourceKey.APP_POD_DISRUPTION_BUDGET_KEY; + +// spotless:off +@KubernetesDependent( + labelSelector = "app.kubernetes.io/name=apicurio-registry,app.kubernetes.io/component=" + COMPONENT_APP, + resourceDiscriminator = LabelDiscriminators.AppPodDisruptionBudgetDiscriminator.class +) +// spotless:on +public class AppPodDisruptionBudgetResource + extends CRUDKubernetesDependentResource { + + public AppPodDisruptionBudgetResource() { + super(PodDisruptionBudget.class); + } + + @Override + protected PodDisruptionBudget desired(ApicurioRegistry3 primary, Context context) { + PodDisruptionBudget pdb = APP_POD_DISRUPTION_BUDGET_KEY.getFactory().apply(primary); + return pdb; + } +} diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/studioui/StudioUIPodDisruptionBudgetResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/studioui/StudioUIPodDisruptionBudgetResource.java new file mode 100644 index 0000000000..94d7fe8dfa --- /dev/null +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/studioui/StudioUIPodDisruptionBudgetResource.java @@ -0,0 +1,31 @@ +package io.apicurio.registry.operator.resource.studioui; + +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.resource.LabelDiscriminators; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_STUDIO_UI; +import static io.apicurio.registry.operator.resource.ResourceKey.STUDIO_UI_POD_DISRUPTION_BUDGET_KEY; + +// spotless:off +@KubernetesDependent( + labelSelector = "app.kubernetes.io/name=apicurio-registry,app.kubernetes.io/component=" + COMPONENT_STUDIO_UI, + resourceDiscriminator = LabelDiscriminators.StudioUiPodDisruptionBudgetDiscriminator.class +) +// spotless:on +public class StudioUIPodDisruptionBudgetResource + extends CRUDKubernetesDependentResource { + + public StudioUIPodDisruptionBudgetResource() { + super(PodDisruptionBudget.class); + } + + @Override + protected PodDisruptionBudget desired(ApicurioRegistry3 primary, Context context) { + PodDisruptionBudget pdb = STUDIO_UI_POD_DISRUPTION_BUDGET_KEY.getFactory().apply(primary); + return pdb; + } +} diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIPodDisruptionBudgetResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIPodDisruptionBudgetResource.java new file mode 100644 index 0000000000..3bfb982e09 --- /dev/null +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIPodDisruptionBudgetResource.java @@ -0,0 +1,31 @@ +package io.apicurio.registry.operator.resource.ui; + +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.resource.LabelDiscriminators; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_UI; +import static io.apicurio.registry.operator.resource.ResourceKey.UI_POD_DISRUPTION_BUDGET_KEY; + +// spotless:off +@KubernetesDependent( + labelSelector = "app.kubernetes.io/name=apicurio-registry,app.kubernetes.io/component=" + COMPONENT_UI, + resourceDiscriminator = LabelDiscriminators.UiPodDisruptionBudgetDiscriminator.class +) +// spotless:on +public class UIPodDisruptionBudgetResource + extends CRUDKubernetesDependentResource { + + public UIPodDisruptionBudgetResource() { + super(PodDisruptionBudget.class); + } + + @Override + protected PodDisruptionBudget desired(ApicurioRegistry3 primary, Context context) { + PodDisruptionBudget pdb = UI_POD_DISRUPTION_BUDGET_KEY.getFactory().apply(primary); + return pdb; + } +} diff --git a/operator/controller/src/main/resources/k8s/default/app.poddisruptionbudget.yaml b/operator/controller/src/main/resources/k8s/default/app.poddisruptionbudget.yaml new file mode 100644 index 0000000000..54dfe73837 --- /dev/null +++ b/operator/controller/src/main/resources/k8s/default/app.poddisruptionbudget.yaml @@ -0,0 +1,9 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: { } +spec: + maxUnavailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: apicurio-registry + app.kubernetes.io/component: app diff --git a/operator/controller/src/main/resources/k8s/default/studio-ui.poddisruptionbudget.yaml b/operator/controller/src/main/resources/k8s/default/studio-ui.poddisruptionbudget.yaml new file mode 100644 index 0000000000..aaa6c69e0c --- /dev/null +++ b/operator/controller/src/main/resources/k8s/default/studio-ui.poddisruptionbudget.yaml @@ -0,0 +1,9 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: { } +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: apicurio-registry + app.kubernetes.io/component: studio-ui diff --git a/operator/controller/src/main/resources/k8s/default/ui.poddisruptionbudget.yaml b/operator/controller/src/main/resources/k8s/default/ui.poddisruptionbudget.yaml new file mode 100644 index 0000000000..89ec0508dd --- /dev/null +++ b/operator/controller/src/main/resources/k8s/default/ui.poddisruptionbudget.yaml @@ -0,0 +1,9 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: { } +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: apicurio-registry + app.kubernetes.io/component: ui diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/it/ITBase.java b/operator/controller/src/test/java/io/apicurio/registry/operator/it/ITBase.java index ba385a3972..314eee395d 100644 --- a/operator/controller/src/test/java/io/apicurio/registry/operator/it/ITBase.java +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/it/ITBase.java @@ -6,6 +6,7 @@ import io.fabric8.kubernetes.api.model.NamespaceBuilder; import io.fabric8.kubernetes.api.model.Pod; import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; import io.fabric8.kubernetes.client.Config; import io.fabric8.kubernetes.client.ConfigBuilder; import io.fabric8.kubernetes.client.KubernetesClient; @@ -20,7 +21,11 @@ import jakarta.enterprise.util.TypeLiteral; import org.awaitility.Awaitility; import org.eclipse.microprofile.config.ConfigProvider; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -155,6 +160,21 @@ protected static void checkIngressDoesNotExist(ApicurioRegistry3 primary, String }); } + protected static PodDisruptionBudget checkPodDisruptionBudgetExists(ApicurioRegistry3 primary, + String component) { + final ValueOrNull rval = new ValueOrNull<>(); + + await().ignoreExceptions().untilAsserted(() -> { + PodDisruptionBudget pdb = client.policy().v1().podDisruptionBudget() + .withName(primary.getMetadata().getName() + "-" + component + "-poddisruptionbudget") + .get(); + assertThat(pdb).isNotNull(); + rval.setValue(pdb); + }); + + return rval.getValue(); + } + static KubernetesClient createK8sClient(String namespace) { return new KubernetesClientBuilder() .withConfig(new ConfigBuilder(Config.autoConfigure(null)).withNamespace(namespace).build()) @@ -286,4 +306,23 @@ public static void after() throws Exception { } client.close(); } + + private static class ValueOrNull { + private T value; + + ValueOrNull() { + } + + ValueOrNull(T value) { + this.value = value; + } + + public void setValue(T value) { + this.value = value; + } + + public T getValue() { + return value; + } + } } diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/it/PodDisruptionBudgetITTest.java b/operator/controller/src/test/java/io/apicurio/registry/operator/it/PodDisruptionBudgetITTest.java new file mode 100644 index 0000000000..803bd585dc --- /dev/null +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/it/PodDisruptionBudgetITTest.java @@ -0,0 +1,75 @@ +package io.apicurio.registry.operator.it; + +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.resource.ResourceFactory; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudget; +import io.fabric8.kubernetes.api.model.policy.v1.PodDisruptionBudgetStatus; +import io.quarkus.test.junit.QuarkusTest; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.stream.Collectors; + +@QuarkusTest +public class PodDisruptionBudgetITTest extends ITBase { + + private static final Logger log = LoggerFactory.getLogger(PodDisruptionBudgetITTest.class); + + @Test + void testPodDisruptionBudget() { + ApicurioRegistry3 registry = ResourceFactory.deserialize( + "/k8s/examples/simple-with-studio.apicurioregistry3.yaml", ApicurioRegistry3.class); + registry.getSpec().getApp().setReplicas(2); + client.resource(registry).create(); + + // Wait for the deployment to exist + checkDeploymentExists(registry, ResourceFactory.COMPONENT_APP, 2); + + // Check that the two expected PodDisruptionBudget resources were created + PodDisruptionBudget appPDB = checkPodDisruptionBudgetExists(registry, ResourceFactory.COMPONENT_APP); + PodDisruptionBudget uiPDB = checkPodDisruptionBudgetExists(registry, ResourceFactory.COMPONENT_UI); + PodDisruptionBudget studioPDB = checkPodDisruptionBudgetExists(registry, + ResourceFactory.COMPONENT_STUDIO_UI); + + // Verify the content of the app component's PDB + assertLabelsContains(appPDB.getMetadata().getLabels(), "app.kubernetes.io/component=app", + "app.kubernetes.io/managed-by=apicurio-registry-operator", + "app.kubernetes.io/name=apicurio-registry"); + assertLabelsContains(appPDB.getSpec().getSelector().getMatchLabels(), + "app.kubernetes.io/component=app", "app.kubernetes.io/name=apicurio-registry", + "app.kubernetes.io/instance=" + registry.getMetadata().getName()); + PodDisruptionBudgetStatus appPdbStatus = appPDB.getStatus(); + Assertions.assertThat(appPdbStatus.getExpectedPods()).isEqualTo(2); + Assertions.assertThat(appPdbStatus.getDisruptionsAllowed()).isEqualTo(1); + + // Verify the content of the ui component's PDB + assertLabelsContains(uiPDB.getMetadata().getLabels(), "app.kubernetes.io/component=ui", + "app.kubernetes.io/managed-by=apicurio-registry-operator", + "app.kubernetes.io/name=apicurio-registry"); + assertLabelsContains(uiPDB.getSpec().getSelector().getMatchLabels(), "app.kubernetes.io/component=ui", + "app.kubernetes.io/name=apicurio-registry", + "app.kubernetes.io/instance=" + registry.getMetadata().getName()); + PodDisruptionBudgetStatus uiPdbStatus = uiPDB.getStatus(); + Assertions.assertThat(uiPdbStatus.getExpectedPods()).isEqualTo(1); + Assertions.assertThat(uiPdbStatus.getDisruptionsAllowed()).isEqualTo(0); + + // Verify the content of the studio component's PDB + assertLabelsContains(studioPDB.getMetadata().getLabels(), "app.kubernetes.io/component=studio-ui", + "app.kubernetes.io/managed-by=apicurio-registry-operator", + "app.kubernetes.io/name=apicurio-registry"); + assertLabelsContains(studioPDB.getSpec().getSelector().getMatchLabels(), + "app.kubernetes.io/component=studio-ui", "app.kubernetes.io/name=apicurio-registry", + "app.kubernetes.io/instance=" + registry.getMetadata().getName()); + PodDisruptionBudgetStatus studioPdbStatus = studioPDB.getStatus(); + Assertions.assertThat(studioPdbStatus.getExpectedPods()).isEqualTo(1); + Assertions.assertThat(studioPdbStatus.getDisruptionsAllowed()).isEqualTo(0); + } + + private void assertLabelsContains(Map labels, String... values) { + Assertions.assertThat(labels.entrySet().stream().map(l -> l.getKey() + "=" + l.getValue()) + .collect(Collectors.toSet())).contains(values); + } +} diff --git a/operator/install/install.yaml b/operator/install/install.yaml index a1be0c3854..4438491914 100644 --- a/operator/install/install.yaml +++ b/operator/install/install.yaml @@ -117,6 +117,17 @@ spec: automatically.' type: string type: object + podDisruptionBudget: + description: | + Configuration of a PodDisruptionBudget for the component. + properties: + enabled: + description: | + Whether a PodDisruptionBudget should be managed by the operator. Defaults to 'true'. + + Set this to 'false' if you want to create your own custom PodDisruptionBudget. + type: boolean + type: object podTemplateSpec: description: |- `PodTemplateSpec` describes the data a pod should have when created from a template. @@ -3254,6 +3265,17 @@ spec: IMPORTANT: If the Ingress already exists and the value becomes empty, the Ingress will be deleted. type: string type: object + podDisruptionBudget: + description: | + Configuration of a PodDisruptionBudget for the component. + properties: + enabled: + description: | + Whether a PodDisruptionBudget should be managed by the operator. Defaults to 'true'. + + Set this to 'false' if you want to create your own custom PodDisruptionBudget. + type: boolean + type: object podTemplateSpec: description: |- `PodTemplateSpec` describes the data a pod should have when created from a template. @@ -6243,6 +6265,17 @@ spec: IMPORTANT: If the Ingress already exists and the value becomes empty, the Ingress will be deleted. type: string type: object + podDisruptionBudget: + description: | + Configuration of a PodDisruptionBudget for the component. + properties: + enabled: + description: | + Whether a PodDisruptionBudget should be managed by the operator. Defaults to 'true'. + + Set this to 'false' if you want to create your own custom PodDisruptionBudget. + type: boolean + type: object podTemplateSpec: description: |- `PodTemplateSpec` describes the data a pod should have when created from a template. @@ -9299,6 +9332,12 @@ rules: - ingresses verbs: - '*' +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - '*' --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/ComponentSpec.java b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/ComponentSpec.java index c29c2107d9..ee000e06d5 100644 --- a/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/ComponentSpec.java +++ b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/ComponentSpec.java @@ -19,7 +19,7 @@ @JsonDeserialize(using = None.class) @JsonInclude(NON_NULL) -@JsonPropertyOrder({ "env", "ingress", "host", "podTemplateSpec" }) +@JsonPropertyOrder({ "env", "ingress", "host", "podTemplateSpec", "podDisruptionBudget" }) @NoArgsConstructor(access = PROTECTED) @AllArgsConstructor(access = PROTECTED) @SuperBuilder(toBuilder = true) @@ -98,4 +98,15 @@ public IngressSpec withIngress() { } return ingress; } + + /** + * Pod disruption budget config + */ + @JsonProperty("podDisruptionBudget") + @JsonPropertyDescription(""" + Configuration of a PodDisruptionBudget for the component. + """) + @JsonSetter(nulls = Nulls.SKIP) + private PodDisruptionSpec podDisruptionBudget; + } diff --git a/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/PodDisruptionSpec.java b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/PodDisruptionSpec.java new file mode 100644 index 0000000000..0a47106110 --- /dev/null +++ b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/PodDisruptionSpec.java @@ -0,0 +1,46 @@ +package io.apicurio.registry.operator.api.v1.spec; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.databind.JsonDeserializer.None; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; +import static lombok.AccessLevel.PRIVATE; + +@JsonDeserialize(using = None.class) +@JsonInclude(NON_NULL) +@JsonPropertyOrder({ "enabled" }) +@NoArgsConstructor +@AllArgsConstructor(access = PRIVATE) +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@EqualsAndHashCode +@ToString +public class PodDisruptionSpec { + + /** + * Indicates whether to create and manage a pod disruption budget + */ + @JsonProperty("enabled") + @JsonPropertyDescription(""" + Whether a PodDisruptionBudget should be managed by the operator. Defaults to 'true'. + + Set this to 'false' if you want to create your own custom PodDisruptionBudget. + """) + @JsonSetter(nulls = Nulls.SKIP) + private Boolean enabled; + +}