Skip to content

fix(backupstrategy-controller): repair lookup-gated backup objects - #3524

Merged
IvanHunters merged 6 commits into
cozystack:mainfrom
mattia-eleuteri:fix/backupstrategy-controller-lookup-gate
Aug 10, 2026
Merged

fix(backupstrategy-controller): repair lookup-gated backup objects#3524
IvanHunters merged 6 commits into
cozystack:mainfrom
mattia-eleuteri:fix/backupstrategy-controller-lookup-gate

Conversation

@mattia-eleuteri

@mattia-eleuteri mattia-eleuteri commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Fixes #3518.

Objects in packages/system/backupstrategy-controller and packages/system/bucket are gated on a Helm lookup of an object that is still being created. When the lookup is empty the object is skipped — and since helm-controller does not re-render a release whose chart and values did not change (drift detection is off on operator-generated HelmReleases), the skip is permanent, not a bootstrap window. On the reporter's cluster the install-timing window was 4 seconds and the objects were still missing months later:

  • the 7 default backup Strategy CRs and the Velero BackupStorageLocation, gated on include "backupstrategy-controller.bucketName" → a lookup of BucketClaim;
  • the <bucket>-<user>-credentials Secret (packages/system/bucket/templates/user-credentials.yaml), gated on a lookup of the COSI Secret.

Only BackupClass cozy-default was present, because it renders unconditionally. Its own comment predicted this case while assuming "Flux re-renders the chart", which does not happen — the same trap packages/core/platform/sources/cozystack-basics.yaml already documents: "Drift detection is off on operator-generated HelmReleases, so a capability gate in the templates would render the policy out at first install and never add it back."

Consequence: migration 50 (etcd adoption, v1.6.0) is fail-closed on the snapshot target. It resolves the target from the projected credentials Secret, which has no source without <bucket>-<user>-credentials, so its Job reboots forever and the cluster cannot be upgraded at all.

1. backupstrategy-controller reconciles the objects into existence

New leader-elected DefaultObjectsGate runnable (internal/backupcontroller/default_objects_gate.go), modelled on the existing SystemCredentialsProjector. Every minute:

  • resolves the bucket name from the projector's source Secret (parseSourceSecret) — no new dependency, no lookup, and it covers the external-S3 path too. While it is unresolvable the gate does nothing: forcing then would only re-run the same empty lookup;
  • reads BackupClass cozy-default as the manifest of what must exist (its strategyRefs), plus the cozy-default BSL when velero.bslEnabled;
  • when any is missing, stamps reconcile.fluxcd.io/forceAt and requestedAt on its own HelmRelease. Both are required: requestedAt alone only requests a reconcile, which is a no-op for an unchanged release; forceAt is what triggers a real Helm upgrade, and it is only honoured together with requestedAt;
  • throttled to one forced upgrade per 5 minutes, and a no-op in the steady state. A strategyRef whose CRD is not installed is not counted as missing, so a missing CRD cannot drive an endless force loop.

Two new metrics: cozystack_backup_default_objects_missing{backupclass} — the alert that would have caught this, since it is non-zero regardless of the HelmRelease's Ready condition — and cozystack_backup_default_objects_force_reconciles_total.

RBAC delta is two lines: patch on helmreleases (get was already there) and get on backupstoragelocations. Both accesses are point Gets through the dynamic client, so no cluster-wide informer and no list/watch.

Off switch: backupStorage.reconcileDefaultObjects: false.

Why not a render-time fail in this chart: it would deadlock. templates/bucket.yaml in this same chart is the producer of the Bucket whose BucketClaim the lookup reads, so a failed render never applies it and the condition can never resolve.

Why not template the objects in Go: their bodies are values-driven (endpoint, region, forcePathStyle, the Altinity strategy's whole PodTemplateSpec). Helm stays their single author; the gate only makes sure the render that produces them actually happens.

Why not dependsOn: an edge guarantees the Bucket CR is applied, not that its claim is reconciled, so it does not close the race.

2. The gate also repairs the credentials Secret, one release earlier

The <bucket>-<user>-credentials Secret is the same trap as the Strategy CRs, one release up the chain: packages/system/bucket/templates/user-credentials.yaml renders it behind a lookup of the COSI Secret, and skips it just as permanently. It is also the first domino — the projector reads it, so without it there is no cozy-backups-creds, no strategy, no Velero, and no migration 50.

An earlier revision of this PR made that render fail so the release would retry and converge. @IvanHunters pointed out that Helm cannot render a partial set, so the fail aborts the whole <bucket>-system release — the other users' Secrets and the bucket UI Deployment/Service/Ingress/HTTPRoute with them. One declared user whose BucketAccess never provisions would take down every other user of that bucket, and on an installed release park it in Failed, blocking every later upgrade. That was a real blast-radius regression on a universal path, and it is gone.

The repair now lives in the gate, where it is per-object:

  • while the source Secret is absent, or carries no bucket name, the gate stamps forceAt + requestedAt on the bucket's bucket-<name>-system HelmRelease instead of doing nothing;
  • no self-deadlock: the BucketClaim and the BucketAccess whose COSI Secret that lookup reads are rendered unconditionally by the parent release (packages/apps/bucket/templates/bucketclaim.yaml), not by the one being forced;
  • no RBAC delta: patch on helmreleases is already cluster-scoped;
  • the two releases are throttled independently. They are forced in sequence on a bootstrap (credentials, then the objects the resolved bucket unblocks), so one shared timestamp would delay the second by a full MinForceInterval for no reason;
  • coordinates come from the chart under provisionBucket, and are omitted on external S3, where the Secret is admin-managed and no release renders it.

The bucket chart keeps its partial render. Its comment now explains why the skip is not self-healing and why failing is the wrong lever, and a test renders the UI with every user unresolvable so a reintroduced fail fails the suite.

Scope this narrows, deliberately: tenant buckets keep the pre-existing silent skip. The gate owns only the platform bucket, which is the cluster-breaking path. Generalising the repair to every bucket needs a controller that owns tenant buckets and is a separate change.

Two more corrections from the same review:

  • cozystack_backup_default_objects_missing was only written on the happy path, so the state this PR exists to catch reported 0 and the documented alert never fired. The credentials Secret now counts in missing, so an absent or rotated-away Secret moves the gauge. On an API error the gauge is deliberately left alone rather than flapping, and a new cozystack_backup_default_objects_check_errors_total marks it stale; the doc pairs the two instead of overstating the gauge. force_reconciles_total gained namespace/name, since two releases can now be forced.
  • forceHelmRelease does a point Get and skips a release with spec.suspend: true. helm-controller ignores both annotations while suspended (cozyhr suspend sets exactly that), so the gate was re-stamping every MinForceInterval for the whole suspension and climbing the force counter — which the runbook attributes to a render that is not producing the objects. The skip is logged and not counted.

3. Corrected comments and docs

_helpers.tpl, backupclass-default.yaml, values.yaml and docs/operations/backup-classes.md all claimed the skip self-heals on the HelmRelease interval, and the doc suggested a flux reconcile that does not re-render. They now say it does not, and why. The two-release manual recovery for already-affected clusters is documented, including the easy-to-miss part: the credentials Secret is rendered by the bucket-<name>-system release, not by bucket-<name>.

Manual recovery for already-affected clusters

Only needed on a version without the gate. A plain flux reconcile helmrelease does nothing — it does not re-render.

# 0. Confirm the bucket name is resolvable first — forcing before that
#    just re-runs the same empty lookup.
kubectl -n tenant-root get bucketclaim bucket-cozy-backups -o jsonpath='{.status.bucketName}'

# 1. The credentials Secret. Rendered by the -system release, NOT by
#    bucket-cozy-backups — easy to miss, and the projector (hence every
#    strategy, Velero, and migration 50) has no source without it.
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
kubectl -n tenant-root annotate helmrelease bucket-cozy-backups-system \
  reconcile.fluxcd.io/forceAt="$ts" reconcile.fluxcd.io/requestedAt="$ts" --overwrite

# 2. The Strategy CRs and the Velero BSL.
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
kubectl -n cozy-backup-controller annotate helmrelease backupstrategy-controller \
  reconcile.fluxcd.io/forceAt="$ts" reconcile.fluxcd.io/requestedAt="$ts" --overwrite

Validation

  • go test ./internal/... -count=1 — pass, including 15 cases for the gate: force on missing, no-op in the steady state, throttling, unmapped kinds ignored, BSL skipped when disabled, patch failure surfaced without advancing the throttle, absent HelmRelease tolerated, plus force-on-absent-Secret, force-on-empty-bucket-name, external-S3 reports-but-does-not-force, independent throttles, suspend skip on each of the two releases, gauge written on the unresolved path, and errors counter on a failed check.
  • make helm-unit-tests — pass, with 2 new suites: 3 cases for the bucket chart (partial render preserved, and the UI still renders with every user unresolvable — the guard against reintroducing the fail), 7 for the gate's env + RBAC wiring. Those include that .Release.Name and .Release.Namespace match the HelmRelease the operator generates (releaseName becomes the HR's metadata.name, and no spec.releaseName/targetNamespace is set, so the Helm release name equals the HR name), that the bucket release coordinates track a renamed bucket together with systemSecretName, and that they are unwired on provisionBucket: false.
  • make generate — no diff.
  • make go-unit-tests rd-presets-check test-check-readiness migrations-target-check — pass.
  • make bats-unit-tests — every suite passes except hack/migration-seaweedfs-db-adopt.bats, which needs a Docker daemon not available in this environment ("docker is required: these tests run the migrations inside alpine"). Unrelated to this diff.

Not verified — please read this before approving. There was still no live-cluster run. What that gap covers is smaller than it was: dropping the chart fail removed the universal behaviour change on every bucket's install path, so there is no longer a render-blind failure mode whose convergence has to be observed. What remains is one mechanism — forceAt + requestedAt on an unchanged release — applied to two releases instead of one, argued from read code rather than an observed install: Strategy.Name = RetryOnFailure on operator-generated HelmReleases (internal/operator/package_reconciler.go), and bucketName = "bucket-" + claim.UID in packages/system/objectstorage-controller/images/objectstorage/patches/92-bucketclaim-propagate-ready.diff. It is the same procedure as the manual recovery below, which has been run by hand on the reporter's cluster. Someone with a bootstrap cluster should still confirm that the gate fires within a minute or two of the bucket becoming ready, and that forcing bucket-<name>-system materialises the credentials Secret.

Screenshots

Not applicable: no UI change. This PR touches a controller, two system charts, and docs.

Downstream repositories

Walked file by file against the diff of this PR.

website — affected, follow-up issue opened. content/en/docs/next/operations/services/backup-classes.md mirrors this repo's docs/operations/backup-classes.md and carries the exact stale claim this PR corrects, plus a flux reconcile helmrelease recovery suggestion that has no effect. operations/configuration/platform-package.md enumerates the forwarded backupStorage keys, and reconcileDefaultObjects is admin-settable through spec.components.platform.values.backupStorage (the whole block is deep-merged), so that list becomes incomplete. Filed as an issue rather than a PR because the wording depends on the shape this PR lands in, and whether to touch the frozen v1.5/v1.6 copies is a maintainer call. No existing website issue or PR covered it.

terraform-provider-cozystack — not affected. Checked, not assumed: packages/apps/** is untouched by this diff (git show --stat HEAD -- packages/apps/ is empty), so no app values.schema.json, no values.yaml default, no version enum, and no ApplicationDefinition kind/plural/release.prefix changed. reconcileDefaultObjects is a key on a system chart, which has no values.schema.json and is not modelled by the provider. (An earlier revision added requireUserCredentials to the bucket system chart; that is gone, and no key was added to apps/bucket, so the app API is untouched.) The <bucket>-<user>-credentials Secret the provider looks up by literal name is not renamed — this PR only changes whether it gets created, which if anything removes a case where the provider silently read null from a Secret that never existed.

Everything else: no hack/ layout or make-target change (ccp, external-apps-example, cozyhr), no ApplicationDefinition CRD change, no namespace rename, no node prerequisites in hack/e2e-prepare-cluster.bats (talm, ansible-cozystack), no cozyhr.cozystack.io/values-files annotation or chart-source-kind change, no service-proxy-name / wholeIP / allowICMP rename (cozy-proxy), no telemetry metric or label rename (cozystack-telemetry-server — the two metrics here are new, not renamed), no node or network requirement change (examples).

Release note

fix(backupstrategy-controller): the default backup Strategy CRs, the Velero BackupStorageLocation and the bucket user-credentials Secret were skipped permanently when their install-time Helm `lookup` came back empty, leaving clusters with a BackupClass but no strategies and blocking the v1.6.0 etcd migration. The controller now detects the missing objects and forces the Helm upgrade that creates them, including on the bucket release that renders the credentials Secret.

Summary by CodeRabbit

  • New Features

    • Added automatic detection and recovery for missing default backup resources.
    • Missing resources trigger a throttled Helm upgrade once bucket configuration is available.
    • Added configuration to enable or disable default-object reconciliation.
    • Added validation requiring user credential Secrets during bucket installation by default, with an offline rendering option.
  • Bug Fixes

    • Improved recovery from incomplete backup and Velero storage configuration.
  • Documentation

    • Expanded operational guidance, monitoring metrics, alerts, and manual recovery steps.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@dosubot dosubot Bot added area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/bug Categorizes issue or PR as related to a bug labels Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The backup strategy controller detects missing default backup objects after bucket provisioning and forces throttled Flux HelmRelease upgrades. The chart wires the gate, adds RBAC and configuration, and validates the behavior. Bucket rendering now fails on missing credentials unless offline mode is enabled.

Changes

Default backup object reconciliation

Layer / File(s) Summary
Credential availability handling
packages/system/bucket/templates/user-credentials.yaml, packages/system/bucket/values.yaml, packages/system/bucket/tests/user_credentials_test.yaml
Missing user credential Secrets fail rendering by default. Offline rendering can disable this requirement.
Missing object detection and HelmRelease forcing
internal/backupcontroller/default_objects_gate.go
DefaultObjectsGate checks bucket readiness, referenced strategy objects, and optional Velero storage locations, then applies throttled Flux annotations.
Controller wiring and chart permissions
cmd/backupstrategy-controller/main.go, packages/system/backupstrategy-controller/templates/deployment.yaml, packages/system/backupstrategy-controller/values.yaml, packages/system/backupstrategy-controller/templates/rbac.yaml, packages/system/backupstrategy-controller/templates/_helpers.tpl, packages/system/backupstrategy-controller/templates/backupclass-default.yaml
The controller conditionally registers the gate. The chart supplies its configuration and permissions, and documents the forced Helm upgrade flow.
Gate behavior validation and operations
internal/backupcontroller/default_objects_gate_test.go, packages/system/backupstrategy-controller/tests/default_objects_gate_test.yaml, docs/operations/backup-classes.md
Tests and documentation cover missing objects, steady state, throttling, disabled paths, RBAC, recovery, and metrics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DefaultObjectsGate
  participant KubernetesAPI
  participant FluxHelmRelease
  DefaultObjectsGate->>KubernetesAPI: Read credentials and backup configuration
  DefaultObjectsGate->>KubernetesAPI: Check referenced default objects
  DefaultObjectsGate->>FluxHelmRelease: Patch requestedAt and forceAt
  FluxHelmRelease->>KubernetesAPI: Reconcile missing resources
Loading

Possibly related issues

  • cozystack/cozystack issue 3236: The gate and BackupClass/Velero wiring support the default VM backup flow.
  • cozystack/website issue 639: The documentation and forced HelmRelease recovery flow address the same backup-class self-healing behavior.

Suggested labels: area/storage, area/testing

Suggested reviewers: ivanhunters, myasnikovdaniil

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses [#3518] by forcing Helm upgrades for missing backup objects and making missing bucket credentials fail loudly.
Out of Scope Changes check ✅ Passed The code, chart, documentation, RBAC, metrics, and tests directly support the linked issue objectives.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the backupstrategy-controller fix for lookup-gated backup objects, which is the main change in the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review label Aug 3, 2026
@mattia-eleuteri mattia-eleuteri added the backport Should change be backported on previous release label Aug 3, 2026
@github-actions github-actions Bot added the size/XXL This PR changes 1000+ lines, ignoring generated files label Aug 3, 2026
The default Strategy CRs and the Velero BackupStorageLocation are
Helm-templated behind a `lookup` of the BucketClaim the same chart
creates, and the <bucket>-<user>-credentials Secret behind a `lookup` of
the COSI Secret. When those lookups are empty at install time the objects
were silently skipped, and helm-controller does not re-render a release
whose chart and values did not change (drift detection is off on
operator-generated HelmReleases), so the skip was permanent: clusters ran
for months with only BackupClass cozy-default and no Strategy CRs at all.
That also fail-closes the pre-adoption snapshot in the v1.6.0 etcd
migration, which reads the projected credentials Secret.

Add a DefaultObjectsGate runnable to backupstrategy-controller. Once the
bucket name is resolvable from the projector's source Secret, it checks
that every object cozy-default routes to exists and forces one real Helm
upgrade (reconcile.fluxcd.io/forceAt + requestedAt) when any is missing.
Helm remains the objects' only author; the gate only makes sure the render
that produces them actually happens. It cannot be a render-time `fail` in
this chart: the chart is the producer of the Bucket its own lookup reads,
so a failed render would deadlock the condition.

The bucket chart's user-credentials template CAN fail safely — the
BucketAccess that produces the COSI Secret belongs to the parent release
and that release retries forever — so make it fail loudly instead of
skipping, with requireUserCredentials=false as the offline-render escape.

Also correct the chart and doc comments that claimed Flux re-renders on
its interval, and document the two-release manual recovery for clusters
already affected.

Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
@mattia-eleuteri
mattia-eleuteri force-pushed the fix/backupstrategy-controller-lookup-gate branch from 5fed9ea to 51ad226 Compare August 3, 2026 15:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/backupcontroller/default_objects_gate.go (2)

154-168: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse the manager's RESTMapper instead of building a second one.

SetupWithManager creates a new apiutil.NewDynamicRESTMapper and an HTTP client only to satisfy meta.RESTMapper. Use mgr.GetRESTMapper() unless DefaultObjectsGate actually needs a separate discovery provider; otherwise this starts a second RESTMapper discovery cache for the same config.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/default_objects_gate.go` around lines 154 - 168,
Update DefaultObjectsGate.SetupWithManager to assign g.RESTMapper from
mgr.GetRESTMapper() instead of creating a separate HTTP client and
apiutil.NewDynamicRESTMapper; retain the dynamic client initialization and
existing error handling, and remove the now-unused RESTMapper construction.

105-118: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Update the RBAC comment to include patch for the dynamic client.

internal/backupcontroller/default_objects_gate.go says this field needs only get RBAC, but forceHelmRelease also calls Patch on the HelmRelease. The chart already grants get and patch for helm.toolkit.fluxcd.io/helmreleases; make the comment match that usage.

📝 Suggested comment fix
 	// Interface is a dynamic client used for the per-object existence
 	// checks and the HelmRelease annotation patch. Going through the
 	// dynamic client keeps these reads off the controller-runtime cache,
-	// so they need only `get` RBAC and start no cluster-wide informers.
+	// so they need only `get` (existence checks) and `patch` (forcing the
+	// HelmRelease) RBAC, and start no cluster-wide informers.
 	dynamic.Interface
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/default_objects_gate.go` around lines 105 - 118,
Update the RBAC description above DefaultObjectsGate’s embedded
dynamic.Interface to state that the dynamic client requires both get and patch
permissions, reflecting forceHelmRelease’s HelmRelease patch operation while
retaining the existing cache/informer explanation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/backupcontroller/default_objects_gate.go`:
- Around line 189-220: Bound each periodic invocation in
DefaultObjectsGate.checkAndLog with a timeout shorter than g.Period before
passing the derived context to Check, and cancel it after the check completes.
Preserve the manager context so shutdown still propagates, and ensure the
timeout is applied to every ticker-driven check rather than only initial
startup.
- Around line 222-244: Update DefaultObjectsGate.Check’s source Secret lookup to
treat apierrors.IsNotFound(err) as an expected no-op, returning nil, false, nil
just like the empty creds.bucket case; continue propagating other Get errors
unchanged.

---

Nitpick comments:
In `@internal/backupcontroller/default_objects_gate.go`:
- Around line 154-168: Update DefaultObjectsGate.SetupWithManager to assign
g.RESTMapper from mgr.GetRESTMapper() instead of creating a separate HTTP client
and apiutil.NewDynamicRESTMapper; retain the dynamic client initialization and
existing error handling, and remove the now-unused RESTMapper construction.
- Around line 105-118: Update the RBAC description above DefaultObjectsGate’s
embedded dynamic.Interface to state that the dynamic client requires both get
and patch permissions, reflecting forceHelmRelease’s HelmRelease patch operation
while retaining the existing cache/informer explanation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ffac70f7-518f-45b5-9e5f-624cc4bc859b

📥 Commits

Reviewing files that changed from the base of the PR and between f1f3836 and 5fed9ea.

📒 Files selected for processing (13)
  • cmd/backupstrategy-controller/main.go
  • docs/operations/backup-classes.md
  • internal/backupcontroller/default_objects_gate.go
  • internal/backupcontroller/default_objects_gate_test.go
  • packages/system/backupstrategy-controller/templates/_helpers.tpl
  • packages/system/backupstrategy-controller/templates/backupclass-default.yaml
  • packages/system/backupstrategy-controller/templates/deployment.yaml
  • packages/system/backupstrategy-controller/templates/rbac.yaml
  • packages/system/backupstrategy-controller/tests/default_objects_gate_test.yaml
  • packages/system/backupstrategy-controller/values.yaml
  • packages/system/bucket/templates/user-credentials.yaml
  • packages/system/bucket/tests/user_credentials_test.yaml
  • packages/system/bucket/values.yaml

Comment thread internal/backupcontroller/default_objects_gate.go
Comment thread internal/backupcontroller/default_objects_gate.go
… absent Secret

Two defects found in review of the periodic recovery loop.

checkAndLog passed the manager's root context straight into Check, which
makes several sequential API calls. That context is only cancelled at
shutdown, so an API server stalling on any one of them blocked the call
indefinitely. The ticker loop is sequential, so one stuck call stopped
every later check for the life of the pod: the recovery this gate exists
to provide went silent, leaving a stale gauge and no further log line,
with only a restart to bring it back. Each check now runs under a
timeout of half the Period, capped at 30s, so it can never overlap the
next tick.

Check also propagated a NotFound on the projector's source Secret,
although the very next branch treats an empty bucket name as an expected
no-op. Those are the same bootstrap state seen a moment apart, so the
absent Secret logged a check failure on every tick of the whole
bootstrap window for a condition the doc comment frames as graceful.

Both are pinned by tests that fail against the previous behaviour: one
reverts to an error on the absent Secret, the other to a timeout equal
to Period.

Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Both findings are correct, and both are fixed in e85501e. I verified each against the code rather than taking them at face value, and they hold.

Unbounded context. Period defaults to one minute and the loop is sequential, so a single stalled API call did stop every later check for the life of the pod. Each check now runs under a timeout of half the Period, capped at 30s, so it can never overlap the next tick. I derived it from Period rather than hardcoding 30s so the invariant "shorter than the interval" also holds for a caller that configures a short period.

NotFound on the source Secret. Right, and it contradicted the doc comment two lines below, which frames an unresolvable bucket as a graceful no-op. An absent Secret and an empty bucket name are the same bootstrap state seen a moment apart, so the absent Secret now takes the same silent path. Other Get errors still propagate.

Two tests added, both failing against the previous behaviour: one reverts to an error on the absent Secret, the other to a timeout equal to Period. go test ./internal/backupcontroller/ passes.

@IvanHunters

Copy link
Copy Markdown
Collaborator

Verdict

NOT LGTM

The static review surfaced one MAJOR code defect on a supported configuration toggle, plus a set of minor issues and unverified live behavior. Details below; every finding cites file:line and, where a corner was rendered, the render result.

Findings

[MAJOR] templates/backupclass-default.yaml:60-72 + templates/strategy-velero-vminstance-default.yaml:2 / templates/strategy-velero-vmdisk-default.yaml:2 + internal/backupcontroller/default_objects_gate.go:318-355 — with velero.bslEnabled=false the gate enters a permanent forced-upgrade loop (one real Helm upgrade every MinForceInterval, forever).

backupclass-default.yaml emits the Velero/cozy-default-velero-vminstance and .../-vmdisk strategyRefs unconditionally, while the Velero Strategy CRs themselves are gated on and .Values.velero.bslEnabled $bucketName. The veleroes.strategy.backups.cozystack.io CRD is always installed (crds.yaml globs definitions/*.yaml), so RESTMapping succeeds (this is not a meta.IsNoMatchError skip), Get returns NotFound, and both objects are counted as missing forever. Check therefore stamps forceAt+requestedAt every interval indefinitely: cozystack_backup_default_objects_missing is pinned at 2 (the documented alert fires forever as a false positive), cozystack_backup_default_objects_force_reconciles_total climbs without bound, and the whole chart is SSA-reapplied with Flux revision churn every minute. bslEnabled=false is a documented, supported opt-out (values.yaml:148-166), not an exotic state.

Rendered corner: helm template . --set velero.bslEnabled=false --set backupStorage.bucketNameOverride=x produces a BackupClass carrying both Velero strategyRefs and zero kind: Velero objects.

Fix: make the set the gate checks equal to the set that actually renders under the current values. Either gate the Velero strategyRefs in backupclass-default.yaml on velero.bslEnabled as well, or (matching the intent stated in values.yaml) drop velero.bslEnabled from the Strategy CR templates so the Velero CRs still ship and only the BSL is skipped.

[MINOR] internal/backupcontroller/default_objects_gate.go:363 — the meta.IsNoMatchError branch in the BSL check is dead. The BSL Get uses the hardcoded backupStorageLocationGVR directly through the dynamic client, bypassing the RESTMapper; against an absent Velero CRD the apiserver returns a 404 (apierrors.IsNotFound, caught on line 361), and NoMatchError is only produced by the RESTMapper, so line 363 is unreachable and its comment ("Velero CRDs absent: nothing to materialise") describes behavior that never happens. Consequence: if Velero is removed after bootstrap while bslEnabled=true, the BSL is counted missing and the gate force-loops (same class as the MAJOR above). Route the BSL lookup through RESTMapper like the strategy path so a genuinely-absent Velero API is skipped rather than force-looped.

[MINOR] packages/system/bucket/templates/user-credentials.yaml:30-36 — the new fail aborts the entire render on the first user whose COSI Secret is absent. For a bucket with users {a, b} where a's Secret exists but b's BucketAccess is stuck, the whole release now fails (retries: -1 → visible Failed) and the healthy a no longer gets its <bucket>-a-credentials Secret until b converges — previously each present user's Secret rendered independently. It converges eventually, but this is a behavior regression for multi-user buckets. Consider failing per-user (or collecting the missing set) rather than aborting on the first.

[MINOR] packages/system/backupstrategy-controller/values.yaml:157-159 — stale comment: it states that with bslEnabled=false the cozy-default-velero-vminstance/-vmdisk strategy CRs "still ship". The render above shows zero such CRs. This is not only doc drift — it is the false premise behind the MAJOR finding: the gate expects those CRs to exist precisely because the comment (and the author's model) assumed they always render.

Claim mismatches

[PARTIAL] PR body / values.yaml:143 — "cozystack_backup_default_objects_missing … is non-zero regardless of the HelmRelease's Ready condition." The gauge is Set in exactly one place, internal/backupcontroller/default_objects_gate.go:287, on the success path. Every earlier return bypasses it: source Secret NotFound (:261), parse error (:270), bucket unresolved (:275), BackupClass get error (:280), missingObjects error (:285). During the pre-bucket bootstrap window and on any persistent Check error the gauge holds its prior value (0 on a fresh pod). The alert covers the post-resolution state (bucket resolved, objects missing → gauge > 0) but not a cluster stuck before the bucket resolves; the "regardless of Ready" wording overstates the coverage.

Operational risks

  • Live convergence is unverified (render-blind). The core repair chain — forceAt+requestedAt on an operator-generated HelmRelease actually causing helm-controller to run a Helm upgrade, the cozystack-operator's own reconcile not stripping those annotations before helm-controller consumes them, and the gate detecting readiness within its 1-minute tick — cannot be settled by helm template/go test and was not exercised on a cluster. The PR body already flags this. It should be confirmed on a bootstrap dev cluster before merge.
  • Stuck-gate observability is log-only. On a persistent Check error (Forbidden/timeout/malformed source Secret) the error is logged at Info (default_objects_gate.go:233) with no Event and no Ready=False (the Runnable owns no status object), and the missing-gauge freezes at its last value. A gate that silently stops working is detectable only from the log line.

Caveats

  • Throttle is not durable across restarts / leader changes. lastForce is in-memory (default_objects_gate.go:138); a new leader or restarted pod has lastForce == 0 and forces immediately on the first missing-check, so the "one force / 5 min" bound does not hold across pod lifecycle churn. Each force is idempotent, so impact is bounded to extra Helm upgrades, visible via force_reconciles_total.
  • External-S3 / admin-managed Secret without bucketName → permanent no-op. parseSourceSecret returns bucket == "" and Check no-ops (default_objects_gate.go:272-276); the gate never repairs objects on that path (acknowledged in the PR body).
  • Pre-existing, unrelated: packages/system/bucket/templates/ingress.yaml:1 nil-pointers on .Values._namespace.host / ._cluster in an offline helm template (a runtime-injected value). Untouched by this PR; noted only because it blocks whole-chart offline rendering, so the bucket fail corners were validated via the isolated helm-unittest suite instead.

Recommended follow-ups

  • Run an end-to-end test on a bootstrap dev cluster to confirm: (1) the gate forces and the Strategy CRs + cozy-default BSL appear within a minute of the bucket becoming ready; (2) the <bucket>-system release with the new fail converges (retries then renders) rather than parking as Failed; (3) the cozystack-operator reconcile does not strip reconcile.fluxcd.io/forceAt/requestedAt before helm-controller consumes them.

… when velero.bslEnabled=false

The cozy-default BackupClass routes VMInstance/VMDisk to the Velero
Strategy CRs unconditionally, but the chart gates those CRs on
velero.bslEnabled. With the BSL disabled they never render, so the gate
counted them as permanently missing and forced a Helm upgrade every
MinForceInterval forever, climbing force_reconciles_total and pinning the
missing gauge. Skip Velero-kind strategyRefs when the BSL is disabled
(empty VeleroNamespace), the same flag that gates their render. The
regression test now omits the manually-created Velero objects so it fails
without the skip. Correct the values.yaml comment that wrongly claimed the
Strategy CRs still ship in this mode.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
…ESTMapper

The BSL existence check used a hardcoded GVR and the dynamic client, whose
Get returns a plain 404 (IsNotFound) for an unserved API group. The
IsNoMatchError branch meant to skip an absent Velero API was therefore dead
code, and if Velero were uninstalled after bootstrap while bslEnabled=true
the BSL would be counted missing and force a Helm upgrade forever. Route the
lookup through the RESTMapper like the strategy loop, so an absent velero.io
API is a NoMatch we skip. A regression test fails without the change.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
…n the gate fixture

The gate reads the BackupClass as the manifest of what must exist, but the Go
fixture listed only CNPG, Etcd and the two Velero routes while the shipped
backupclass-default.yaml also routes MariaDB and Altinity. Add both to the
fixture, and to the RESTMapper and list-kinds derived from it, so the
expected-object set the tests exercise equals the real route set.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>

@androndo Andrey Kolkov (androndo) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ✅

Reviewed fix/backupstrategy-controller-lookup-gate against main. Verified locally: go build ./internal/backupcontroller/ ./cmd/backupstrategy-controller/ passes and the new gate tests are green.

The bug is real and correctly diagnosed

The default Strategy CRs, the Velero BackupStorageLocation, and the per-user credentials Secret are all Helm-templated behind a lookup of an object the same chart is still creating. On a fresh install the lookup is empty, the templates render nothing, and because helm-controller does not re-render an unchanged, successful release, the skip is permanent rather than a bootstrap window — fail-closing migration 50 (etcd v1.6.0 adoption).

Two layer-appropriate fixes, both hold up

  1. DefaultObjectsGate runnable — leader-elected, ticks every minute, resolves the bucket from the projector's source Secret (no new lookup, covers external-S3), reads BackupClass cozy-default as the manifest of required objects, and stamps forceAt/requestedAt on its own HelmRelease when anything is missing. Correctly skips Velero-kind strategies when VeleroNamespace=="" (matching the chart's and .Values.velero.bslEnabled $bucketName gate), treats an absent CRD as not-missing via RESTMapping + IsNoMatchError, and handles throttling, the both-annotations requirement, and NotFound tolerance for non-Flux installs. Clean off-switch via backupStorage.reconcileDefaultObjects. RBAC delta is minimal and accurate (patch helmreleases + get backupstoragelocations, all through the dynamic client).
  2. bucket chart fails loudly instead of silently skipping. No deadlock: the <bucket>-system HelmRelease sets remediation.retries: -1 and the BucketAccess that produces the COSI Secret is rendered by the parent release, so fail retries to convergence and cannot block its own precondition. Offline helm template is safe (users: {} → empty range).

Tests & docs

13 new Go test funcs cover the happy path plus error/edge paths (bucket unresolved, source Secret absent, throttling, unmapped kinds, Velero-disabled skip, BSL-API-absent skip, patch failure surfaced, absent HR tolerated, disabled config). Helm-unittest exercises the new fail behavior. The PR also corrects the stale docs that claimed the skip self-heals on the Flux interval.

No blocking findings. All reviewed lines are introduced by this diff.

Non-blocking suggestions

  • The env var == release name and ClusterRole-verb-list asserts in tests/default_objects_gate_test.yaml verify one static file against another — the real contract (gate forces the release, objects appear) is only provable by an e2e. Consider whether those two asserts earn their keep.
  • An e2e for the gate (bucket race → objects materialize within a minute) would be the highest-value follow-up.
  • Minor log-wording nit: in Check, the non-Flux-install branch returns forced=false, so checkAndLog logs "still missing, force throttled" — slightly misleading for the absent-HR case, though harmless.

@IvanHunters

Copy link
Copy Markdown
Collaborator

Verdict

NOT LGTM. The DefaultObjectsGate core is sound and well-tested, but the bucket chart's new render-fail changes behaviour on a universal path (every bucket, every cluster) in a way that was not verified on a live cluster, and it regresses blast radius for multi-user buckets with no per-bucket escape.

Findings

[MAJOR] packages/system/bucket/templates/user-credentials.yaml:35 — the fail aborts the entire <bucket>-system release, and one bad user wedges a multi-user bucket permanently.

The fail sits inside range $name, $user := .Values.users (line 30). A single declared user whose COSI Secret is absent aborts the whole release render, which also carries deployment.yaml, service.yaml, ingress.yaml, httproute.yaml. Two concrete regressions vs the prior per-user silent skip:

  • Fresh install of a multi-user bucket where one user's BucketAccess never provisions (misconfigured bucketAccessClassName, COSI failure): the whole bucket — the other users' Secrets and the serving Deployment/Ingress — never comes up, instead of just that one user's Secret being missing.
  • Existing multi-user bucket, add a second user that never provisions: the release parks in Failed and every future legitimate upgrade of that release is blocked.

There is no per-bucket escape (see the next finding), so the operator cannot unwedge one bucket without touching cluster-wide state. The PR body justifies why fail is safe (retries converge, no self-deadlock) but does not surface this all-or-nothing / multi-user tradeoff. For the common single-user case (including the platform cozy-backups bucket) the change is an improvement; the regression is the multi-user-partial-failure corner.

Fix options: scope the failure per-user (render the resolvable users, signal the unresolved one — Helm fail cannot render a partial set, so this likely means a per-user status signal instead of a fatal fail), or expose requireUserCredentials on the parent apps/bucket chart so a single bucket can opt out.

[MAJOR] packages/apps/bucket/templates/helmrelease.yaml:23-25 + packages/system/bucket/values.yaml:9 — the requireUserCredentials escape hatch is unreachable per-bucket.

The parent HR passes only bucketName and users; the apps/bucket chart exposes no knob for requireUserCredentials, and editing the child HR by hand is reverted by the parent's next render. So the only levers for the wedged tenant above are fixing COSI, or setting requireUserCredentials: false in the cluster-wide cozystack-values Secret (valuesFrom), which silently disables the loud-failure guarantee for every bucket on the cluster. The values.yaml comment also states the flag is "only for offline renders", which contradicts using it as a recovery lever.

Additionally: this is a behaviour change on the fresh-install path of every bucket (silent-skip → transient Failed until COSI writes the Secret). The PR's own "Not verified" section asks a reviewer to confirm the fail converges on a bootstrap cluster rather than parking the release. That is render-blind (live helm-controller timing + install-gate behaviour) and cannot be cleared by a static render; it needs a live-cluster run before merge.

[MINOR] internal/backupcontroller/default_objects_gate.go:287 — the cozystack_backup_default_objects_missing gauge is only set on the happy path, so the documented alert is overstated.

Check returns before the Set at line 287 on an absent source Secret (line 264), an empty bucket name (line 275), or a BackupClass/per-object Get error. docs/operations/backup-classes.md:148 claims the gauge is "non-zero whatever the HelmRelease's Ready condition says" and recommends alerting on it. But if the source Secret is deleted later (credential rotation) while objects are broken, the gauge freezes at its last value (0 → alert never fires; or N → stuck non-zero that never clears even after recovery). Either set the gauge in a defer covering every return, or soften the doc claim to state the alert only tracks the resolved-bucket steady state.

[MINOR] internal/backupcontroller/default_objects_gate.go:280 (forceHelmRelease) — a suspended HelmRelease produces a perpetual force loop and a misleading counter.

The gate patches forceAt/requestedAt without checking spec.suspend. cozyhr suspend sets spec.suspend: true (standard dev workflow); helm-controller then ignores the annotations, so while objects are missing the gate re-patches every MinForceInterval forever and cozystack_backup_default_objects_force_reconciles_total climbs. docs/operations/backup-classes.md:149 attributes a climbing counter to "the forced render is not producing the objects (a missing CRD)", pointing the operator at the wrong diagnosis. A point Get of the HR (RBAC already present) before patching, skipping/logging when suspended, closes both.

Caveats

  • No live-cluster verification (static review). The gate's recovery mechanism was verified by reading: the HR is named backupstrategy-controller/cozy-backup-controller by the operator (internal/operator/package_reconciler.go:243-263; buildHelmReleaseSpec sets no spec.releaseName), so .Release.Name matches the patched HR; leader election is on (--leader-elect, replicas: 2) and the gate is a LeaderElectionRunnable; the Secret cache is disabled (cmd/backupstrategy-controller/main.go:170-171); remediation.retries: -1 is set on the -system HR (packages/apps/bucket/templates/helmrelease.yaml:16,19). SSA / admission / live install timing remain render-blind.

Checked and correct

  • Velero Strategy CRs gate on velero.bslEnabled (strategy-velero-vm{instance,disk}-default.yaml:2), so the gate's kind == "Velero" skip and the values.yaml comment are consistent — no force-loop, no doc drift.
  • Gate missingObjects aligns with the chart render gates: all routed strategies gate on $bucketName, which the gate's readiness signal tracks.
  • No migration needed (controller-driven fix).
  • go build / go vet clean; Go tests and both helm-unittest suites green; test fixtures faithful to the production cozy-default BackupClass.

…from the gate, not a chart fail

Addresses the four findings on cozystack#3524.

**The `fail` in the bucket chart (MAJOR).** The <bucket>-<user>-credentials
Secret was made to `fail` the render while its COSI lookup was empty, so the
release would retry and converge instead of skipping the Secret permanently.
Helm cannot render a partial set, so that fail aborts the WHOLE
<bucket>-system release: the other users' Secrets, and the bucket UI
Deployment/Service/Ingress/HTTPRoute with them. One declared user whose
BucketAccess never provisions takes down every other user of that bucket,
and on an already-installed release parks it in Failed, blocking every later
upgrade.

**No per-bucket escape (MAJOR).** Rather than add one, remove the need for
it. The gate already forces the release that renders the lookup-gated
Strategy CRs; the credentials Secret is the same trap one release earlier,
and the gate reads that Secret to resolve the bucket name in the first
place. So while it is absent, or carries no bucket name, force the bucket's
<bucket>-system release instead of doing nothing. That cannot deadlock its
own precondition: the BucketClaim and the BucketAccess whose COSI Secret the
lookup reads are rendered unconditionally by the parent release, not by the
one being forced.

The two releases are throttled independently — they are forced in sequence
on a bootstrap, so a shared timestamp would delay the second by a full
MinForceInterval for no reason. Coordinates come from the chart under
provisionBucket, and are omitted on external S3 where the Secret is
admin-managed and no release renders it. No RBAC delta: patch on
helmreleases is already cluster-scoped.

The chart goes back to a partial render, with the comment now explaining why
the skip is not self-healing and why failing is the wrong lever, and a test
that renders the UI with every user unresolvable so a reintroduced `fail`
fails the suite. Tenant buckets keep their pre-existing behaviour;
generalising the repair to every bucket needs a controller that owns them
and is tracked separately.

**Stale gauge (MINOR).** cozystack_backup_default_objects_missing was only
written on the happy path, so the state the gate exists to catch reported 0
and the documented alert never fired. It is now written on the unresolved
path too — the credentials Secret counts as one of the objects the default
backups depend on, which is what it always was. On an API error the gauge is
deliberately left alone rather than flapping, so a new
cozystack_backup_default_objects_check_errors_total marks it stale, and the
doc pairs the two instead of overstating the gauge.

**Suspended releases (MINOR).** forceHelmRelease now does a point Get and
skips a release with spec.suspend: true. helm-controller ignores forceAt and
requestedAt while suspended (`cozyhr suspend` sets exactly that), so the gate
was re-stamping every MinForceInterval for the whole suspension and climbing
the force counter — which the runbook attributes to a render that is not
producing the objects, the wrong diagnosis. The skip is logged and not
counted, so a climbing counter keeps its documented meaning.

Reported-by: IvanHunters <xorokhotnikov@gmail.com>
Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
Assisted-By: Claude <noreply@anthropic.com>
@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

All four confirmed against the code. Pushed as f859b71.

The two MAJORs: the fail is gone, not mitigated

You are right that fail inside range is all-or-nothing, and I had not weighed what else rides in that release — deployment.yaml, service.yaml, ingress.yaml, httproute.yaml go down with it. Neither fix option you offered actually closes it, though:

  • Scope the failure per-user — Helm cannot render a partial set, as you note. A non-fatal per-user status signal also gives up the only thing the fail was buying, which is a retry that re-runs the lookup.
  • Expose requireUserCredentials on apps/bucket — that is an unwedge lever, but it puts a Helm implementation detail into the public app API (schema, Go types, the Terraform provider), and it only helps an operator who already knows to reach for it.

So I dropped the fail and moved the repair into the gate, which is the mechanism you already found sound.

The gate reads bucket-cozy-backups-system-credentials to resolve the bucket name, and previously did nothing when it was absent — that is the case the chart fail existed to cover. It now forces the bucket-<name>-system release instead. Same annotation pair, same throttle, and per-object rather than per-release, so nothing else in that release is affected and no escape hatch is needed.

Two preconditions I checked rather than assumed:

  • No self-deadlock. The BucketClaim and the BucketAccess whose COSI Secret the lookup reads are rendered unconditionally by the parent release (packages/apps/bucket/templates/bucketclaim.yaml — no lookup, no gate), so forcing the child cannot block its own precondition.
  • No RBAC delta. patch on helmreleases is already granted through a ClusterRole, so reaching tenant-root costs nothing new.

The two releases get independent throttles: on a bootstrap they are forced in sequence (credentials, then the objects the resolved bucket unblocks), so one shared timestamp would have delayed the second by a full MinForceInterval for no reason. Coordinates are plumbed from the chart under provisionBucket, and omitted on external S3 where the Secret is admin-managed and no release renders it.

The chart is back to a partial render, with the comment now stating why the skip is not self-healing and why failing is the wrong lever. Added a case that renders the bucket UI with every user unresolvable, so a reintroduced fail fails the suite rather than being caught in review again.

Scope this narrows, deliberately: tenant buckets keep the pre-existing silent skip. The gate only owns the platform bucket, and that is the cluster-breaking path (projector → every strategy → Velero → migration 50). Generalising it needs a controller that owns tenant buckets, which is a separate change — happy to file it if you want it tracked.

MINOR: stale gauge

Both branches you name were real. Fixed by making the unresolved path a first-class result instead of an early return: the credentials Secret is now counted in missing, which it always was in substance, so credential rotation moves the gauge to 1 rather than freezing at 0.

On API errors I kept the gauge deliberately unwritten — flapping the alert on a transient apiserver error is worse than a stale value — so I added cozystack_backup_default_objects_check_errors_total to mark it stale, and the doc now pairs the two instead of claiming the gauge alone is sufficient. force_reconciles_total also carries namespace/name now, since two releases can be forced.

MINOR: suspended HelmRelease

Fixed as you described: point Get before the patch, skip and log on spec.suspend: true, and no counter increment — so a climbing counter keeps meaning "the forced render is not producing the objects". Documented that a release left suspended is never repaired until resumed.

Verification

go build, go vet, go test ./internal/..., make helm-unit-tests, make generate (no diff), make rd-presets-check test-check-readiness migrations-target-check — all green. 6 new Go cases (force on absent Secret, force on empty bucket name, external-S3 reports-but-does-not-force, independent throttles, suspend skip on each release) and 3 new helm cases (bucket release coordinates, that they track a renamed bucket together with systemSecretName, and unwired on provisionBucket=false).

Still no live-cluster run — I don't have a bootstrap cluster to hand. What that gap covers is now much smaller, which was the point of taking this route: the universal behaviour change on every bucket's install path is gone, so there is no longer a render-blind fail whose convergence has to be observed. What remains unobserved is the same gate mechanism you already reviewed, applied to one more release. The claim that a forced upgrade of bucket-<name>-system re-runs the COSI lookup and materialises the Secret is the documented forceAt+requestedAt behaviour and matches the manual recovery in the PR body, which is a procedure that has been run by hand on the reporter's cluster — but it is read code, not an observed install, and I'd rather say so than imply otherwise.

I'll update the PR body next; section 2 still describes the fail.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

CI note — the two red checks are not from this diff.

Everything else is green: pre-commit, DCO, CodeQL, Analyze (go), API owner review, Plan build, size, label.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

LGTM with non-blocking notes

Static review found no correctness, RBAC, upgrade, or config-matrix defect; the controller is HA-correct, tests are adequate and non-vacuous, and every schema-valid chart corner renders — the only open item is the live-cluster efficacy of the force mechanism, which the author already flags and which belongs to an e2e run.

Findings

[MINOR] packages/system/backupstrategy-controller/templates/deployment.yaml:54, multi-paragraph comment essays ship into the rendered manifest

Lines 54-60 and 68-90 are # YAML comments inside the container env: list, e.g. # DefaultObjectsGate: the Strategy CRs and the Velero BSL are gated. Unlike a {{/* */}} template comment, # comments are not stripped at render: I confirmed by helm template that both blocks appear verbatim in the rendered Deployment. The prose is accurate but belongs on the docs site (it duplicates docs/operations/backup-classes.md); leave a one-line pointer and move the essay out so it cannot drift from the code and does not ride into every rendered manifest.

[MINOR] packages/system/bucket/templates/user-credentials.yaml:1, 36-line explanatory essay in a template comment

The {{/* … */}} block (lines 1-36) is a genuine narrative essay. Because it is a Go-template comment it is stripped before render (so it does not reach the applied object — lower cost than the deployment.yaml case above), but the inline-prose-rot argument still applies: it re-explains the whole gate design next to a template that no longer decides anything. Reduce to a short pointer to default_objects_gate.go / the docs page and keep the design rationale in one canonical place.

Claim mismatches

[UNVERIFIABLE] "make generate — no diff": not executed (generator unavailable in the hermetic clone). Low risk: neither touched chart has a values.schema.json or README.md in-tree, and no schema/README diff is present in the PR, so there is nothing for make generate to regenerate for these charts.

Caveats

  • Core mechanism is not statically verifiable and the author declares no live run. The fix rests on helm-controller honouring reconcile.fluxcd.io/forceAt + requestedAt on an otherwise-unchanged operator-generated HelmRelease AND re-running the lookups during that forced upgrade so the gated Strategy CRs / BSL / credentials Secret materialise. This is standard documented Flux behaviour and the equivalent manual stamp was hand-run on the reporter's cluster, but forcing a live Helm upgrade through helm-controller (SSA against live objects, actual re-render timing) is invisible to helm template and go test. Not executed — reasoned only. Route to a live e2e run (see follow-ups).
  • Leader-failover / controller restart resets the in-memory throttle. lastForce / lastCredentialsForce (default_objects_gate.go:178-179) are process-local and Start runs an immediate checkAndLog before the ticker (:241), so a freshly-elected leader or a restarted pod issues one force immediately if objects are still missing, bypassing MinForceInterval. Bounded and harmless (the stamp is idempotent, a re-render is what is wanted, and steady state is a no-op), so noted, not blocking.
  • Verified sound and left as a checked-and-correct ledger: leader election is enforced (--leader-elect in deployment.yaml:29, replicas: 2, NeedLeaderElection()=true), so annotation-state is written only by the leader — no HA write race; RBAC delta is minimal (patch on helmreleases already cluster-wide for RestoreJob's cross-tenant rename, get on backupstoragelocations read-only); parseSourceSecret resolves the bucket name from both the flat-key and the raw COSI BucketInfo format, so a COSI-format Secret does not drive a permanent force loop; unmapped strategy CRDs and an absent Velero API are skipped (no endless force); velero.bslEnabled=false gates both the BSL check and the Velero strategy skip consistently.

Recommended follow-ups

  • Run a live e2e on a bootstrap cluster: confirm the gate forces bucket-<name>-system, that the credentials Secret then materialises, that the bucket name resolves, and that the second force on backupstrategy-controller creates the 6 Strategy CRs + the cozy-default BSL — within a minute or two, per the PR's own remaining-verification note.
  • backupStrategyController.chBackupClientImage reuse of platform-migrations (values.yaml:2-38) is a pre-existing, already-documented WORKAROUND/TODO (349 MB image, cross-package digest coupling) — out of scope for this PR, tracked in-file; noting so it is not lost.

@IvanHunters
IvanHunters merged commit cfaf2e0 into cozystack:main Aug 10, 2026
13 of 42 checks passed
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for release-1.6:

@github-actions

Copy link
Copy Markdown

Created backport PR for release-1.5:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-3524-to-release-1.5
git worktree add --checkout .worktree/backport-3524-to-release-1.5 backport-3524-to-release-1.5
cd .worktree/backport-3524-to-release-1.5
git reset --hard HEAD^
git cherry-pick -x 51ad22670684b7bc3b7b13bb54403e1aecedc997 e85501e9f5d3dc6effe5e8721b5db073af59bdb4 aa3accd12800fd977678d771c719e89fd064ff5c bf152ba8640da08fd77f486eaa7c4714c6ba6a9d 8b006391c2ba89280096dcee8fd7f85dd3a41c7d f859b710a45d7df4bd255991ab1d980b048d352a
git push --force-with-lease

myasnikovdaniil pushed a commit that referenced this pull request Aug 17, 2026
The default Strategy CRs and the Velero BackupStorageLocation are
templated behind a `lookup` of the BucketClaim the same chart creates,
because the COSI driver assigns the real S3 bucket name
(`bucket-<claim-UID>`) and it cannot be computed at render time. On a
fresh install that lookup is empty, so those templates render nothing.

helm-controller does not re-render a release that succeeded and whose
chart and values did not change, and drift detection is off on
operator-generated HelmReleases, so the skip is permanent: a cluster
that loses the install-time race keeps `BackupClass cozy-default` with
no Strategy CRs and no Velero BSL indefinitely.

Add a DefaultObjectsGate runnable that, once the bucket name is
resolvable from the credentials projector's source Secret, verifies
that every object the BackupClass routes to exists and stamps both
reconcile.fluxcd.io/forceAt and reconcile.fluxcd.io/requestedAt on the
HelmRelease to force the real Helm upgrade that re-runs the lookups.
requestedAt alone is a no-op for an unchanged release, so both
annotations are required. Two Prometheus metrics expose the state
independently of the HelmRelease Ready condition.

Also make the per-user bucket credentials template fail loudly instead
of silently skipping when the source Secret is absent, so this class of
race surfaces as a failed release rather than as a missing Secret.

Backport of #3524 to release-1.5. The conflict in
docs/operations/backup-classes.md was resolved to the post-fix wording:
the pre-fix text claimed Flux repairs the skip on its interval
reconcile and told operators to run `flux reconcile helmrelease`, which
is precisely the misconception this change corrects.

Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Aug 18, 2026
…gated backup objects (#3731)

# Description
Backport of #3524 to `release-1.6`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review backport Should change be backported on previous release backport-previous Backport target — previous release line kind/bug Categorizes issue or PR as related to a bug size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Helm lookup-gated backup objects are skipped once and never created, which makes migration 50 fail-closed

4 participants