diff --git a/Dockerfile b/Dockerfile index 19dcb851..497dfb1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,10 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# cache deps before building and copying source so that we don't need to re-download as much -# and so that source changes don't invalidate our downloaded layer -RUN go mod download +# Deps are downloaded as part of the build step below, not in a separate "go mod download" +# layer: go.mod lists the private sveltos-enterprise module (needed only for `-tags +# enterprise` builds), and unlike `go build`, bare `go mod download` is not build-tag-aware +# and would eagerly try to resolve it even for this default (non-enterprise) build. # Copy the go source COPY cmd/main.go cmd/main.go diff --git a/Dockerfile.enterprise b/Dockerfile.enterprise new file mode 100644 index 00000000..caf50610 --- /dev/null +++ b/Dockerfile.enterprise @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.4 + +# Build the manager binary (Enterprise build - links in the private sveltos-enterprise module) +FROM golang:1.26.5 AS builder + +ARG BUILDOS +ARG TARGETARCH + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum + +# github.com/projectsveltos/sveltos-enterprise is a private module. Unlike the default +# Dockerfile, this build always needs it (it's the enterprise variant), so it's fetched here +# via git over SSH, authenticated with a key forwarded from the host through BuildKit's ssh +# mount (see Makefile's docker-buildx target: --ssh default=). +RUN git config --global url."git@github.com:".insteadOf "https://github.com/" && \ + mkdir -p -m 0700 /root/.ssh && ssh-keyscan github.com >> /root/.ssh/known_hosts +RUN --mount=type=ssh GOPRIVATE=github.com/projectsveltos/sveltos-enterprise go mod download + +# Copy the go source +COPY cmd/main.go cmd/main.go +COPY api/ api/ +COPY lib/ lib/ +COPY controllers/ controllers/ +COPY pkg/ pkg/ +COPY internal/ internal/ + +# Build +RUN CGO_ENABLED=0 GOOS=$BUILDOS GOARCH=$TARGETARCH go build -tags enterprise -a -o manager cmd/main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot + +ARG GIT_VERSION=unknown + +LABEL org.opencontainers.image.source="https://github.com/projectsveltos/addon-controller" \ + org.opencontainers.image.url="https://projectsveltos.io" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.vendor="projectsveltos" \ + org.opencontainers.image.title="addon-controller" \ + org.opencontainers.image.description="Deploys Kubernetes add-ons and applications (Helm charts, Kustomize, raw YAML) across fleets of clusters, with built-in multi-tenancy support." \ + org.opencontainers.image.version="$GIT_VERSION" \ + org.opencontainers.image.revision="$GIT_VERSION" + +WORKDIR / +COPY --from=builder /workspace/manager . +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/Dockerfile_WithGit b/Dockerfile_WithGit index 36e56d1d..40a39939 100644 --- a/Dockerfile_WithGit +++ b/Dockerfile_WithGit @@ -8,9 +8,10 @@ WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum -# cache deps before building and copying source so that we don't need to re-download as much -# and so that source changes don't invalidate our downloaded layer -RUN go mod download +# Deps are downloaded as part of the build step below, not in a separate "go mod download" +# layer: go.mod lists the private sveltos-enterprise module (needed only for `-tags +# enterprise` builds), and unlike `go build`, bare `go mod download` is not build-tag-aware +# and would eagerly try to resolve it even for this default (non-enterprise) build. # Copy the go source COPY cmd/main.go cmd/main.go diff --git a/Dockerfile_WithGit.enterprise b/Dockerfile_WithGit.enterprise new file mode 100644 index 00000000..4d529f11 --- /dev/null +++ b/Dockerfile_WithGit.enterprise @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.4 + +# Build the manager binary (Enterprise build - links in the private sveltos-enterprise module) +FROM golang:1.26.5 AS builder + +ARG BUILDOS +ARG TARGETARCH + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum + +# github.com/projectsveltos/sveltos-enterprise is a private module. Unlike the default +# Dockerfile, this build always needs it (it's the enterprise variant), so it's fetched here +# via git over SSH, authenticated with a key forwarded from the host through BuildKit's ssh +# mount (see Makefile's docker-buildx target: --ssh default=). +RUN git config --global url."git@github.com:".insteadOf "https://github.com/" && \ + mkdir -p -m 0700 /root/.ssh && ssh-keyscan github.com >> /root/.ssh/known_hosts +RUN --mount=type=ssh GOPRIVATE=github.com/projectsveltos/sveltos-enterprise go mod download + +# Copy the go source +COPY cmd/main.go cmd/main.go +COPY api/ api/ +COPY lib/ lib/ +COPY controllers/ controllers/ +COPY pkg/ pkg/ +COPY internal/ internal/ + +# Build +RUN CGO_ENABLED=0 GOOS=$BUILDOS GOARCH=$TARGETARCH go build -tags enterprise -a -o manager cmd/main.go + +# This is needed to support kustomization that points to a oci/git repo that utilizes remote kustomization references. +FROM alpine + +ARG GIT_VERSION=unknown + +LABEL org.opencontainers.image.source="https://github.com/projectsveltos/addon-controller" \ + org.opencontainers.image.url="https://projectsveltos.io" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.vendor="projectsveltos" \ + org.opencontainers.image.title="addon-controller" \ + org.opencontainers.image.description="Deploys Kubernetes add-ons and applications (Helm charts, Kustomize, raw YAML) across fleets of clusters, with built-in multi-tenancy support. This variant bundles git for Kustomize remote-reference support." \ + org.opencontainers.image.version="$GIT_VERSION" \ + org.opencontainers.image.revision="$GIT_VERSION" + +RUN apk add --no-cache git +WORKDIR / +COPY --from=builder /workspace/manager . +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile index 1a263a79..df9277f7 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,11 @@ K8S_LATEST_VER ?= $(shell curl -s https://dl.k8s.io/release/stable.txt) export CONTROLLER_IMG ?= $(REGISTRY)/$(IMAGE_NAME) TAG ?= v1.13.0 +# SSH key with read access to the private github.com/projectsveltos/sveltos-enterprise repo, +# forwarded into the enterprise docker-buildx build (see Dockerfile.enterprise) so it can +# fetch that module. Override with e.g. `make docker-buildx SVELTOS_ENTERPRISE_SSH_KEY=~/.ssh/other_key`. +SVELTOS_ENTERPRISE_SSH_KEY ?= $(HOME)/.ssh/id_ed25519 + .PHONY: all all: build @@ -542,9 +547,9 @@ docker-push: ## Push docker image with the manager. docker push $(CONTROLLER_IMG):$(TAG) .PHONY: docker-buildx -docker-buildx: ## docker build for multiple arch and push to docker hub - docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg GIT_VERSION=$(TAG) -t $(CONTROLLER_IMG):$(TAG) . - docker buildx build --push --platform linux/amd64,linux/arm64 --build-arg GIT_VERSION=$(TAG) -t $(CONTROLLER_IMG)-git:$(TAG) -f Dockerfile_WithGit . +docker-buildx: ## docker build for multiple arch and push to docker hub (enterprise build - requires SSH access to sveltos-enterprise) + docker buildx build --push --platform linux/amd64,linux/arm64 --ssh default=$(SVELTOS_ENTERPRISE_SSH_KEY) --build-arg GIT_VERSION=$(TAG) -t $(CONTROLLER_IMG):$(TAG) -f Dockerfile.enterprise . + docker buildx build --push --platform linux/amd64,linux/arm64 --ssh default=$(SVELTOS_ENTERPRISE_SSH_KEY) --build-arg GIT_VERSION=$(TAG) -t $(CONTROLLER_IMG)-git:$(TAG) -f Dockerfile_WithGit.enterprise . .PHONY: load-image load-image: docker-build $(KIND) diff --git a/controllers/clusterpromotion_controller.go b/controllers/clusterpromotion_controller.go index d9f8c38f..a0167efe 100644 --- a/controllers/clusterpromotion_controller.go +++ b/controllers/clusterpromotion_controller.go @@ -17,19 +17,11 @@ limitations under the License. package controllers import ( - "bytes" "context" - "crypto/sha256" - "encoding/json" "fmt" - "sort" - "time" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" @@ -41,26 +33,43 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/go-logr/logr" - "github.com/pkg/errors" - "github.com/robfig/cron" configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" "github.com/projectsveltos/addon-controller/pkg/scope" - libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" - "github.com/projectsveltos/libsveltos/lib/k8s_utils" - license "github.com/projectsveltos/libsveltos/lib/licenses" logs "github.com/projectsveltos/libsveltos/lib/logsettings" ) +const ( + // maxFreeClusterPromotionStages is how many ClusterPromotion instances remain usable + // without a license, absent a valid license (top-X by creation order). + maxFreeClusterPromotionStages = 2 +) + +// clusterPromotionEnterpriseDeps bundles what the Sveltos Enterprise ClusterPromotion +// implementation needs. Defined here, not in sveltos-enterprise, so this (open source) +// package can declare the plugin seam below without importing anything private. +type clusterPromotionEnterpriseDeps struct { + Client client.Client + Config *rest.Config + Scheme *runtime.Scheme + EventRecorder events.EventRecorder + SveltosNamespace string +} + var ( - clusterPromotionNameLabel = "config.projectsveltos.io/promotionname" - stageNameAnnotation = "config.projectsveltos.io/stagename" - preVerificationClusterProfileLabel = "config.projectsveltos.io/promotion-verification" + // reconcileClusterPromotionNormal implements ClusterPromotion's stage-advancement business + // logic (a Sveltos Enterprise feature). A default (non-"enterprise" tagged) build of this + // package cannot import the private sveltos-enterprise module at all, so it wires this to + // a stub that reports the feature is unavailable (see clusterpromotion_oss.go). Official + // Sveltos images are built with `-tags enterprise` and a checkout of sveltos-enterprise + // available, which wires this to the real implementation instead (see + // clusterpromotion_plugin.go). Either way, this file never imports sveltos-enterprise. + reconcileClusterPromotionNormal func(ctx context.Context, deps clusterPromotionEnterpriseDeps, + promotionScope *scope.ClusterPromotionScope, isInFreeTopX bool, logger logr.Logger) reconcile.Result ) -const ( - // normalStageRequeueAfter is the default time to wait before re-queuing the ClusterPromotion - normalStageRequeueAfter = 2 * time.Minute +var ( + clusterPromotionNameLabel = "config.projectsveltos.io/promotionname" ) // ClusterPromotionReconciler reconciles a ClusterPromotion object @@ -156,12 +165,15 @@ func (r *ClusterPromotionReconciler) reconcileDelete( return reconcile.Result{} } +// reconcileNormal ensures the finalizer is present, then delegates ClusterPromotion's +// stage-advancement business logic (and its license entitlement check) to the Sveltos +// Enterprise library. isInFreeTopX is computed here, since only addon-controller tracks +// every ClusterPromotion instance across reconciles. func (r *ClusterPromotionReconciler) reconcileNormal( ctx context.Context, promotionScope *scope.ClusterPromotionScope) reconcile.Result { logger := promotionScope.Logger - logger.V(logs.LogDebug).Info("Reconciling ClusterPromotion") if !controllerutil.ContainsFinalizer(promotionScope.ClusterPromotion, configv1beta1.ClusterPromotionFinalizer) { if err := r.addFinalizer(ctx, promotionScope); err != nil { @@ -170,135 +182,18 @@ func (r *ClusterPromotionReconciler) reconcileNormal( } } - isEligible, err := r.verifyStageEligibility(ctx, promotionScope, logger) - if err != nil { - return reconcile.Result{Requeue: true, RequeueAfter: licenseRequeueAfter} - } - - if !isEligible { - logger.V(logs.LogInfo).Info("license is required") - r.updateStatusWithMissingLicenseError(promotionScope) - return reconcile.Result{} - } - - err = r.removeStaleClusterProfiles(ctx, promotionScope) - if err != nil { - promotionScope.Error(err, "Failed to remove stale ClusterProfiles") - return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter} - } - - // Checks whether the **Stage** configuration (e.g., cluster selector, trigger) - // or the **ClusterProfile** resource applied by the current stage has changed. - // In either case, a restart of the promotion process is necessary. - restart, newProfileSpecHash, newStagesHash, err := r.needsPromotionRestart(promotionScope) - if err != nil { - promotionScope.Error(err, "Failed to check if promotion needs restart") - return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter} - } - - r.setPromotionHashesInStatus(promotionScope, newProfileSpecHash, newStagesHash) - - if restart { - return r.handlePromotionRestart(ctx, promotionScope, logger) - } - - currentStageName := getCurrentStage(promotionScope.ClusterPromotion) - l := logger.WithValues("stage", currentStageName) - if !r.isStageProvisioned(promotionScope.ClusterPromotion, currentStageName) { - deployed, message, err := r.checkCurrentStageDeployment(ctx, promotionScope.ClusterPromotion, l) - if err != nil { - promotionScope.Error(err, "Failed to verify current stage deployment status") - return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter} - } - if !deployed { - updateStageStatus(promotionScope.ClusterPromotion, currentStageName, false, &message) - return reconcile.Result{Requeue: true, RequeueAfter: normalStageRequeueAfter} - } - - l.V(logs.LogDebug).Info(fmt.Sprintf("Stage %s provisioned", currentStageName)) - r.eventRecorder.Eventf(promotionScope.ClusterPromotion, nil, corev1.EventTypeNormal, "Sveltos", - configv1beta1.ClusterPromotionKind, fmt.Sprintf("Stage %s provisioned", currentStageName)) - // Stage is successfully deployed - updateStageStatus(promotionScope.ClusterPromotion, currentStageName, true, nil) - updateStageDescription(promotionScope.ClusterPromotion, currentStageName, message) - } - - return r.advanceToNextStage(ctx, promotionScope, l) -} - -// handlePromotionRestart restarts the promotion process from Stage 1. -func (r *ClusterPromotionReconciler) handlePromotionRestart(ctx context.Context, - promotionScope *scope.ClusterPromotionScope, logger logr.Logger) reconcile.Result { - - // The minimum length of stages is 1 so accessing the first stage is safe - firstStage := promotionScope.ClusterPromotion.Spec.Stages[0] - - l := logger.WithValues("stage", firstStage.Name) - - if err := r.reconcileStageProfile(ctx, promotionScope, &firstStage, l); err != nil { - return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter} - } - - r.eventRecorder.Eventf(promotionScope.ClusterPromotion, nil, corev1.EventTypeNormal, "Sveltos", - configv1beta1.ClusterPromotionKind, fmt.Sprintf("Provisioning stage %s (Restart)", firstStage.Name)) + isInFreeTopX := GetLicenseManager().IsClusterPromotionInTopX("", promotionScope.ClusterPromotion.Name, + maxFreeClusterPromotionStages) - resetStageStatuses(promotionScope.ClusterPromotion) - addStageStatus(promotionScope.ClusterPromotion, firstStage.Name) - updateStageDescription(promotionScope.ClusterPromotion, firstStage.Name, - "Clusters are being provisioned") - - return reconcile.Result{Requeue: true, RequeueAfter: normalStageRequeueAfter} -} - -// advanceToNextStage checks if promotion criteria are met and moves to the next stage if possible. -func (r *ClusterPromotionReconciler) advanceToNextStage(ctx context.Context, - promotionScope *scope.ClusterPromotionScope, logger logr.Logger) reconcile.Result { - - currentStageName := getCurrentStage(promotionScope.ClusterPromotion) - - // Current Stage is provisioned. Evaluates whether moving to next stage is needed. - canAdvance, err := r.doMoveToNextStage(ctx, promotionScope.ClusterPromotion, logger) - if err != nil { - return reconcile.Result{Requeue: true, RequeueAfter: normalStageRequeueAfter} + deps := clusterPromotionEnterpriseDeps{ + Client: r.Client, + Config: r.Config, + Scheme: r.Scheme, + EventRecorder: r.eventRecorder, + SveltosNamespace: getSveltosNamespace(), } - if !canAdvance { - // Not ready to advance, requeue and wait. - return reconcile.Result{Requeue: true, RequeueAfter: normalStageRequeueAfter} - } - - updateStageStatus(promotionScope.ClusterPromotion, currentStageName, true, nil) - updateStageDescription(promotionScope.ClusterPromotion, currentStageName, - "Successfully provisioned") - - if err := r.deleteCheckDeploymentClusterProfile(ctx, promotionScope.ClusterPromotion, - currentStageName, logger); err != nil { - logger.V(logs.LogInfo).Error(err, "failed to delete preHealthCheckDeployment profile") - return reconcile.Result{Requeue: true, RequeueAfter: deleteRequeueAfter} - } - - nextStage := r.getNextStage(promotionScope.ClusterPromotion, currentStageName) - if nextStage != nil { - // Move to next stage - logger.V(logs.LogInfo).Info("ClusterPromotion advancing to next stage", - "nexStage", nextStage.Name) - - if err := r.reconcileStageProfile(ctx, promotionScope, nextStage, logger); err != nil { - return reconcile.Result{Requeue: true, RequeueAfter: normalRequeueAfter} - } - - addStageStatus(promotionScope.ClusterPromotion, nextStage.Name) - r.eventRecorder.Eventf(promotionScope.ClusterPromotion, nil, corev1.EventTypeNormal, "Sveltos", - configv1beta1.ClusterPromotionKind, fmt.Sprintf("Provisioning stage %s", nextStage.Name)) - - return reconcile.Result{Requeue: true, RequeueAfter: normalStageRequeueAfter} - } - - // No more stages — promotion is complete - promotionScope.Logger.V(logs.LogInfo).Info("ClusterPromotion is complete") - promotionScope.Logger.V(logs.LogDebug).Info("Reconcile success") - - return reconcile.Result{} + return reconcileClusterPromotionNormal(ctx, deps, promotionScope, isInFreeTopX, logger) } // SetupWithManager sets up the controller with the Manager. @@ -334,323 +229,10 @@ func (r *ClusterPromotionReconciler) addFinalizer(ctx context.Context, promotion return nil } -// StageHashable defines the structure used *only* for hashing, -// intentionally omitting the 'Trigger' field. -type StageHashable struct { - Name string `json:"name"` - ClusterSelector libsveltosv1beta1.Selector `json:"clusterSelector,omitempty"` -} - -func (r *ClusterPromotionReconciler) getStagesHash(spec *configv1beta1.ClusterPromotionSpec) ([]byte, error) { - // Create a new slice of the hashable stage structure. - // The Trigger field in the Stage struct is used for runtime progression logic (like manual approval), - // changes to it shouldn't trigger a re-hash. So exclude Trigger field. - stagesForHash := make([]StageHashable, len(spec.Stages)) - for i, stage := range spec.Stages { - stagesForHash[i] = StageHashable{ - Name: stage.Name, - ClusterSelector: stage.ClusterSelector, - } - } - - stagesBytes, err := json.Marshal(stagesForHash) - if err != nil { - return nil, fmt.Errorf("failed to marshal Stages for hashing: %w", err) - } - - h := sha256.New() - if _, err := h.Write(stagesBytes); err != nil { - return nil, fmt.Errorf("failed to write Stages bytes to total: %w", err) - } - - return h.Sum(nil), nil -} - -func (r *ClusterPromotionReconciler) getProfileSpecHash(profileSpec *configv1beta1.ProfileSpec, -) ([]byte, error) { - - h := sha256.New() - - specCopy := *profileSpec - - specCopy.HelmCharts = getSortedHelmCharts(profileSpec.HelmCharts) - specCopy.KustomizationRefs = getSortedKustomizationRefs(profileSpec.KustomizationRefs) - specCopy.PolicyRefs = getSortedPolicyRefs(profileSpec.PolicyRefs) - - // Use sort.Strings for []string - specCopy.DependsOn = make([]string, len(profileSpec.DependsOn)) - copy(specCopy.DependsOn, profileSpec.DependsOn) - sort.Strings(specCopy.DependsOn) - - specCopy.TemplateResourceRefs = getSortedTemplateResourceRefs(profileSpec.TemplateResourceRefs) - specCopy.ValidateHealths = getSortedValidateHealths(profileSpec.ValidateHealths) - specCopy.Patches = getSortedPatches(profileSpec.Patches) - specCopy.DriftExclusions = getSortedDriftExclusions(profileSpec.DriftExclusions) - - jsonBytes, err := json.Marshal(specCopy) - if err != nil { - return nil, fmt.Errorf("failed to marshal ProfileSpec for hashing: %w", err) - } - - if _, err := h.Write(jsonBytes); err != nil { - return nil, fmt.Errorf("failed to write ProfileSpec bytes to hash: %w", err) - } - - return h.Sum(nil), nil -} - -// profileSpecChanged compares the old profileSpecHash from the status with the -// currently computed hash from the spec. It returns true if the hash has changed -// (meaning the ProfileSpec configuration has been modified). -func (r *ClusterPromotionReconciler) profileSpecChanged(promotionScope *scope.ClusterPromotionScope, -) ( - changed bool, - newProfileSpecHash []byte, - err error, -) { - - oldHash := promotionScope.ProfileSpecHash() - - currentHash, err := r.getProfileSpecHash(&promotionScope.ClusterPromotion.Spec.ProfileSpec) - if err != nil { - promotionScope.Logger.V(logs.LogInfo).Info("getProfileSpecHash failed", "error", err) - return false, oldHash, err - } - - return !bytes.Equal(oldHash, currentHash), currentHash, nil -} - -func (r *ClusterPromotionReconciler) setPromotionHashesInStatus(promotionScope *scope.ClusterPromotionScope, - newProfileSpecHash, newStagesHash []byte) { - - promotionScope.ClusterPromotion.Status.StagesHash = newStagesHash - promotionScope.ClusterPromotion.Status.ProfileSpecHash = newProfileSpecHash -} - -// stagesChanged compares the old stages hash from the status with the -// currently computed hash from the spec. It returns true if the hash has changed -// (meaning the ProfileSpec configuration has been modified). -func (r *ClusterPromotionReconciler) stagesChanged(promotionScope *scope.ClusterPromotionScope, -) ( - changed bool, - newStagesHash []byte, - err error, -) { - - oldHash := promotionScope.StagesHash() - - currentHash, err := r.getStagesHash(&promotionScope.ClusterPromotion.Spec) - if err != nil { - promotionScope.Logger.V(logs.LogInfo).Info("getStagesHash failed", "error", err) - return false, oldHash, err - } - - return !bytes.Equal(oldHash, currentHash), currentHash, nil -} - -func (r *ClusterPromotionReconciler) needsPromotionRestart(promotionScope *scope.ClusterPromotionScope, -) ( - restart bool, - newProfileSpecHash []byte, - newStagesHash []byte, - err error, -) { - - // If ProfileSpec or Stages changed or Stages has changed, restart - // the promotion cycle - - // 1. Check for Stages changes and calculate new hash - stagesChanged, newStagesHash, err := r.stagesChanged(promotionScope) - if err != nil { - return false, nil, nil, err - } - - // 2. Check for ProfileSpec changes and calculate new hash - profileSpecChanged, newProfileSpecHash, err := r.profileSpecChanged(promotionScope) - if err != nil { - return false, nil, nil, err - } - - // Determine if a restart is needed - restart = stagesChanged || profileSpecChanged - - return restart, newProfileSpecHash, newStagesHash, nil -} - -// reconcileStageProfile creates/updates the ClusterProfile for the given stage -func (r *ClusterPromotionReconciler) reconcileStageProfile(ctx context.Context, - promotionScope *scope.ClusterPromotionScope, stage *configv1beta1.Stage, logger logr.Logger, -) error { - - if err := r.deleteCheckDeploymentClusterProfile(ctx, promotionScope.ClusterPromotion, stage.Name, - logger); err != nil { - return err - } - - logger.V(logs.LogInfo).Info("Reconciling ClusterProfile for stage") - - addTypeInformationToObject(r.Scheme, promotionScope.ClusterPromotion) - - clusterProfile := &configv1beta1.ClusterProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: mainDeploymentClusterProfileName(promotionScope.Name(), stage.Name), - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: configv1beta1.GroupVersion.String(), - Kind: promotionScope.ClusterPromotion.GetObjectKind().GroupVersionKind().Kind, - Name: promotionScope.ClusterPromotion.GetName(), - UID: promotionScope.ClusterPromotion.GetUID(), - }, - }, - Labels: getMainDeploymentClusterProfileLabels(promotionScope.ClusterPromotion), - Annotations: getStageAnnotation(stage.Name), - }, - } - - // The desired ClusterProfile Spec is the combination of the ClusterPromotion's - // global ProfileSpec and the stage-specific ClusterSelector. - desiredSpec := configv1beta1.Spec{ - // 1. Copy all fields from ClusterPromotionSpec.ProfileSpec - SyncMode: promotionScope.ClusterPromotion.Spec.ProfileSpec.SyncMode, - Tier: promotionScope.ClusterPromotion.Spec.ProfileSpec.Tier, - ContinueOnConflict: promotionScope.ClusterPromotion.Spec.ProfileSpec.ContinueOnConflict, - ContinueOnError: promotionScope.ClusterPromotion.Spec.ProfileSpec.ContinueOnError, - MaxUpdate: promotionScope.ClusterPromotion.Spec.ProfileSpec.MaxUpdate, - StopMatchingBehavior: promotionScope.ClusterPromotion.Spec.ProfileSpec.StopMatchingBehavior, - Reloader: promotionScope.ClusterPromotion.Spec.ProfileSpec.Reloader, - TemplateResourceRefs: promotionScope.ClusterPromotion.Spec.ProfileSpec.TemplateResourceRefs, - DependsOn: promotionScope.ClusterPromotion.Spec.ProfileSpec.DependsOn, - PolicyRefs: promotionScope.ClusterPromotion.Spec.ProfileSpec.PolicyRefs, - HelmCharts: promotionScope.ClusterPromotion.Spec.ProfileSpec.HelmCharts, - KustomizationRefs: promotionScope.ClusterPromotion.Spec.ProfileSpec.KustomizationRefs, - PreDeployChecks: promotionScope.ClusterPromotion.Spec.ProfileSpec.PreDeployChecks, - ValidateHealths: promotionScope.ClusterPromotion.Spec.ProfileSpec.ValidateHealths, - Patches: promotionScope.ClusterPromotion.Spec.ProfileSpec.Patches, - PatchesFrom: promotionScope.ClusterPromotion.Spec.ProfileSpec.PatchesFrom, - DriftExclusions: promotionScope.ClusterPromotion.Spec.ProfileSpec.DriftExclusions, - MaxConsecutiveFailures: promotionScope.ClusterPromotion.Spec.ProfileSpec.MaxConsecutiveFailures, - PreDeleteChecks: promotionScope.ClusterPromotion.Spec.ProfileSpec.PreDeleteChecks, - PostDeleteChecks: promotionScope.ClusterPromotion.Spec.ProfileSpec.PostDeleteChecks, - - // 2. Set the stage-specific ClusterSelector - ClusterSelector: stage.ClusterSelector, - } - - clusterProfile.Spec = desiredSpec - - clusterProfile.SetGroupVersionKind(configv1beta1.GroupVersion.WithKind(configv1beta1.ClusterProfileKind)) - - dr, err := k8s_utils.GetDynamicResourceInterface(r.Config, clusterProfile.GroupVersionKind(), "") - if err != nil { - promotionScope.Logger.V(logs.LogInfo).Info("failed to get dynamic ResourceInterface", "error", err) - return err - } - - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(clusterProfile) - if err != nil { - promotionScope.Logger.V(logs.LogInfo).Info("failed to convert ClusterProfile to Unstructured", "error", err) - return err - } - - // Convert the Unstructured object to a byte slice for the Patch data. - patchData, err := json.Marshal(unstructuredObj) - if err != nil { - promotionScope.Logger.V(logs.LogInfo).Info("failed to marshal Unstructured object", "error", err) - return err - } - - forceConflict := true - options := metav1.PatchOptions{ - FieldManager: applyPatchFieldManager, - Force: &forceConflict, - } - - _, err = dr.Patch(ctx, clusterProfile.Name, types.ApplyPatchType, patchData, options) - if err != nil { - promotionScope.Error(err, "failed to apply patch for ClusterProfile", "clusterProfileName", clusterProfile.Name) - return err - } - - return nil -} - -func (r *ClusterPromotionReconciler) checkCurrentStageDeployment(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, logger logr.Logger, -) (deployed bool, message string, err error) { - - // Get the Name of the current stage from the status - currentStageName := clusterPromotion.Status.CurrentStageName - if currentStageName == "" { - logger.V(logs.LogDebug).Info("No current stage is set, nothing to check.") - return true, "No current stage to check.", nil - } - - clusterProfileName := mainDeploymentClusterProfileName(clusterPromotion.Name, currentStageName) - - currentClusterProfile := &configv1beta1.ClusterProfile{} - - // Check if the ClusterProfile exists - if err := r.Get(ctx, types.NamespacedName{Name: clusterProfileName}, currentClusterProfile); err != nil { - logger.Error(err, "failed to fetch ClusterProfile", "clusterProfileName", clusterProfileName) - return false, "Failed to fetch ClusterProfile.", err - } - - // Assuming addTypeInformationToObject is a helper that ensures GVK is set - addTypeInformationToObject(managementClusterClient.Scheme(), currentClusterProfile) - - tempProfileScope, scopeError := scope.NewProfileScope(scope.ProfileScopeParams{ - Client: r.Client, - Logger: logger, - Profile: currentClusterProfile, - ControllerName: "clusterpromotion", - }) - - if scopeError != nil { - logger.Error(scopeError, "failed to create ProfileScope", "clusterProfileName", clusterProfileName) - return false, "Failed to create ProfileScope.", scopeError - } - - // Check deployment status of ClusterSummaries - isDeployed, message, checkErr := allClusterSummariesDeployed(ctx, r.Client, tempProfileScope, logger) - - if checkErr != nil { - logger.Error(checkErr, "Error checking ClusterSummary status for stage", "stageName", currentStageName) - return false, message, checkErr - } - - if !isDeployed { - logger.V(logs.LogDebug).Info("Stage deployment still pending.", "stageName", currentStageName, "status", message) - } else { - logger.V(logs.LogDebug).Info("Stage deployment is provisioned.", "stageName", currentStageName, "status", message) - } - // Return the result of the deployment check - return isDeployed, message, nil -} - -// Returns the name of the ClusterProfile Sveltos creates for the primary resources -// being deployed in the current promotion stage. -func mainDeploymentClusterProfileName(promotionClusterName, stageName string) string { - return fmt.Sprintf("%s-%s", promotionClusterName, stageName) -} - -// Returns the name of the ClusterProfile Sveltos creates for the PreHealthCheckDeployment -// resources (e.g., a validation Job) in the current promotion stage. -func preCheckDeploymentClusterProfileName(promotionClusterName, stageName string) string { - return fmt.Sprintf("preverification-%s-%s", promotionClusterName, stageName) -} - -// getStageAnnotation returns the annotations added to the managed ClusterProfile -// created for a given ClusterPromotion's stage. -func getStageAnnotation(stageName string, -) map[string]string { - - return map[string]string{ - stageNameAnnotation: stageName, - } -} - // getMainDeploymentClusterProfileLabels returns the labels added to the main ClusterProfile -// created for a given ClusterPromotion instance. +// created for a given ClusterPromotion instance. Deletion needs this to find the +// ClusterProfiles a ClusterPromotion owns regardless of license status, so it stays open +// source; the Sveltos Enterprise library duplicates this tiny helper for the creation path. func getMainDeploymentClusterProfileLabels(clusterPromotion *configv1beta1.ClusterPromotion, ) map[string]string { @@ -659,581 +241,6 @@ func getMainDeploymentClusterProfileLabels(clusterPromotion *configv1beta1.Clust } } -// getPreCheckDeploymentClusterProfileLabels returns the labels added to the ClusterProfile -// created for the PreHealthCheckDeployment step (e.g., validation Job). -func getPreCheckDeploymentClusterProfileLabels(clusterPromotion *configv1beta1.ClusterPromotion, -) map[string]string { - - return map[string]string{ - clusterPromotionNameLabel: clusterPromotion.Name, - // Adding a distinct label to easily identify this ClusterProfile as part of the pre-verification step. - preVerificationClusterProfileLabel: stringTrue, - } -} - -func resetStageStatuses(clusterPromotion *configv1beta1.ClusterPromotion) { - clusterPromotion.Status.Stages = []configv1beta1.StageStatus{} - clusterPromotion.Status.LastPromotionTime = nil -} - -func addStageStatus(clusterPromotion *configv1beta1.ClusterPromotion, stageName string) { - clusterPromotion.Status.Stages = append(clusterPromotion.Status.Stages, - configv1beta1.StageStatus{ - Name: stageName, - LastUpdateReconciledTime: &metav1.Time{Time: time.Now()}, - FailureMessage: nil, - LastSuccessfulAppliedTime: nil, - LastStatusCheckTime: nil, - }, - ) - clusterPromotion.Status.CurrentStageName = stageName - clusterPromotion.Status.LastPromotionTime = &metav1.Time{Time: time.Now()} -} - -func updateStageStatus(clusterPromotion *configv1beta1.ClusterPromotion, stageName string, - allProvisioned bool, failureMessage *string) { - - now := &metav1.Time{Time: time.Now()} - - for i := range clusterPromotion.Status.Stages { - if clusterPromotion.Status.Stages[i].Name == stageName { - clusterPromotion.Status.Stages[i].FailureMessage = failureMessage - clusterPromotion.Status.Stages[i].LastStatusCheckTime = now - - if allProvisioned { - clusterPromotion.Status.Stages[i].LastSuccessfulAppliedTime = now - } - } - } -} - -func updateStageDescription(clusterPromotion *configv1beta1.ClusterPromotion, - stageName string, description string) { - - if description == "" { - return - } - - nowTime := time.Now().UTC().Truncate(time.Second) - formattedTime := nowTime.Format("2006-01-02T15:04:05Z") - message := fmt.Sprintf("%s (%s)", description, formattedTime) - - for i := range clusterPromotion.Status.Stages { - if clusterPromotion.Status.Stages[i].Name == stageName { - clusterPromotion.Status.Stages[i].CurrentStatusDescription = &message - - return - } - } -} - -func getStageSpecByName(clusterPromotion *configv1beta1.ClusterPromotion, name string) *configv1beta1.Stage { - stages := clusterPromotion.Spec.Stages - for i := range stages { - if stages[i].Name == name { - return &stages[i] - } - } - return nil -} - -func getStageStatusByName(clusterPromotion *configv1beta1.ClusterPromotion, name string) *configv1beta1.StageStatus { - stages := clusterPromotion.Status.Stages - for i := range stages { - if stages[i].Name == name { - return &stages[i] - } - } - return nil -} - -func getCurrentStage(clusterPromotion *configv1beta1.ClusterPromotion) string { - return clusterPromotion.Status.CurrentStageName -} - -// isStageProvisioned checks if the stage has completed deployment. -func (r *ClusterPromotionReconciler) isStageProvisioned(clusterPromotion *configv1beta1.ClusterPromotion, - stageName string) bool { - - if stageName == "" { - // If no stage is current, assume either finished or not started (not provisioned yet) - return false - } - - // Find the StageStatus entry for the current stage - stageStatus := getStageStatusByName(clusterPromotion, stageName) - if stageStatus == nil { - // Status entry hasn't been created yet (shouldn't happen if CurrentStageName is set) - return false - } - - // Provisioned if LastSuccessfulAppliedTime is set (non-nil) - return stageStatus.LastSuccessfulAppliedTime != nil -} - -// getNextStage returns the name of the stage following the given stageName. -// If stageName is the last one or not found, it returns an empty string. -func (r *ClusterPromotionReconciler) getNextStage(clusterPromotion *configv1beta1.ClusterPromotion, - stageName string) *configv1beta1.Stage { - - if stageName == "" { - return nil - } - - for i := range clusterPromotion.Spec.Stages { - if clusterPromotion.Spec.Stages[i].Name != stageName { - continue - } - - // Found current stage. Check if there's a next one. - if i+1 < len(clusterPromotion.Spec.Stages) { - return &clusterPromotion.Spec.Stages[i+1] - } - // No next stage (current is last) - return nil - } - - // stageName not found in the list - return nil -} - -// doMoveToNextStage returns true if Sveltos should move to next stage -func (r *ClusterPromotionReconciler) doMoveToNextStage(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, logger logr.Logger) (bool, error) { - - currentStageName := getCurrentStage(clusterPromotion) - - if !r.isStageProvisioned(clusterPromotion, currentStageName) { - return false, nil - } - - currentStageSpec := getStageSpecByName(clusterPromotion, currentStageName) - if currentStageSpec == nil { - errorMsg := fmt.Sprintf("spec not present for stage %s", currentStageName) - logger.V(logs.LogDebug).Info(errorMsg) - return false, errors.New(errorMsg) - } - - if currentStageSpec.Trigger == nil { - logger.V(logs.LogDebug).Info("stage is completed as no trigger is defined") - return true, nil - } - - if currentStageSpec.Trigger.Auto != nil { - logger.V(logs.LogDebug).Info("trigger is auto") - return r.canAutoAdvance(ctx, clusterPromotion, currentStageName, currentStageSpec.Trigger.Auto, logger) - } - - if currentStageSpec.Trigger.Manual != nil { - logger.V(logs.LogDebug).Info("trigger is manual") - return r.canManualAdvance(ctx, clusterPromotion, currentStageName, currentStageSpec.Trigger.Manual, logger) - } - - return true, nil -} - -// canAutoAdvance returns true if Sveltos should move to next stage based on Auto Trigger -func (r *ClusterPromotionReconciler) canAutoAdvance(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, currentStageName string, - autoTrigger *configv1beta1.AutoTrigger, logger logr.Logger) (bool, error) { - - if autoTrigger.Delay != nil { - requiredReadyTime, err := r.getRequiredReadyTime(clusterPromotion, currentStageName, - autoTrigger.Delay) - if err != nil { - logger.V(logs.LogDebug).Info(err.Error()) - return false, err - } - - // 2. Check the delay condition - if time.Now().Before(*requiredReadyTime) { - // The required delay time has NOT yet passed. - message := fmt.Sprintf("Delayed: Waiting for Time Window: %s", - requiredReadyTime.Format(time.RFC3339)) - logger.V(logs.LogDebug).Info(message, - "stage", currentStageName, - "delay", autoTrigger.Delay.Duration.String(), - "ready_at", requiredReadyTime.Format(time.RFC3339), - ) - - updateStageDescription(clusterPromotion, currentStageName, message) - return false, nil - } - } - - // --- DELAY HAS PASSED: Deploy PreHealthCheckDeployment -- - stage := getStageSpecByName(clusterPromotion, currentStageName) - if err := r.reconcilePreHealthCheckDeployment(ctx, clusterPromotion, stage, - autoTrigger.PreHealthCheckDeployment, logger); err != nil { - return false, err - } - - // --- DELAY HAS PASSED: Reconcile Post-Delay Health Checks --- - if err := r.reconcilePostDelayHealthChecks(ctx, clusterPromotion, currentStageName, - autoTrigger.PostDelayHealthChecks, logger); err != nil { - logger.V(logs.LogDebug).Info("Running Post-Promotion Health Checks") - updateStageDescription(clusterPromotion, currentStageName, "Running Post-Promotion Health Checks") - - return false, err - } - - // Verify ClusterSummary instances are all Provisioned (which means PostDelayHealthChecks) are passing - deployed, message, err := r.checkCurrentStageDeployment(ctx, clusterPromotion, logger) - if err != nil { - logger.Error(err, "Failed to verify current stage deployment status") - return false, err - } - if !deployed { - logger.V(logs.LogDebug).Info("Awaiting Health Checks to Pass") - updateStageStatus(clusterPromotion, currentStageName, false, &message) - updateStageDescription(clusterPromotion, currentStageName, "Awaiting Health Checks to Pass") - return false, nil - } - - // Enforce Promotion Window (If Defined) - if autoTrigger.PromotionWindow != nil { - isOpen, nextOpenTime, err := r.isPromotionWindowOpen(autoTrigger.PromotionWindow, time.Now(), logger) - if err != nil { - logger.V(logs.LogInfo).Error(err, "failed to evaluate promotion window") - return false, err - } - - if !isOpen { - message := fmt.Sprintf("Window Closed: Waiting for Next Schedule (%s).", - nextOpenTime.Format(time.RFC3339)) - // Promotion Window is currently closed. Block advancement. - logger.V(logs.LogInfo).Info(message, - "stage", currentStageName, - "next_open_time", nextOpenTime.Format(time.RFC3339), - ) - updateStageDescription(clusterPromotion, currentStageName, message) - return false, nil - } - } - - return true, nil -} - -func (r *ClusterPromotionReconciler) isPromotionWindowOpen(promotionWindow *configv1beta1.TimeWindow, - anchorTime time.Time, logger logr.Logger) (isOpen bool, nextOpenTime *time.Time, err error) { - - schedFrom, err := cron.ParseStandard(promotionWindow.From) - if err != nil { - logger.V(logs.LogInfo).Error(err, "failed to parse promotionWindow", "from", promotionWindow.From) - return false, nil, fmt.Errorf("unparseable schedule %q: %w", promotionWindow.From, err) - } - - schedTo, err := cron.ParseStandard(promotionWindow.To) - if err != nil { - logger.V(logs.LogInfo).Error(err, "failed to parse promotionWindow", "to", promotionWindow.To) - return false, nil, fmt.Errorf("unparseable schedule %q: %w", promotionWindow.To, err) - } - - // Use the cluster's local time for evaluation - now := anchorTime - - // 1. Establish a Robust Historical Reference Point (a year lookback) - const safeLookback = 366 * 24 * time.Hour - referenceTime := now.Add(-safeLookback) - - // 2. Iterate forward from the reference point to find the latest time <= now. - var prevOpenTime time.Time - - t := schedFrom.Next(referenceTime) - - // Loop until the next scheduled open time is in the future (> now). - for t.Before(now) || t.Equal(now) { - prevOpenTime = t - t = schedFrom.Next(t) - } - - // 3. Handle Error/Malformity (No 'From' time found in the last year) - if prevOpenTime.IsZero() { - // Schedule is extremely infrequent or malformed. Block now and schedule for the absolute next open time. - nextOpen := schedFrom.Next(now) - nextOpenTime = &nextOpen - logger.V(logs.LogInfo).Info("Schedule 'From' is too infrequent or malformed, deferring until next absolute open time.", - "next_open_time", nextOpenTime.Format(time.RFC3339)) - return false, nextOpenTime, nil - } - - // --- Determine Next Close Time (nextCloseTime) --- - - // 4. Find the next close time relative to the last time the window opened. - // This ensures we calculate the end time of the *current* cycle. - nextCloseTime := schedTo.Next(prevOpenTime) - - // If nextCloseTime is in the past, advance both nextCloseTime and nextCloseTime - if nextCloseTime.Before(now) || nextCloseTime.Equal(now) { - prevOpenTime = schedFrom.Next(prevOpenTime) - nextCloseTime = schedTo.Next(nextCloseTime) - } - - // 5. Handle Wrapping/Short Windows: Ensure nextCloseTime is *after* the current moment if we missed the open. - // If the next calculated close time is in the past, step forward until it's in the future. - for nextCloseTime.Before(now) || nextCloseTime.Equal(now) { - nextCloseTime = schedTo.Next(nextCloseTime) - } - - // --- Final Evaluation --- - - // 6. Check if 'now' is within the window: [prevOpenTime, nextCloseTime) - if now.After(prevOpenTime) && now.Before(nextCloseTime) { - isOpen = true - // If open, we requeue the controller when the window closes. - nextOpenTime = &nextCloseTime - } else { - isOpen = false - // If closed, we must requeue at the next time the window opens. - nextOpen := schedFrom.Next(now) - nextOpenTime = &nextOpen - } - - logger.V(logs.LogDebug).Info("Promotion window evaluated", - "is_open", isOpen, - "prev_open", prevOpenTime.Format(time.RFC3339), - "next_close", nextCloseTime.Format(time.RFC3339), - "next_requeue_time", nextOpenTime.Format(time.RFC3339), - ) - - return isOpen, nextOpenTime, nil -} - -func (r *ClusterPromotionReconciler) deleteCheckDeploymentClusterProfile(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, stageName string, logger logr.Logger, -) error { - - profileName := preCheckDeploymentClusterProfileName(clusterPromotion.Name, stageName) - clusterProfile := &configv1beta1.ClusterProfile{} - err := r.Get(ctx, types.NamespacedName{Name: profileName}, clusterProfile) - if err != nil { - if apierrors.IsNotFound(err) { - return nil - } - logger.V(logs.LogInfo).Error(err, "failed to get preCheckDeployment ClusterProfile") - return err - } - - logger.V(logs.LogDebug).Info("deleting preCheckDeployment ClusterProfile") - return r.Delete(ctx, clusterProfile) -} - -// reconcilePreHealthCheckDeployment creates/updates the PreHealthCheck ClusterProfile -// for the given stage -func (r *ClusterPromotionReconciler) reconcilePreHealthCheckDeployment(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, stage *configv1beta1.Stage, - preHealthCheckDeployment []configv1beta1.PolicyRef, logger logr.Logger) error { - - if stage.Trigger == nil || len(preHealthCheckDeployment) == 0 { - return nil - } - - logger.V(logs.LogDebug).Info("create/update PreHealthCheckDeployment ClusterProfile") - clusterProfile := &configv1beta1.ClusterProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: preCheckDeploymentClusterProfileName(clusterPromotion.Name, stage.Name), - OwnerReferences: []metav1.OwnerReference{ - { - APIVersion: configv1beta1.GroupVersion.String(), - Kind: clusterPromotion.GetObjectKind().GroupVersionKind().Kind, - Name: clusterPromotion.GetName(), - UID: clusterPromotion.GetUID(), - }, - }, - Labels: getPreCheckDeploymentClusterProfileLabels(clusterPromotion), - Annotations: getStageAnnotation(stage.Name), - }, - } - - // The desired ClusterProfile Spec is the combination of the ClusterPromotion's - // global ProfileSpec and the stage-specific ClusterSelector. - desiredSpec := configv1beta1.Spec{ - // 1. Copy all fields from ClusterPromotionSpec.ProfileSpec - SyncMode: configv1beta1.SyncModeContinuous, - Tier: clusterPromotion.Spec.ProfileSpec.Tier, - ContinueOnConflict: clusterPromotion.Spec.ProfileSpec.ContinueOnConflict, - ContinueOnError: clusterPromotion.Spec.ProfileSpec.ContinueOnError, - - // 2. Set the stage-specific ClusterSelector - ClusterSelector: stage.ClusterSelector, - - // 3. Set PreHealthCheckDeployment - PolicyRefs: preHealthCheckDeployment, - } - - clusterProfile.Spec = desiredSpec - - clusterProfile.SetGroupVersionKind(configv1beta1.GroupVersion.WithKind(configv1beta1.ClusterProfileKind)) - - dr, err := k8s_utils.GetDynamicResourceInterface(r.Config, clusterProfile.GroupVersionKind(), "") - if err != nil { - logger.V(logs.LogInfo).Info("failed to get dynamic ResourceInterface", "error", err) - return err - } - - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(clusterProfile) - if err != nil { - logger.V(logs.LogInfo).Info("failed to convert ClusterProfile to Unstructured", "error", err) - return err - } - - // Convert the Unstructured object to a byte slice for the Patch data. - patchData, err := json.Marshal(unstructuredObj) - if err != nil { - logger.V(logs.LogInfo).Info("failed to marshal Unstructured object", "error", err) - return err - } - - forceConflict := true - options := metav1.PatchOptions{ - FieldManager: applyPatchFieldManager, - Force: &forceConflict, - } - - _, err = dr.Patch(ctx, clusterProfile.Name, types.ApplyPatchType, patchData, options) - if err != nil { - logger.Error(err, "failed to apply patch for ClusterProfile", "clusterProfileName", clusterProfile.Name) - return err - } - - return nil -} - -// reconcilePostDelayHealthChecks updates the ClusterProfile's Spec.ValidateHealths -// to include the PostDelayHealthChecks if they are not already present. -func (r *ClusterPromotionReconciler) reconcilePostDelayHealthChecks( - ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, - currentStageName string, - postDelayChecks []libsveltosv1beta1.ValidateHealth, - logger logr.Logger, -) error { - - clusterProfileName := mainDeploymentClusterProfileName(clusterPromotion.Name, currentStageName) - currentClusterProfile := &configv1beta1.ClusterProfile{} - - if err := r.Get(ctx, types.NamespacedName{Name: clusterProfileName}, currentClusterProfile); err != nil { - logger.Error(err, "failed to fetch ClusterProfile", "clusterProfileName", clusterProfileName) - return err - } - - // 1. Check if the post-delay checks are already included in the Spec. - if r.postDelayChecksAlreadyIncluded(currentClusterProfile.Spec.ValidateHealths, postDelayChecks) { - logger.V(logs.LogDebug).Info("PostDelayHealthChecks already reconciled into ClusterProfile spec.") - return nil - } - - // 2. Add the PostDelayHealthChecks to the desired ClusterProfile Spec. - desiredClusterProfile := currentClusterProfile.DeepCopy() - desiredClusterProfile.Spec.ValidateHealths = append( - desiredClusterProfile.Spec.ValidateHealths, - postDelayChecks..., - ) - - if err := r.Update(ctx, desiredClusterProfile); err != nil { - logger.Error(err, "failed to apply patch for PostDelayHealthChecks") - return err - } - - logger.V(logs.LogInfo).Info("Successfully reconciled PostDelayHealthChecks into ClusterProfile spec.") - - // Return an error. Since we just updated the ClusterProfile, we need to wait for it to be reconcilied - return fmt.Errorf("updated ClusterProfile %s with PostDelayHealthChecks", clusterProfileName) -} - -// postDelayChecksAlreadyIncluded is a helper to check if the full set of post-delay checks -// is already present in the existing health checks. -func (r *ClusterPromotionReconciler) postDelayChecksAlreadyIncluded( - existingChecks []libsveltosv1beta1.ValidateHealth, - postDelayChecks []libsveltosv1beta1.ValidateHealth, -) bool { - // Check length: if existing checks are shorter than post-delay checks, they can't be included. - if len(existingChecks) < len(postDelayChecks) { - return false - } - - // 1. Create a map of existing check names for quick lookup. - existingNames := make(map[string]struct{}) - for i := range existingChecks { - check := &existingChecks[i] - existingNames[check.Name] = struct{}{} - } - - // 2. Iterate through the post-delay checks and verify every name exists - // in the map of existing names. - for i := range postDelayChecks { - postCheck := &postDelayChecks[i] - if _, exists := existingNames[postCheck.Name]; !exists { - // Found a PostDelayHealthCheck name that is not in the existing list. - return false - } - } - - // All post-delay check names were found in the existing list. - return true -} - -// canManualAdvance returns true if Sveltos should move to next stage based on Manual Trigger -func (r *ClusterPromotionReconciler) canManualAdvance(ctx context.Context, - clusterPromotion *configv1beta1.ClusterPromotion, currentStageName string, - manualTrigger *configv1beta1.ManualTrigger, logger logr.Logger) (bool, error) { - - if manualTrigger.Delay != nil { - requiredReadyTime, err := r.getRequiredReadyTime(clusterPromotion, currentStageName, - manualTrigger.Delay) - if err != nil { - logger.V(logs.LogDebug).Info(err.Error()) - return false, err - } - - // 2. Check the delay condition - if time.Now().Before(*requiredReadyTime) { - // The required delay time has NOT yet passed. - message := fmt.Sprintf("Delayed: Waiting for Time Window: %s", - requiredReadyTime.Format(time.RFC3339)) - logger.V(logs.LogDebug).Info(message, - "stage", currentStageName, - "delay", manualTrigger.Delay.Duration.String(), - "ready_at", requiredReadyTime.Format(time.RFC3339), - ) - - updateStageDescription(clusterPromotion, currentStageName, message) - return false, nil - } - } - - // --- DELAY HAS PASSED: Deploy PreHealthCheckDeployment -- - stage := getStageSpecByName(clusterPromotion, currentStageName) - if err := r.reconcilePreHealthCheckDeployment(ctx, clusterPromotion, stage, - manualTrigger.PreHealthCheckDeployment, logger); err != nil { - return false, err - } - - // --- DELAY HAS PASSED: Reconcile Post-Delay Health Checks --- - if err := r.reconcilePostDelayHealthChecks(ctx, clusterPromotion, currentStageName, - manualTrigger.PostDelayHealthChecks, logger); err != nil { - logger.V(logs.LogDebug).Info("Running Post-Promotion Health Checks") - updateStageDescription(clusterPromotion, currentStageName, "Running Post-Promotion Health Checks") - - return false, err - } - - if manualTrigger.Approved == nil || - !(*manualTrigger.Approved) { - - message := "Paused: Awaiting Manual Approval" - logger.V(logs.LogDebug).Info(message) - updateStageDescription(clusterPromotion, currentStageName, message) - return false, nil - } - - if manualTrigger.AutomaticReset { - manualTrigger.Approved = nil - } - - return true, nil -} - func (r *ClusterPromotionReconciler) cleanClusterProfiles(ctx context.Context, clusterPromotion *configv1beta1.ClusterPromotion) error { @@ -1275,117 +282,3 @@ func (r *ClusterPromotionReconciler) allClusterProfilesGone(ctx context.Context, return true } - -func (r *ClusterPromotionReconciler) removeStaleClusterProfiles(ctx context.Context, - promotionScope *scope.ClusterPromotionScope) error { - - expectedClusterProfiles := make(map[string]struct{}, len(promotionScope.ClusterPromotion.Spec.Stages)) - - for i := range promotionScope.ClusterPromotion.Spec.Stages { - stage := &promotionScope.ClusterPromotion.Spec.Stages[i] - expectedClusterProfiles[mainDeploymentClusterProfileName(promotionScope.Name(), stage.Name)] = struct{}{} - expectedClusterProfiles[preCheckDeploymentClusterProfileName(promotionScope.Name(), stage.Name)] = struct{}{} - } - - listOptions := []client.ListOption{ - client.MatchingLabels(getMainDeploymentClusterProfileLabels(promotionScope.ClusterPromotion)), - } - clusterProfiles := &configv1beta1.ClusterProfileList{} - err := r.List(ctx, clusterProfiles, listOptions...) - if err != nil { - return err - } - - for i := range clusterProfiles.Items { - if _, ok := expectedClusterProfiles[clusterProfiles.Items[i].Name]; !ok { - if k8s_utils.IsOwnerReference(&clusterProfiles.Items[i], promotionScope.ClusterPromotion) { - promotionScope.V(logs.LogInfo).Info("deleting stale ClusterProfile", - "clusterProfile", clusterProfiles.Items[i]) - _ = r.Delete(ctx, &clusterProfiles.Items[i]) - } - } - } - - return nil -} - -func (r *ClusterPromotionReconciler) verifyStageEligibility(ctx context.Context, - promotionScope *scope.ClusterPromotionScope, logger logr.Logger) (bool, error) { - - // ClusterPromotion requires a valid license - publicKey, err := license.GetPublicKey() - if err != nil { - logger.V(logs.LogInfo).Info(fmt.Sprintf("failed to get public key: %v", err)) - return false, err - } - - result := license.VerifyLicenseSecret(ctx, r.Client, getSveltosNamespace(), publicKey, logger) - if result.Message != "" { - logger.V(logs.LogDebug).Info(result.Message) - } - - return grantsClusterPromotionEligibility(&result, promotionScope.ClusterPromotion.Name, logger), nil -} - -// grantsClusterPromotionEligibility decides, from an already-verified license result, whether -// this ClusterPromotion instance is eligible to run. Split out of verifyStageEligibility so the -// decision can be unit tested against synthetic results without needing a real signed license. -func grantsClusterPromotionEligibility(result *license.LicenseVerificationResult, - clusterPromotionName string, logger logr.Logger) bool { - - if result.IsValid || result.IsInGracePeriod { - if result.Payload == nil { - logger.V(logs.LogInfo).Info("An Enterprise or EnterprisePlus License is required") - return false - } - - if !result.Payload.HasFeature(license.FeaturePromotion) { - // Features, when explicitly set, is an allowlist. - logger.V(logs.LogInfo).Info("License does not include the Promotion feature") - return false - } - - if result.Payload.Plan != license.PlanEnterprisePlus && result.Payload.Plan != license.PlanEnterprise { - logger.V(logs.LogInfo).Info("An Enterprise or EnterprisePlus License is required") - return false - } - - return true - } - - licenseManagerInstance := GetLicenseManager() - - // Without license, 2 clusterPromotions instances are still free - const maxFreeStages = 2 - return licenseManagerInstance.IsClusterPromotionInTopX("", clusterPromotionName, maxFreeStages) -} - -func (r *ClusterPromotionReconciler) updateStatusWithMissingLicenseError( - promotionScope *scope.ClusterPromotionScope) { - - notEligibleError := "license is required to manage ClusterPromotion" - - // The minimum length of stages is 1 so accessing the first stage is safe - firstStage := promotionScope.ClusterPromotion.Spec.Stages[0] - addStageStatus(promotionScope.ClusterPromotion, firstStage.Name) - updateStageStatus(promotionScope.ClusterPromotion, firstStage.Name, false, ¬EligibleError) -} - -// getRequiredReadyTime returns the time when the delay is satisfied. -func (r *ClusterPromotionReconciler) getRequiredReadyTime(clusterPromotion *configv1beta1.ClusterPromotion, - currentStageName string, delay *metav1.Duration) (*time.Time, error) { - - currentStageStatus := getStageStatusByName(clusterPromotion, currentStageName) - if currentStageStatus == nil { - return nil, fmt.Errorf("status not present for stage %s", currentStageName) - } - - if currentStageStatus.LastSuccessfulAppliedTime == nil { - return nil, fmt.Errorf("LastSuccessfulAppliedTime not set for stage %s", currentStageName) - } - - // Calculate: Success Time + Delay Duration - requiredReadyTime := currentStageStatus.LastSuccessfulAppliedTime.Add(delay.Duration) - - return &requiredReadyTime, nil -} diff --git a/controllers/clusterpromotion_controller_test.go b/controllers/clusterpromotion_controller_test.go index 1bfa391a..b9c16819 100644 --- a/controllers/clusterpromotion_controller_test.go +++ b/controllers/clusterpromotion_controller_test.go @@ -17,28 +17,19 @@ limitations under the License. package controllers_test import ( - "bytes" "context" "fmt" "reflect" - "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/klog/v2/textlogger" configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" "github.com/projectsveltos/addon-controller/controllers" - "github.com/projectsveltos/addon-controller/lib/clusterops" - "github.com/projectsveltos/addon-controller/pkg/scope" - libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" ) const ( @@ -46,12 +37,6 @@ const ( ) var _ = Describe("ClusterPromotionController", func() { - var logger logr.Logger - - BeforeEach(func() { - logger = textlogger.NewLogger(textlogger.NewConfig()) - }) - It("ClusterPromotion exposes all ClusterProfile.Spec fields", func() { // With the exception of ClusterSelector,ClusterRefs and SetRefs // ClusterPromotion exposes all other fields defined in ClusterProfile.Spec @@ -103,913 +88,6 @@ var _ = Describe("ClusterPromotionController", func() { } }) - It("Calculate Stages hash", func() { - profileSpec := &configv1beta1.ProfileSpec{} - - stage1 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - } - - stage2 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - } - - promotionSpec := &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage1, stage2}, - } - - reconciler := controllers.ClusterPromotionReconciler{} - - hash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(hash).ToNot(BeNil()) - - // order of stages does matter - promotionSpec = &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage2, stage1}, - } - - newHash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(reflect.DeepEqual(hash, newHash)).To(BeFalse()) - }) - - It("Calculate Stages hash: trigger changes are not included", func() { - profileSpec := &configv1beta1.ProfileSpec{} - - notApproved := false - stage1 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{ - AutomaticReset: true, - Approved: ¬Approved, - }, - }, - } - - stage2 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{ - AutomaticReset: true, - Approved: ¬Approved, - }, - }, - } - - promotionSpec := &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage1, stage2}, - } - - reconciler := controllers.ClusterPromotionReconciler{} - - hash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(hash).ToNot(BeNil()) - - // change stage1 trigger - approved := true - stage1.Trigger.Manual.Approved = &approved - - promotionSpec = &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage1, stage2}, - } - - newHash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(reflect.DeepEqual(hash, newHash)).To(BeTrue()) - }) - - It("Calculate Stages hash: non trigger changes are included", func() { - profileSpec := &configv1beta1.ProfileSpec{} - - notApproved := false - stage1 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{ - AutomaticReset: true, - Approved: ¬Approved, - }, - }, - } - - stage2 := configv1beta1.Stage{ - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{ - AutomaticReset: true, - Approved: ¬Approved, - }, - }, - } - - promotionSpec := &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage1, stage2}, - } - - reconciler := controllers.ClusterPromotionReconciler{} - - hash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(hash).ToNot(BeNil()) - - // change stage1 trigger - stage1.ClusterSelector = libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - } - - promotionSpec = &configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{stage1, stage2}, - } - - newHash, err := controllers.GetStagesHash(&reconciler, promotionSpec) - Expect(err).To(BeNil()) - Expect(reflect.DeepEqual(hash, newHash)).To(BeFalse()) - }) - - It("Calculate ProfileSpec hash", func() { - hc1 := configv1beta1.HelmChart{ - RepositoryURL: randomString(), ChartName: randomString(), ChartVersion: randomString(), - ReleaseName: randomString(), ReleaseNamespace: randomString(), RepositoryName: randomString(), - } - - hc2 := configv1beta1.HelmChart{ - RepositoryURL: randomString(), ChartName: randomString(), ChartVersion: randomString(), - ReleaseName: randomString(), ReleaseNamespace: randomString(), RepositoryName: randomString(), - } - - pr1 := configv1beta1.PolicyRef{ - Namespace: randomString(), Name: randomString(), Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), - } - - pr2 := configv1beta1.PolicyRef{ - Namespace: randomString(), Name: randomString(), Kind: string(libsveltosv1beta1.SecretReferencedResourceKind), - } - - kr1 := configv1beta1.KustomizationRef{ - Namespace: randomString(), Name: randomString(), Kind: sourcev1.GitRepositoryKind, - } - - kr2 := configv1beta1.KustomizationRef{ - Namespace: randomString(), Name: randomString(), Kind: sourcev1.GitRepositoryKind, - } - - dependency1 := randomString() - dependency2 := randomString() - - profileSpec := &configv1beta1.ProfileSpec{ - DependsOn: []string{dependency1, dependency2}, - HelmCharts: []configv1beta1.HelmChart{hc1, hc2}, - PolicyRefs: []configv1beta1.PolicyRef{pr1, pr2}, - KustomizationRefs: []configv1beta1.KustomizationRef{kr1, kr2}, - } - - reconciler := controllers.ClusterPromotionReconciler{} - - hash, err := controllers.GetProfileSpecHash(&reconciler, profileSpec) - Expect(err).To(BeNil()) - Expect(hash).ToNot(BeNil()) - - // order does not matter - profileSpec = &configv1beta1.ProfileSpec{ - DependsOn: []string{dependency2, dependency1}, - HelmCharts: []configv1beta1.HelmChart{hc2, hc1}, - PolicyRefs: []configv1beta1.PolicyRef{pr2, pr1}, - KustomizationRefs: []configv1beta1.KustomizationRef{kr2, kr1}, - } - - newHash, err := controllers.GetProfileSpecHash(&reconciler, profileSpec) - Expect(err).To(BeNil()) - Expect(reflect.DeepEqual(hash, newHash)).To(BeTrue()) - }) - - It("profileSpecChanged detects ProfileSpec changes", func() { - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - ProfileSpec: configv1beta1.ProfileSpec{ - HelmCharts: []configv1beta1.HelmChart{ - { - RepositoryURL: randomString(), ChartName: randomString(), - ChartVersion: randomString(), ReleaseName: randomString(), - ReleaseNamespace: randomString(), RepositoryName: randomString(), - }, - }, - PolicyRefs: []configv1beta1.PolicyRef{ - { - Namespace: randomString(), Name: randomString(), - Kind: string(libsveltosv1beta1.SecretReferencedResourceKind), - }, - }, - }, - }, - Status: configv1beta1.ClusterPromotionStatus{ - ProfileSpecHash: []byte(randomString()), - }, - } - - reconciler := controllers.ClusterPromotionReconciler{} - promotionScope := scope.ClusterPromotionScope{ - ClusterPromotion: clusterPromotion, - } - - changed, profileSpecHash, err := controllers.ProfileSpecChanged(&reconciler, &promotionScope) - Expect(err).To(BeNil()) - Expect(changed).To(BeTrue()) - - currentHash, err := controllers.GetProfileSpecHash(&reconciler, &clusterPromotion.Spec.ProfileSpec) - Expect(err).To(BeNil()) - Expect(bytes.Equal(currentHash, profileSpecHash)).To(BeTrue()) - clusterPromotion.Status.ProfileSpecHash = currentHash - - changed, profileSpecHash, err = controllers.ProfileSpecChanged(&reconciler, &promotionScope) - Expect(err).To(BeNil()) - Expect(changed).To(BeFalse()) - Expect(bytes.Equal(currentHash, profileSpecHash)).To(BeTrue()) - }) - - It("stagesChanged detects Stages changes", func() { - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - Stages: []configv1beta1.Stage{ - { - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - }, - { - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - }, - }, - }, - Status: configv1beta1.ClusterPromotionStatus{ - StagesHash: []byte(randomString()), - }, - } - - reconciler := controllers.ClusterPromotionReconciler{} - promotionScope := scope.ClusterPromotionScope{ - ClusterPromotion: clusterPromotion, - } - - changed, stagesHash, err := controllers.StagesChanged(&reconciler, &promotionScope) - Expect(err).To(BeNil()) - Expect(changed).To(BeTrue()) - - currentHash, err := controllers.GetStagesHash(&reconciler, &clusterPromotion.Spec) - Expect(err).To(BeNil()) - Expect(bytes.Equal(currentHash, stagesHash)).To(BeTrue()) - clusterPromotion.Status.StagesHash = currentHash - - changed, stagesHash, err = controllers.StagesChanged(&reconciler, &promotionScope) - Expect(err).To(BeNil()) - Expect(bytes.Equal(currentHash, stagesHash)).To(BeTrue()) - Expect(changed).To(BeFalse()) - }) - - It("reconcileStageProfile creates/updates ClusterProfile", func() { - hc1 := configv1beta1.HelmChart{ - RepositoryURL: randomString(), ChartName: randomString(), ChartVersion: randomString(), - ReleaseName: randomString(), ReleaseNamespace: randomString(), RepositoryName: randomString(), - } - - pr1 := configv1beta1.PolicyRef{ - Namespace: randomString(), Name: randomString(), Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), - } - - pr2 := configv1beta1.PolicyRef{ - Namespace: randomString(), Name: randomString(), Kind: string(libsveltosv1beta1.SecretReferencedResourceKind), - } - - kr1 := configv1beta1.KustomizationRef{ - Namespace: randomString(), Name: randomString(), Kind: sourcev1.GitRepositoryKind, - } - - check := libsveltosv1beta1.ValidateHealth{ - Group: "", - Version: testV1APIVersion, - Kind: "Pod", - LabelFilters: []libsveltosv1beta1.LabelFilter{ - {Key: randomString(), Value: randomString(), Operation: libsveltosv1beta1.OperationEqual}, - }, - Name: randomString(), - FeatureID: libsveltosv1beta1.FeatureHelm, - } - - const tier = 90 - var maxConsecutiveFailures = uint(10) - - profileSpec := &configv1beta1.ProfileSpec{ - DependsOn: []string{randomString(), randomString()}, - HelmCharts: []configv1beta1.HelmChart{hc1}, - PolicyRefs: []configv1beta1.PolicyRef{pr1, pr2}, - KustomizationRefs: []configv1beta1.KustomizationRef{kr1}, - ValidateHealths: []libsveltosv1beta1.ValidateHealth{check}, - Tier: tier, - SyncMode: configv1beta1.SyncModeContinuousWithDriftDetection, - ContinueOnConflict: true, - ContinueOnError: true, - MaxConsecutiveFailures: &maxConsecutiveFailures, - } - - reconciler := controllers.ClusterPromotionReconciler{ - Client: testEnv.Client, - Config: testEnv.Config, - Scheme: testEnv.Scheme(), - } - - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - ProfileSpec: *profileSpec, - Stages: []configv1beta1.Stage{ - { - Name: randomString(), - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{ - randomString(): randomString(), - randomString(): randomString(), - }, - }, - }, - }, - }, - }, - } - - Expect(testEnv.Create(context.TODO(), clusterPromotion)).To(Succeed()) - Expect(waitForObject(context.TODO(), testEnv.Client, clusterPromotion)).To(Succeed()) - - promotionScope := scope.ClusterPromotionScope{ - ClusterPromotion: clusterPromotion, - Logger: logger, - } - - stage := &clusterPromotion.Spec.Stages[0] - Expect(controllers.ReconcileStageProfile(&reconciler, context.TODO(), &promotionScope, - stage, logger)).To(Succeed()) - - clusterProfileName := controllers.MainDeploymentClusterProfileName(promotionScope.Name(), stage.Name) - Eventually(func() error { - currentClusterProfile := &configv1beta1.ClusterProfile{} - return testEnv.Get(context.TODO(), types.NamespacedName{Name: clusterProfileName}, - currentClusterProfile) - }, timeout, pollingInterval).Should(BeNil()) - - currentClusterProfile := &configv1beta1.ClusterProfile{} - Expect(testEnv.Get(context.TODO(), types.NamespacedName{Name: clusterProfileName}, - currentClusterProfile)).To(Succeed()) - - verifyProfileSpecFields(¤tClusterProfile.Spec, &clusterPromotion.Spec.ProfileSpec, - &stage.ClusterSelector) - - clusterPromotion.Spec.ProfileSpec.HelmCharts = append(currentClusterProfile.Spec.HelmCharts, - configv1beta1.HelmChart{ - RepositoryURL: randomString(), ChartName: randomString(), ChartVersion: randomString(), - ReleaseName: randomString(), ReleaseNamespace: randomString(), RepositoryName: randomString(), - }) - - Expect(controllers.ReconcileStageProfile(&reconciler, context.TODO(), &promotionScope, - stage, logger)).To(Succeed()) - Eventually(func() bool { - currentClusterProfile := &configv1beta1.ClusterProfile{} - err := testEnv.Get(context.TODO(), types.NamespacedName{Name: clusterProfileName}, - currentClusterProfile) - if err != nil { - return false - } - return reflect.DeepEqual(currentClusterProfile.Spec.HelmCharts, - clusterPromotion.Spec.ProfileSpec.HelmCharts) - }, timeout, pollingInterval).Should(BeFalse()) - }) - - It("stage's methods update ClusterPromotions Status with stage statuses", func() { - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - } - - stageName1 := randomString() - controllers.AddStageStatus(clusterPromotion, stageName1) - Expect(len(clusterPromotion.Status.Stages)).To(Equal(1)) - Expect(clusterPromotion.Status.Stages[0].Name).To(Equal(stageName1)) - Expect(clusterPromotion.Status.Stages[0].LastUpdateReconciledTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastStatusCheckTime).To(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastSuccessfulAppliedTime).To(BeNil()) - - failureMessage := randomString() - controllers.UpdateStageStatus(clusterPromotion, stageName1, false, &failureMessage) - Expect(len(clusterPromotion.Status.Stages)).To(Equal(1)) - Expect(clusterPromotion.Status.Stages[0].Name).To(Equal(stageName1)) - Expect(clusterPromotion.Status.Stages[0].LastUpdateReconciledTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastStatusCheckTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastSuccessfulAppliedTime).To(BeNil()) - Expect(clusterPromotion.Status.Stages[0].FailureMessage).ToNot(BeNil()) - Expect(*clusterPromotion.Status.Stages[0].FailureMessage).To(Equal(failureMessage)) - - controllers.UpdateStageStatus(clusterPromotion, stageName1, true, nil) - Expect(len(clusterPromotion.Status.Stages)).To(Equal(1)) - Expect(clusterPromotion.Status.Stages[0].Name).To(Equal(stageName1)) - Expect(clusterPromotion.Status.Stages[0].LastUpdateReconciledTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastStatusCheckTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].LastSuccessfulAppliedTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[0].FailureMessage).To(BeNil()) - - stageName2 := randomString() - controllers.AddStageStatus(clusterPromotion, stageName2) - Expect(len(clusterPromotion.Status.Stages)).To(Equal(2)) - Expect(clusterPromotion.Status.Stages[0].Name).To(Equal(stageName1)) - Expect(clusterPromotion.Status.Stages[1].Name).To(Equal(stageName2)) - Expect(clusterPromotion.Status.Stages[1].LastUpdateReconciledTime).ToNot(BeNil()) - Expect(clusterPromotion.Status.Stages[1].LastStatusCheckTime).To(BeNil()) - Expect(clusterPromotion.Status.Stages[1].LastSuccessfulAppliedTime).To(BeNil()) - - controllers.ResetStageStatuses(clusterPromotion) - Expect(len(clusterPromotion.Status.Stages)).To(Equal(0)) - }) - - It("checkCurrentStageDeployment verifies that all clusters matching a stage are provisined", func() { - stageName := randomString() - - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Status: configv1beta1.ClusterPromotionStatus{ - CurrentStageName: stageName, - }, - } - - // ClusterProfile matching a clusterPromotion stage - clusterProfile := &configv1beta1.ClusterProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, stageName), - }, - Spec: configv1beta1.Spec{ - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - PolicyRefs: []configv1beta1.PolicyRef{ - { - Namespace: randomString(), Name: randomString(), - Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), - }, - }, - HelmCharts: []configv1beta1.HelmChart{ - { - RepositoryURL: randomString(), ChartName: randomString(), - ChartVersion: randomString(), ReleaseName: randomString(), - ReleaseNamespace: randomString(), RepositoryName: randomString(), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterProfile)).To(Succeed()) - - // Create two ClusterSummary instance for the above ClusterProfile - clusterSummary1 := &configv1beta1.ClusterSummary{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - Namespace: randomString(), - Labels: map[string]string{ - clusterops.ClusterProfileLabelName: clusterProfile.Name, - }, - }, - Spec: configv1beta1.ClusterSummarySpec{ - ClusterProfileSpec: configv1beta1.Spec{ - PolicyRefs: clusterProfile.Spec.PolicyRefs, - HelmCharts: clusterProfile.Spec.HelmCharts, - }, - }, - Status: configv1beta1.ClusterSummaryStatus{ - FeatureSummaries: []configv1beta1.FeatureSummary{ - { - FeatureID: libsveltosv1beta1.FeatureResources, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - { - FeatureID: libsveltosv1beta1.FeatureHelm, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterSummary1)).To(Succeed()) - - clusterSummary2 := &configv1beta1.ClusterSummary{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - Namespace: randomString(), - Labels: map[string]string{ - clusterops.ClusterProfileLabelName: clusterProfile.Name, - }, - }, - Spec: configv1beta1.ClusterSummarySpec{ - ClusterProfileSpec: configv1beta1.Spec{ - PolicyRefs: clusterProfile.Spec.PolicyRefs, - }, - }, - Status: configv1beta1.ClusterSummaryStatus{ - FeatureSummaries: []configv1beta1.FeatureSummary{ - { - FeatureID: libsveltosv1beta1.FeatureHelm, - Status: libsveltosv1beta1.FeatureStatusProvisioning, - Hash: []byte(randomString())}, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterSummary2)).To(Succeed()) - - initObjects := []client.Object{ - clusterProfile, - clusterSummary1, - clusterSummary2, - } - - c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(initObjects...).WithObjects(initObjects...).Build() - - reconciler := controllers.ClusterPromotionReconciler{Client: c} - promotionScope := scope.ClusterPromotionScope{ - ClusterPromotion: clusterPromotion, - } - - deployed, msg, err := controllers.CheckCurrentStageDeployment(&reconciler, context.TODO(), promotionScope.ClusterPromotion, logger) - Expect(err).To(BeNil()) - Expect(deployed).To(BeFalse()) - Expect(msg).To(Equal(fmt.Sprintf("ClusterSummary HelmCharts %s is not in sync.", clusterSummary2.Name))) - - clusterSummary2.Spec.ClusterProfileSpec = configv1beta1.Spec{ - PolicyRefs: clusterProfile.Spec.PolicyRefs, - HelmCharts: clusterProfile.Spec.HelmCharts, - } - - Expect(c.Update(context.TODO(), clusterSummary2)).To(Succeed()) - deployed, msg, err = controllers.CheckCurrentStageDeployment(&reconciler, context.TODO(), promotionScope.ClusterPromotion, logger) - Expect(err).To(BeNil()) - Expect(deployed).To(BeFalse()) - Expect(msg).To(Equal(fmt.Sprintf("ClusterSummary %s is not yet provisioned.", clusterSummary2.Name))) - - clusterSummary2.Status = configv1beta1.ClusterSummaryStatus{ - FeatureSummaries: []configv1beta1.FeatureSummary{ - { - FeatureID: libsveltosv1beta1.FeatureResources, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - { - FeatureID: libsveltosv1beta1.FeatureHelm, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - }, - } - Expect(c.Status().Update(context.TODO(), clusterSummary2)).To(Succeed()) - deployed, msg, err = controllers.CheckCurrentStageDeployment(&reconciler, context.TODO(), promotionScope.ClusterPromotion, logger) - Expect(err).To(BeNil()) - Expect(deployed).To(BeTrue()) - Expect(msg).To(Equal("All matching clusters are successfully deployed.")) - }) - - It("getNextStage returns the next stage to provision", func() { - stage1 := randomString() - stage2 := randomString() - stage3 := randomString() - - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - Stages: []configv1beta1.Stage{ - {Name: stage1}, - {Name: stage2}, - {Name: stage3}, - }, - }, - Status: configv1beta1.ClusterPromotionStatus{ - CurrentStageName: stage1, - }, - } - - reconciler := controllers.ClusterPromotionReconciler{} - - nextStage := controllers.GetNextStage(&reconciler, clusterPromotion, stage1) - Expect(nextStage).ToNot(BeNil()) - Expect(nextStage.Name).To(Equal(stage2)) - - clusterPromotion.Status.CurrentStageName = stage2 - nextStage = controllers.GetNextStage(&reconciler, clusterPromotion, stage2) - Expect(nextStage).ToNot(BeNil()) - Expect(nextStage.Name).To(Equal(stage3)) - - clusterPromotion.Status.CurrentStageName = stage3 - nextStage = controllers.GetNextStage(&reconciler, clusterPromotion, stage3) - Expect(nextStage).To(BeNil()) - }) - - It("canAutoAdvance returns true when advancing to next stage is allowed", func() { - oneHour, err := time.ParseDuration("1h") - Expect(err).To(BeNil()) - - stage1 := randomString() - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - Stages: []configv1beta1.Stage{ - { - Name: stage1, - Trigger: &configv1beta1.Trigger{ - Auto: &configv1beta1.AutoTrigger{ - Delay: &metav1.Duration{Duration: oneHour}, - }, - }, - }, - }, - }, - Status: configv1beta1.ClusterPromotionStatus{ - CurrentStageName: stage1, - Stages: []configv1beta1.StageStatus{ - { - Name: stage1, - LastSuccessfulAppliedTime: &metav1.Time{Time: time.Now()}, - }, - }, - }, - } - - // ClusterProfile matching a clusterPromotion stage - clusterProfile := &configv1beta1.ClusterProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, stage1), - }, - Spec: configv1beta1.Spec{ - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - PolicyRefs: []configv1beta1.PolicyRef{ - { - Namespace: randomString(), Name: randomString(), - Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), - }, - }, - HelmCharts: []configv1beta1.HelmChart{ - { - RepositoryURL: randomString(), ChartName: randomString(), - ChartVersion: randomString(), ReleaseName: randomString(), - ReleaseNamespace: randomString(), RepositoryName: randomString(), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterProfile)).To(Succeed()) - - // Create two ClusterSummary instance for the above ClusterProfile - clusterSummary1 := &configv1beta1.ClusterSummary{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - Namespace: randomString(), - Labels: map[string]string{ - clusterops.ClusterProfileLabelName: clusterProfile.Name, - }, - }, - Spec: configv1beta1.ClusterSummarySpec{ - ClusterProfileSpec: configv1beta1.Spec{ - PolicyRefs: clusterProfile.Spec.PolicyRefs, - HelmCharts: clusterProfile.Spec.HelmCharts, - }, - }, - Status: configv1beta1.ClusterSummaryStatus{ - FeatureSummaries: []configv1beta1.FeatureSummary{ - { - FeatureID: libsveltosv1beta1.FeatureResources, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - { - FeatureID: libsveltosv1beta1.FeatureHelm, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterSummary1)).To(Succeed()) - - initObjects := []client.Object{ - clusterProfile, - clusterSummary1, - } - - c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(initObjects...).WithObjects(initObjects...).Build() - - reconciler := controllers.ClusterPromotionReconciler{Client: c} - - canAdvance, err := controllers.CanAutoAdvance(&reconciler, context.TODO(), clusterPromotion, - stage1, clusterPromotion.Spec.Stages[0].Trigger.Auto, logger) - Expect(err).To(BeNil()) - Expect(canAdvance).To(BeFalse()) // delay is set to one hour - - twoHoursAgo := time.Now().Add(-2 * time.Hour) - clusterPromotion.Status.Stages = []configv1beta1.StageStatus{ - { - Name: stage1, - LastSuccessfulAppliedTime: &metav1.Time{Time: twoHoursAgo}, - }, - } - - canAdvance, err = controllers.CanAutoAdvance(&reconciler, context.TODO(), clusterPromotion, - stage1, clusterPromotion.Spec.Stages[0].Trigger.Auto, logger) - Expect(err).To(BeNil()) - Expect(canAdvance).To(BeTrue()) // delay is set to one hour and LastSuccessfulAppliedTime was set to 2 hours back - }) - - It("canManulAdvance returns true when advancing to next stage is allowed", func() { - stage1 := randomString() - clusterPromotion := &configv1beta1.ClusterPromotion{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - }, - Spec: configv1beta1.ClusterPromotionSpec{ - Stages: []configv1beta1.Stage{ - { - Name: stage1, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{}, - }, - }, - }, - }, - Status: configv1beta1.ClusterPromotionStatus{ - CurrentStageName: stage1, - Stages: []configv1beta1.StageStatus{ - { - Name: stage1, - LastSuccessfulAppliedTime: &metav1.Time{Time: time.Now()}, - }, - }, - }, - } - - // ClusterProfile matching a clusterPromotion stage - clusterProfile := &configv1beta1.ClusterProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, stage1), - }, - Spec: configv1beta1.Spec{ - ClusterSelector: libsveltosv1beta1.Selector{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{randomString(): randomString()}, - }, - }, - PolicyRefs: []configv1beta1.PolicyRef{ - { - Namespace: randomString(), Name: randomString(), - Kind: string(libsveltosv1beta1.ConfigMapReferencedResourceKind), - }, - }, - HelmCharts: []configv1beta1.HelmChart{ - { - RepositoryURL: randomString(), ChartName: randomString(), - ChartVersion: randomString(), ReleaseName: randomString(), - ReleaseNamespace: randomString(), RepositoryName: randomString(), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterProfile)).To(Succeed()) - - // Create two ClusterSummary instance for the above ClusterProfile - clusterSummary1 := &configv1beta1.ClusterSummary{ - ObjectMeta: metav1.ObjectMeta{ - Name: randomString(), - Namespace: randomString(), - Labels: map[string]string{ - clusterops.ClusterProfileLabelName: clusterProfile.Name, - }, - }, - Spec: configv1beta1.ClusterSummarySpec{ - ClusterProfileSpec: configv1beta1.Spec{ - PolicyRefs: clusterProfile.Spec.PolicyRefs, - HelmCharts: clusterProfile.Spec.HelmCharts, - }, - }, - Status: configv1beta1.ClusterSummaryStatus{ - FeatureSummaries: []configv1beta1.FeatureSummary{ - { - FeatureID: libsveltosv1beta1.FeatureResources, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - { - FeatureID: libsveltosv1beta1.FeatureHelm, - Status: libsveltosv1beta1.FeatureStatusProvisioned, - Hash: []byte(randomString()), - }, - }, - }, - } - Expect(addTypeInformationToObject(scheme, clusterSummary1)).To(Succeed()) - - initObjects := []client.Object{ - clusterProfile, - clusterSummary1, - } - - c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(initObjects...).WithObjects(initObjects...).Build() - - reconciler := controllers.ClusterPromotionReconciler{Client: c} - - canAdvance, err := controllers.CanManualAdvance(&reconciler, context.TODO(), clusterPromotion, - stage1, clusterPromotion.Spec.Stages[0].Trigger.Manual, logger) - Expect(err).To(BeNil()) - Expect(canAdvance).To(BeFalse()) // Approved is not set in ManualTrigger - - approved := true - - clusterPromotion.Spec = configv1beta1.ClusterPromotionSpec{ - Stages: []configv1beta1.Stage{ - { - Name: stage1, - Trigger: &configv1beta1.Trigger{ - Manual: &configv1beta1.ManualTrigger{ - Approved: &approved, - }, - }, - }, - }, - } - - canAdvance, err = controllers.CanManualAdvance(&reconciler, context.TODO(), clusterPromotion, - stage1, clusterPromotion.Spec.Stages[0].Trigger.Manual, logger) - Expect(err).To(BeNil()) - Expect(canAdvance).To(BeTrue()) // Approved is set to true in ManualTrigger - }) - It("cleanClusterProfiles deletes all ClusterProfile instances created by a ClusterPromotion", func() { clusterPromotion := &configv1beta1.ClusterPromotion{ ObjectMeta: metav1.ObjectMeta{ @@ -1019,17 +97,17 @@ var _ = Describe("ClusterPromotionController", func() { clusterProfile1 := &configv1beta1.ClusterProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, randomString()), + Name: randomString(), Labels: controllers.GetMainDeploymentClusterProfileLabels(clusterPromotion)}, } clusterProfile2 := &configv1beta1.ClusterProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, randomString()), + Name: randomString(), Labels: controllers.GetMainDeploymentClusterProfileLabels(clusterPromotion)}, } clusterProfile3 := &configv1beta1.ClusterProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: controllers.MainDeploymentClusterProfileName(clusterPromotion.Name, randomString()), + Name: randomString(), Labels: controllers.GetMainDeploymentClusterProfileLabels(clusterPromotion)}, } @@ -1052,132 +130,4 @@ var _ = Describe("ClusterPromotionController", func() { Expect(c.List(ctx, clusterProfiles, listOptions...)).To(Succeed()) Expect(len(clusterProfiles.Items)).To(BeZero()) }) - - Context("When the current time is inside the promotion window", func() { - // Window: Open 10:00 AM daily, Close 11:00 AM daily - openCron := "0 10 * * *" - closeCron := "0 11 * * *" - var window *configv1beta1.TimeWindow - - It("should return true when anchored 5 minutes after opening", func() { - window = &configv1beta1.TimeWindow{From: openCron, To: closeCron} - - // Set 'now' to be exactly 10:05 AM in the local time zone - anchorTime := time.Date(2025, time.October, 7, 10, 5, 0, 0, time.Local) - - reconciler := &controllers.ClusterPromotionReconciler{} - isOpen, nextTime, err := controllers.IsPromotionWindowOpen(reconciler, window, anchorTime, logger) - - Expect(err).NotTo(HaveOccurred()) - Expect(isOpen).To(BeTrue()) - - // Next scheduled event should be the window close time: 11:00 AM - expectedCloseTime := time.Date(2025, time.October, 7, 11, 0, 0, 0, time.Local) - Expect(*nextTime).To(BeTemporally("==", expectedCloseTime)) - }) - }) - - Context("When the current time is outside the promotion window", func() { - // Use the same window: Open 10:00 AM, Close 11:00 AM - openCron := "0 20 * * *" - closeCron := "0 22 * * *" - var window *configv1beta1.TimeWindow - - It("should return false and point to the next open time", func() { - window = &configv1beta1.TimeWindow{From: openCron, To: closeCron} - - // Set 'now' to be exactly 19:30 PM (before the 10:00 PM opening) - anchorTime := time.Date(2025, time.October, 7, 19, 30, 0, 0, time.Local) - - reconciler := &controllers.ClusterPromotionReconciler{} - isOpen, nextTime, err := controllers.IsPromotionWindowOpen(reconciler, window, anchorTime, logger) - - Expect(err).NotTo(HaveOccurred()) - Expect(isOpen).To(BeFalse()) - - // Next scheduled event should be the window open time: 10:00 AM - expectedOpenTime := time.Date(2025, time.October, 7, 20, 0, 0, 0, time.Local) - Expect(*nextTime).To(BeTemporally("==", expectedOpenTime)) - }) - }) - - Context("When the current time is well before the next promotion window", func() { - openCron := "0 10 * * 6" // Opens Saturday at 10:00 AM (Hour 10, Day of Week 6 = Saturday) - closeCron := "0 22 * * 0" // Closes Sunday at 10:00 PM (Hour 22, Day of Week 0 = Sunday) - var window *configv1beta1.TimeWindow - - It("should return false and point to the next open time", func() { - window = &configv1beta1.TimeWindow{From: openCron, To: closeCron} - - // October, 7th, 2025 is a Tuesday - anchorTime := time.Date(2025, time.October, 7, 17, 30, 0, 0, time.Local) - - reconciler := &controllers.ClusterPromotionReconciler{} - isOpen, nextTime, err := controllers.IsPromotionWindowOpen(reconciler, window, anchorTime, logger) - - Expect(err).NotTo(HaveOccurred()) - Expect(isOpen).To(BeFalse()) - - // Next scheduled event should be the window open time: 10:00 AM - expectedOpenTime := time.Date(2025, time.October, 11, 10, 0, 0, 0, time.Local) - Expect(*nextTime).To(BeTemporally("==", expectedOpenTime)) - }) - }) }) - -func verifyProfileSpecFields( - actualSpec *configv1beta1.Spec, - expectedProfileSpec *configv1beta1.ProfileSpec, - stageSelector *libsveltosv1beta1.Selector, -) { - - actualVal := reflect.ValueOf(*actualSpec) - expectedVal := reflect.ValueOf(*expectedProfileSpec) - - // Ensure the types are structs before proceeding - if actualVal.Kind() != reflect.Struct || expectedVal.Kind() != reflect.Struct { - Fail("Inputs must be struct types for comparison.") - return - } - - // Iterate over the fields of the expected ProfileSpec (the template) - for i := 0; i < expectedVal.NumField(); i++ { - expectedField := expectedVal.Field(i) - fieldName := expectedVal.Type().Field(i).Name - - // Find the field in the actual ClusterProfile Spec by name - actualField := actualVal.FieldByName(fieldName) - - // Skip the ClusterSelector field, as it is derived from the stage, not ProfileSpec - if fieldName == ClusterSelectorField { - // Special case: Verify the actual ClusterSelector matches the STAGE selector - Expect(actualField.Interface()).To( - Equal(stageSelector), - "Expected ClusterSelector to match the stage selector", - ) - continue - } - - // Skip invalid fields in actualSpec (i.e., fields that don't exist) - if !actualField.IsValid() { - continue - } - - // Handle different types of comparison - expectedInterface := expectedField.Interface() - actualInterface := actualField.Interface() - - // If the field type is a slice, use DeepEqual for a more accurate comparison - if reflect.TypeOf(expectedInterface).Kind() == reflect.Slice { - Expect(reflect.DeepEqual(actualInterface, expectedInterface)).To( - BeTrue(), - "Field '%s' value mismatch. Expected: %v, Actual: %v", fieldName, expectedInterface, actualInterface, - ) - } else { - Expect(actualInterface).To( - Equal(expectedInterface), - "Field '%s' value mismatch. Expected: %v, Actual: %v", fieldName, expectedInterface, actualInterface, - ) - } - } -} diff --git a/controllers/clusterpromotion_oss.go b/controllers/clusterpromotion_oss.go new file mode 100644 index 00000000..9c36cdb0 --- /dev/null +++ b/controllers/clusterpromotion_oss.go @@ -0,0 +1,42 @@ +//go:build !enterprise + +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/projectsveltos/addon-controller/pkg/scope" + logs "github.com/projectsveltos/libsveltos/lib/logsettings" +) + +// Default (non-"enterprise") build: ClusterPromotion is a Sveltos Enterprise feature and +// its implementation lives in the private sveltos-enterprise module, which this build +// does not import. Building official images requires `-tags enterprise` with a checkout +// of sveltos-enterprise available; see clusterpromotion_plugin.go. +func init() { + reconcileClusterPromotionNormal = func(_ context.Context, _ clusterPromotionEnterpriseDeps, + promotionScope *scope.ClusterPromotionScope, _ bool, logger logr.Logger) reconcile.Result { + + logger.V(logs.LogInfo).Info("ClusterPromotion requires a Sveltos Enterprise build") + return reconcile.Result{RequeueAfter: licenseRequeueAfter} + } +} diff --git a/controllers/clusterpromotion_plugin.go b/controllers/clusterpromotion_plugin.go new file mode 100644 index 00000000..fb5138b4 --- /dev/null +++ b/controllers/clusterpromotion_plugin.go @@ -0,0 +1,49 @@ +//go:build enterprise + +/* +Copyright 2026. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/projectsveltos/addon-controller/pkg/scope" + "github.com/projectsveltos/sveltos-enterprise/clusterpromotion" +) + +// Official Sveltos images are built with `-tags enterprise` and a checkout of the private +// sveltos-enterprise module available (CI only). This file is the only place in this +// package that imports it; a default build excludes this file entirely (see +// clusterpromotion_oss.go), so `go build` from a public clone never needs to resolve it. +func init() { + reconcileClusterPromotionNormal = func(ctx context.Context, deps clusterPromotionEnterpriseDeps, + promotionScope *scope.ClusterPromotionScope, isInFreeTopX bool, logger logr.Logger) reconcile.Result { + + enterpriseReconciler := clusterpromotion.Reconciler{ + Client: deps.Client, + Config: deps.Config, + Scheme: deps.Scheme, + EventRecorder: deps.EventRecorder, + SveltosNamespace: deps.SveltosNamespace, + } + + return enterpriseReconciler.ReconcileNormal(ctx, promotionScope, isInFreeTopX, logger) + } +} diff --git a/controllers/eligibility_test.go b/controllers/eligibility_test.go new file mode 100644 index 00000000..9f1c24b0 --- /dev/null +++ b/controllers/eligibility_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2025. projectsveltos.io. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2/textlogger" + + configv1beta1 "github.com/projectsveltos/addon-controller/api/v1beta1" + "github.com/projectsveltos/addon-controller/controllers" + libsveltosv1beta1 "github.com/projectsveltos/libsveltos/api/v1beta1" + license "github.com/projectsveltos/libsveltos/lib/licenses" +) + +var _ = Describe("grantsPullModeEligibility", func() { + logger := textlogger.NewLogger(textlogger.NewConfig()) + + BeforeEach(func() { + controllers.NewLicenseManager() + }) + + It("denies when Features is non-empty and does not include PullMode, even with a qualifying Plan", func() { + result := &license.LicenseVerificationResult{ + IsValid: true, + Payload: &license.LicensePayload{ + Features: []license.Features{license.FeatureMCP}, + Plan: license.PlanEnterprisePlus, + MaxClusters: 0, + }, + } + Expect(controllers.GrantsPullModeEligibility(result, randomString(), randomString(), logger)).To(BeFalse()) + }) + + It("denies when Features includes PullMode but Plan does not qualify", func() { + result := &license.LicenseVerificationResult{ + IsValid: true, + Payload: &license.LicensePayload{ + Features: []license.Features{license.FeaturePullMode}, + MaxClusters: 0, + }, + } + Expect(controllers.GrantsPullModeEligibility(result, randomString(), randomString(), logger)).To(BeFalse()) + }) + + It("grants unlimited clusters when Features includes PullMode, Plan qualifies, and MaxClusters is 0", func() { + result := &license.LicenseVerificationResult{ + IsValid: true, + Payload: &license.LicensePayload{ + Features: []license.Features{license.FeaturePullMode}, + Plan: license.PlanEnterprisePlus, + MaxClusters: 0, + }, + } + Expect(controllers.GrantsPullModeEligibility(result, randomString(), randomString(), logger)).To(BeTrue()) + }) + + It("grants when Features is empty (backward compatible), Plan qualifies, and MaxClusters is 0", func() { + result := &license.LicenseVerificationResult{ + IsValid: true, + Payload: &license.LicensePayload{Plan: license.PlanEnterprise, MaxClusters: 0}, + } + Expect(controllers.GrantsPullModeEligibility(result, randomString(), randomString(), logger)).To(BeTrue()) + }) + + It("only admits the first MaxClusters registered clusters when MaxClusters is set", func() { + clusterNamespace := randomString() + firstCluster := randomString() + secondCluster := randomString() + + controllers.GetLicenseManager().AddCluster(&libsveltosv1beta1.SveltosCluster{ + ObjectMeta: metav1.ObjectMeta{Namespace: clusterNamespace, Name: firstCluster}, + }) + controllers.GetLicenseManager().AddCluster(&libsveltosv1beta1.SveltosCluster{ + ObjectMeta: metav1.ObjectMeta{Namespace: clusterNamespace, Name: secondCluster}, + }) + + result := &license.LicenseVerificationResult{ + IsValid: true, + Payload: &license.LicensePayload{ + Features: []license.Features{license.FeaturePullMode}, + Plan: license.PlanEnterprisePlus, + MaxClusters: 1, + }, + } + Expect(controllers.GrantsPullModeEligibility(result, clusterNamespace, firstCluster, logger)).To(BeTrue()) + Expect(controllers.GrantsPullModeEligibility(result, clusterNamespace, secondCluster, logger)).To(BeFalse()) + }) + + It("allows 2 free clusters when there is no valid license", func() { + clusterNamespace := randomString() + firstCluster := randomString() + secondCluster := randomString() + thirdCluster := randomString() + + controllers.GetLicenseManager().AddCluster(&libsveltosv1beta1.SveltosCluster{ + ObjectMeta: metav1.ObjectMeta{Namespace: clusterNamespace, Name: firstCluster}, + }) + controllers.GetLicenseManager().AddCluster(&libsveltosv1beta1.SveltosCluster{ + ObjectMeta: metav1.ObjectMeta{Namespace: clusterNamespace, Name: secondCluster}, + }) + controllers.GetLicenseManager().AddCluster(&libsveltosv1beta1.SveltosCluster{ + ObjectMeta: metav1.ObjectMeta{Namespace: clusterNamespace, Name: thirdCluster}, + }) + + result := &license.LicenseVerificationResult{} + Expect(controllers.GrantsPullModeEligibility(result, clusterNamespace, firstCluster, logger)).To(BeTrue()) + Expect(controllers.GrantsPullModeEligibility(result, clusterNamespace, secondCluster, logger)).To(BeTrue()) + Expect(controllers.GrantsPullModeEligibility(result, clusterNamespace, thirdCluster, logger)).To(BeFalse()) + }) +}) + +// grantsClusterPromotionEligibility itself (the license-validity + Features/Plan/top-X +// decision) moved to the Sveltos Enterprise library and is tested there. The top-X +// counting addon-controller still performs on its behalf stays here. +var _ = Describe("LicenseManager ClusterPromotion top-X", func() { + BeforeEach(func() { + controllers.NewLicenseManager() + }) + + It("allows only the top 2 free ClusterPromotions when there is no valid license", func() { + first := &configv1beta1.ClusterPromotion{ObjectMeta: metav1.ObjectMeta{Name: randomString()}} + second := &configv1beta1.ClusterPromotion{ObjectMeta: metav1.ObjectMeta{Name: randomString()}} + third := &configv1beta1.ClusterPromotion{ObjectMeta: metav1.ObjectMeta{Name: randomString()}} + + controllers.GetLicenseManager().AddClusterPromotion(first) + controllers.GetLicenseManager().AddClusterPromotion(second) + controllers.GetLicenseManager().AddClusterPromotion(third) + + const maxFreeStages = 2 + Expect(controllers.GetLicenseManager().IsClusterPromotionInTopX("", first.Name, maxFreeStages)).To(BeTrue()) + Expect(controllers.GetLicenseManager().IsClusterPromotionInTopX("", second.Name, maxFreeStages)).To(BeTrue()) + Expect(controllers.GetLicenseManager().IsClusterPromotionInTopX("", third.Name, maxFreeStages)).To(BeFalse()) + }) +}) diff --git a/controllers/export_test.go b/controllers/export_test.go index 6459cdc5..c178af06 100644 --- a/controllers/export_test.go +++ b/controllers/export_test.go @@ -41,7 +41,6 @@ var ( GetUpdatedAndUpdatingClusters = getUpdatedAndUpdatingClusters ReviseUpdatingClusterList = reviseUpdatingClusterList GrantsPullModeEligibility = grantsPullModeEligibility - GrantsClusterPromotionEligibility = grantsClusterPromotionEligibility ) var ( @@ -82,22 +81,8 @@ var ( ) var ( - GetProfileSpecHash = (*ClusterPromotionReconciler).getProfileSpecHash - GetStagesHash = (*ClusterPromotionReconciler).getStagesHash - ProfileSpecChanged = (*ClusterPromotionReconciler).profileSpecChanged - StagesChanged = (*ClusterPromotionReconciler).stagesChanged - ReconcileStageProfile = (*ClusterPromotionReconciler).reconcileStageProfile - CheckCurrentStageDeployment = (*ClusterPromotionReconciler).checkCurrentStageDeployment - GetNextStage = (*ClusterPromotionReconciler).getNextStage - CanAutoAdvance = (*ClusterPromotionReconciler).canAutoAdvance - CanManualAdvance = (*ClusterPromotionReconciler).canManualAdvance - CleanClusterProfiles = (*ClusterPromotionReconciler).cleanClusterProfiles - IsPromotionWindowOpen = (*ClusterPromotionReconciler).isPromotionWindowOpen - - MainDeploymentClusterProfileName = mainDeploymentClusterProfileName - ResetStageStatuses = resetStageStatuses - AddStageStatus = addStageStatus - UpdateStageStatus = updateStageStatus + CleanClusterProfiles = (*ClusterPromotionReconciler).cleanClusterProfiles + GetMainDeploymentClusterProfileLabels = getMainDeploymentClusterProfileLabels SetNextReconcileTime = (*ClusterSummaryReconciler).setNextReconcileTime diff --git a/controllers/profile_utils.go b/controllers/profile_utils.go index 8ab3d153..5cb6593b 100644 --- a/controllers/profile_utils.go +++ b/controllers/profile_utils.go @@ -101,99 +101,6 @@ func allClusterSummariesGone(ctx context.Context, c client.Client, profileScope return len(clusterSummaryList.Items) == 0 } -// allClusterSummariesDeployed returns true if all ClusterSummaries owned by a -// ClusterProfile/Profile instance are provisioned. It also returns a status message -// and an error if one occurs during API interaction. -func allClusterSummariesDeployed(ctx context.Context, c client.Client, profileScope *scope.ProfileScope, - logger logr.Logger) (deployed bool, message string, err error) { - - listOptions := []client.ListOption{} - - // The current Kind being processed (ClusterProfile or Profile) - currentKind := profileScope.GetKind() - - // Determine labels based on Kind - switch currentKind { - case configv1beta1.ClusterProfileKind: - listOptions = append(listOptions, client.MatchingLabels{clusterops.ClusterProfileLabelName: profileScope.Name()}) - case configv1beta1.ProfileKind: // Explicitly check for ProfileKind - listOptions = append(listOptions, - client.MatchingLabels{clusterops.ProfileLabelName: profileScope.Name()}, - // Only Profile (namespaced) requires a namespace match. ClusterProfile (cluster-scoped) does not. - client.InNamespace(profileScope.Profile.GetNamespace())) - } - - clusterSummaryList := &configv1beta1.ClusterSummaryList{} - - // List ClusterSummaries - if errList := c.List(ctx, clusterSummaryList, listOptions...); errList != nil { - // Return API error immediately - message = fmt.Sprintf("failed to list ClusterSummaries for %s %s. Error: %v", currentKind, profileScope.Name(), errList) - profileScope.V(logs.LogInfo).Info(message) - return false, message, errList // Use errList instead of shadowing the named 'err' - } - - // Check if any ClusterSummaries were found (edge case: selector matched no clusters) - if len(clusterSummaryList.Items) == 0 { - message = fmt.Sprintf("No matching clusters found for %s %s. Deployment considered complete.", - currentKind, profileScope.Name()) - profileScope.V(logs.LogInfo).Info(message) - return true, message, nil - } - - // Check provisioning status - for i := range clusterSummaryList.Items { - // Verify ClusterSummary is in sync - deployed, message = isClusterSummarySyncedAndProvisioned(profileScope, &clusterSummaryList.Items[i], logger) - if !deployed { - return deployed, message, nil - } - } - - // Success case: all found ClusterSummaries are provisioned - return true, "All matching clusters are successfully deployed.", nil -} - -func isClusterSummarySyncedAndProvisioned(profileScope *scope.ProfileScope, - clusterSummary *configv1beta1.ClusterSummary, logger logr.Logger) (provisioned bool, message string) { - - // Verify ClusterSummary is in sync - if !reflect.DeepEqual(clusterSummary.Spec.ClusterProfileSpec.HelmCharts, - profileScope.GetSpec().HelmCharts) { - - message = fmt.Sprintf("ClusterSummary HelmCharts %s is not in sync.", - clusterSummary.Name) - logger.V(logs.LogDebug).Info(message) - return false, message - } - if !reflect.DeepEqual(clusterSummary.Spec.ClusterProfileSpec.PolicyRefs, - profileScope.GetSpec().PolicyRefs) { - - message = fmt.Sprintf("ClusterSummary PolicyRefs %s is not in sync.", - clusterSummary.Name) - logger.V(logs.LogDebug).Info(message) - return false, message - } - if !reflect.DeepEqual(clusterSummary.Spec.ClusterProfileSpec.KustomizationRefs, - profileScope.GetSpec().KustomizationRefs) { - - message = fmt.Sprintf("ClusterSummaryKustomizationRefs %s is not in sync.", - clusterSummary.Name) - logger.V(logs.LogDebug).Info(message) - return false, message - } - - // The helper should check the status of the individual ClusterSummary - if !isCluterSummaryProvisioned(clusterSummary) { - // Found one that is not provisioned: return false with a message - message = fmt.Sprintf("ClusterSummary %s is not yet provisioned.", clusterSummary.Name) - logger.V(logs.LogDebug).Info(message) - return false, message - } - - return true, "" -} - // canRemoveFinalizer returns true if there is no ClusterSummary left created by this // ClusterProfile/Profile instance func canRemoveFinalizer(ctx context.Context, c client.Client, profileScope *scope.ProfileScope) bool { diff --git a/go.mod b/go.mod index 9b1f6b79..214b1b62 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/pkg/errors v0.9.1 github.com/projectsveltos/libsveltos v1.13.0 + github.com/projectsveltos/sveltos-enterprise v0.0.0-20260728165936-3be605d9a912 github.com/prometheus/client_golang v1.24.1 - github.com/robfig/cron v1.2.0 github.com/sigstore/cosign/v3 v3.1.2 github.com/sigstore/sigstore v1.10.8 github.com/spf13/pflag v1.0.10 @@ -226,6 +226,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect + github.com/robfig/cron v1.2.0 // indirect github.com/rubenv/sql-migrate v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/go.sum b/go.sum index 44558134..5e7a7234 100644 --- a/go.sum +++ b/go.sum @@ -621,8 +621,6 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= -github.com/opencontainers/go-digest v1.0.1-0.20260624224211-f7325504ae36 h1:/1PtKk00fYU0P6oweGoMHbjSSNRBex8yQtPspjFZHQM= -github.com/opencontainers/go-digest v1.0.1-0.20260624224211-f7325504ae36/go.mod h1:RqnyioA3pIEZMkSbOIcrw32YSgETfn/VrLuEikEdPNU= github.com/opencontainers/go-digest v1.0.1-0.20260721143128-e5208fdd9cb0 h1:RLWeaWcTZ1ycIvdql2gDDu5k9C1tfKQX3BtuFbZCmYc= github.com/opencontainers/go-digest v1.0.1-0.20260721143128-e5208fdd9cb0/go.mod h1:RqnyioA3pIEZMkSbOIcrw32YSgETfn/VrLuEikEdPNU= github.com/opencontainers/go-digest/blake3 v0.0.0-20260615172202-b50d36f46dea h1:OedKvkUaI3EfCAmzWaRjdP72olcfvLwTMsq5ztDXLR0= @@ -654,6 +652,8 @@ github.com/projectsveltos/lua-utils/glua-sprig v0.0.0-20251212200258-2b3cdcb7c0f github.com/projectsveltos/lua-utils/glua-sprig v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:esi+znTJzieo7M60Ytx56vJZbWJ8WuJfVyRvOCOWs64= github.com/projectsveltos/lua-utils/glua-strings v0.0.0-20251212200258-2b3cdcb7c0f5 h1:ifNj1y4pqhSSDL0B5XfCPTnFy9ZjGTzStuOJu1jE9xs= github.com/projectsveltos/lua-utils/glua-strings v0.0.0-20251212200258-2b3cdcb7c0f5/go.mod h1:P/l817Avvelnzyb9YyMmnDYG3OabOyK8KuWF8s35kj0= +github.com/projectsveltos/sveltos-enterprise v0.0.0-20260728165936-3be605d9a912 h1:o/fB6TrHaJCyqv9odkaDcBVmK/TGPoRfBqb8rtVzqgc= +github.com/projectsveltos/sveltos-enterprise v0.0.0-20260728165936-3be605d9a912/go.mod h1:dThrx/Isg2w+51UFQCOG3x/idcaPDyjtMKa9wY2OIzM= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= diff --git a/test/fv/promotion_test.go b/test/fv/promotion_test.go index 1e1ad7c5..99170ed7 100644 --- a/test/fv/promotion_test.go +++ b/test/fv/promotion_test.go @@ -100,7 +100,7 @@ spec: end` ) - It("Deploy ClusterPromotion with multiple stages", Label("NEW-FV", "NEW-FV-PULLMODE", "EXTENDED"), func() { + It("Deploy ClusterPromotion with multiple stages", Label("ClusterPromotion"), func() { configMapNs := defaultNamespace configMap := createConfigMapWithPolicy(configMapNs, namePrefix+randomString(), fmt.Sprintf(counterJob, configMapNs))