From 4e9a8cbf89a07f6de294dad283d334809da86b02 Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Sun, 26 Apr 2026 10:15:12 +0200 Subject: [PATCH 1/2] (bug) DryRun: keep ClusterSummary alive when cluster stops matching When a ClusterProfile is edited to simultaneously change the cluster selector (deselecting a cluster) and switch the sync mode to DryRun, the expected behavior is for a ClusterReport to be generated showing what resources would be removed. Instead, two bugs caused this to fail silently: Bug 1: When a cluster stopped matching, the system immediately marked the ClusterSummary for deletion. This triggered actual undeploy logic before DryRun mode could take effect, leaving no ClusterReport behind. Bug 2: In DryRun ClusterSummary must not be deleted. If the profile change is reverted, goal is for Sveltos to remove nothing. Fix: When a cluster stops matching a profile that is in DryRun mode: - The ClusterSummary is kept alive rather than deleted, but its deployable content (Helm charts, policy refs, kustomization refs) is cleared and its sync mode is updated to DryRun atomically in a single update. This ensures the reconciler treats it as a DryRun from the moment it next runs. - A ClusterReport is created immediately so the DryRun reconciliation has something to write its diff into. - The ClusterConfiguration for the deselected cluster is left intact so the reconciler can access it during DryRun processing. If the user reverts the selector change, the ClusterSummary is still present and can be restored without an unnecessary undeploy/redeploy cycle. If the user commits the change by switching off DryRun, the normal deletion and cleanup path runs as expected. --- controllers/profile_utils.go | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/controllers/profile_utils.go b/controllers/profile_utils.go index 00181384..b905d567 100644 --- a/controllers/profile_utils.go +++ b/controllers/profile_utils.go @@ -397,6 +397,13 @@ func cleanClusterConfigurations(ctx context.Context, c client.Client, profileSco continue } + // In DryRun mode, keep the ClusterConfiguration intact so the ClusterSummary + // reconciler can access it during DryRun reconciliation (e.g., ChartManager init). + // Cleanup happens when the user commits the change and DryRun is disabled. + if profileScope.IsDryRunSync() { + continue + } + err = cleanClusterConfiguration(ctx, c, profileScope.Profile, cc) if err != nil && !apierrors.IsNotFound(err) { return err @@ -840,6 +847,26 @@ func cleanClusterSummaries(ctx context.Context, c client.Client, profileScope *s if util.IsOwnedByObject(cs, profileScope.Profile, targetGK) { if _, ok := matching[getClusterInfo(cs.Spec.ClusterNamespace, cs.Spec.ClusterName, cs.Spec.ClusterType)]; !ok { clusterRef := getClusterObjectReferenceFromClusterSummary(cs) + + if profileScope.IsDryRunSync() { + // In DryRun mode do not delete the ClusterSummary. Instead clear its + // helmCharts/policyRefs/kustomizationRefs and set syncMode to DryRun so + // that the next DryRun reconciliation shows all resources as being removed. + // The ClusterSummary stays alive so that if the user reverts the selector + // change there is no unnecessary undeploy+redeploy cycle. + if err := clearClusterSummaryRefs(ctx, c, cs, profileScope.GetSpec().SyncMode); err != nil { + profileScope.Error(err, fmt.Sprintf("failed to clear ClusterSummary refs for cluster %s/%s", + cs.Namespace, cs.Name)) + return err + } + if err := createClusterReport(ctx, c, profileScope.Profile, clusterRef); err != nil { + profileScope.Error(err, fmt.Sprintf("failed to create ClusterReport for cluster %s/%s", + cs.Namespace, cs.Name)) + return err + } + continue + } + currentClusterSummary, err := updateClusterSummary(ctx, c, profileScope, cs, clusterRef) if err != nil { profileScope.Error(err, fmt.Sprintf("failed to update ClusterSummary for cluster %s/%s", @@ -867,6 +894,33 @@ func cleanClusterSummaries(ctx context.Context, c client.Client, profileScope *s return nil } +// clearClusterSummaryRefs zeros out the deployable content of a ClusterSummary and sets its +// syncMode without deleting it. Used in DryRun mode when a cluster stops matching: the next +// DryRun reconciliation will report all resources as pending removal, while the ClusterSummary +// remains so it can be recovered cheaply if the selector is reverted. +func clearClusterSummaryRefs(ctx context.Context, c client.Client, + clusterSummary *configv1beta1.ClusterSummary, syncMode configv1beta1.SyncMode) error { + + cs := &configv1beta1.ClusterSummary{} + if err := c.Get(ctx, types.NamespacedName{Namespace: clusterSummary.Namespace, Name: clusterSummary.Name}, cs); err != nil { + return err + } + + if cs.Spec.ClusterProfileSpec.HelmCharts == nil && + cs.Spec.ClusterProfileSpec.PolicyRefs == nil && + cs.Spec.ClusterProfileSpec.KustomizationRefs == nil && + cs.Spec.ClusterProfileSpec.SyncMode == syncMode { + + return nil + } + + cs.Spec.ClusterProfileSpec.HelmCharts = nil + cs.Spec.ClusterProfileSpec.PolicyRefs = nil + cs.Spec.ClusterProfileSpec.KustomizationRefs = nil + cs.Spec.ClusterProfileSpec.SyncMode = syncMode + return c.Update(ctx, cs) +} + func updateClusterSummarySyncMode(ctx context.Context, c client.Client, clusterSummary *configv1beta1.ClusterSummary, syncMode configv1beta1.SyncMode) error { From eb3687c96a2209d6e7f0f267278a0b8d0a01990e Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Sun, 26 Apr 2026 18:56:52 +0200 Subject: [PATCH 2/2] multi-arch image push, SBOM generation and cosign signing On every v* tag push this workflow: - Builds and pushes a multi-arch (amd64 + arm64) image to Docker Hub using docker buildx - Signs the image with cosign keyless signing (no key management required, identity is tied to the GitHub Actions OIDC token) - Generates an SBOM by scanning the pushed image (capturing base image packages in addition to Go dependencies) - Attaches the SBOM as a signed DSSE attestation to the image in the Docker Hub registry - Uploads the SBOM files (spdx-json, cyclonedx-json) to the GitHub release as a convenience for consumers that don't use OCI tooling Any user can verify the image and SBOM came from this repo's CI and were not tampered with: ``` cosign verify \ --certificate-identity-regexp \ "https://github.com/projectsveltos/addon-controller/.github/workflows/release.yaml@refs/tags/v.*" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ docker.io/projectsveltos/addon-controller:VERSION cosign verify-attestation --type spdxjson \ --certificate-identity-regexp \ "https://github.com/projectsveltos/addon-controller/.github/workflows/release.yaml@refs/tags/v.*" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ docker.io/projectsveltos/addon-controller:VERSION ``` --- .github/workflows/release.yaml | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..67075469 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,86 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write # upload assets to the GitHub release + id-token: write # keyless OIDC signing via Sigstore + +jobs: + release: + runs-on: ubuntu-latest + env: + IMAGE: docker.io/projectsveltos/addon-controller + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5730b3d9a2f25d8890f7d5d06a7c9b820024d8f # v3.10.0 + + - name: Log in to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push image + id: build + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + with: + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ env.IMAGE }}:${{ github.ref_name }} + + - name: Install cosign + uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 + + - name: Build and push image (git variant) + id: build-git + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + with: + file: Dockerfile_WithGit + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ env.IMAGE }}-git:${{ github.ref_name }} + + - name: Sign images + run: | + cosign sign --yes ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} + cosign sign --yes ${{ env.IMAGE }}-git@${{ steps.build-git.outputs.digest }} + + - name: Install syft + # Pin syft to a specific version. Check for new releases at https://github.com/anchore/syft/releases and bump this version periodically. + run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.18.0 + + - name: Generate SBOM + # Scan the image rather than the source tree so the SBOM reflects what is + # actually deployed, including base image packages. + run: | + syft scan ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} \ + -o spdx-json=sbom.spdx.json \ + -o cyclonedx-json=sbom.cyclonedx.json + + - name: Attest SBOM + # Stores a signed DSSE attestation in the registry, linked to the image digest. + # Consumers can retrieve and verify it with: cosign verify-attestation --type spdxjson IMAGE + run: | + cosign attest --yes \ + --predicate sbom.spdx.json \ + --type spdxjson \ + ${{ env.IMAGE }}@${{ steps.build.outputs.digest }} + cosign attest --yes \ + --predicate sbom.spdx.json \ + --type spdxjson \ + ${{ env.IMAGE }}-git@${{ steps.build-git.outputs.digest }} + + - name: Upload SBOMs to release + uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2.0.8 + with: + files: | + sbom.spdx.json + sbom.cyclonedx.json