Skip to content

fix(migrations): derive the etcd-adoption snapshot target from the projected bucket creds - #3335

Merged
myasnikovdaniil merged 3 commits into
mainfrom
fix/etcd-adopt-backup-endpoint
Jul 20, 2026
Merged

fix(migrations): derive the etcd-adoption snapshot target from the projected bucket creds#3335
myasnikovdaniil merged 3 commits into
mainfrom
fix/etcd-adopt-backup-endpoint

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Migration 50 takes a mandatory S3 safety snapshot before adopting live etcd onto the v1alpha2 operator. It is a pre-upgrade hook, so every chart-rendered resource it reads still belongs to the version being upgraded from. It resolved the snapshot target from the live cozy-default-etcd Strategy CR — and on a v1.5.x cluster that CR cannot be trusted to describe a reachable target. The platform upgrade is gated behind the snapshot, so nothing can self-heal first: the corrected backupstrategy-controller.endpoint helper only reconciles after the upgrade this hook blocks.

Two failure modes, both a hard block

1. The Strategy CR does not exist. strategy-etcd-default.yaml is guarded by {{- if $bucketName -}}, whose helper lookups the BucketClaim's .status.bucketName. That BucketClaim is created by the same chart, so on first render the lookup is empty and the Strategy is silently skipped. Helm lookup only runs at install/upgrade time, so a cluster that reached 1.5.x in one hop never grows the CR on reconcile — a forced reconcile does not produce it.

2. The Strategy CR exists carrying http://seaweedfs-s3.tenant-root.svc.cozy.local:8333 (the static v1.5.x backupStorage.endpoint default). Cozystack ships SeaweedFS with enableSecurity=true, so :8333 is a TLS listener — plaintext against it fails the handshake and every snapshot dies. Fixing the scheme is not sufficient: as backupstrategy-controller.endpoint documents, the Etcd Strategy's S3 schema has no caCert/insecureSkipVerify, so the self-signed in-cluster endpoint is unusable at any scheme. A trusted-cert endpoint is required.

The fix

Take the coordinates from cozy-backups-creds instead. The projector writes it from the COSI-provisioned bucket's system credentials — bucket provisioning, not chart values — so it describes the live bucket regardless of which version rendered the charts. It carries endpoint, bucketName, region and forcePathStyle alongside the AWS keys, and resolve_platform_backup_args already read this Secret to validate those keys, so preferring it adds no new dependency.

Both modes disappear: mode 1 because the CR is no longer needed, mode 2 because its endpoint is ignored.

Classifying COSI vs. external S3

The projected endpoint is a bare host fronted by the always-TLS S3 ingress, so on the platform-managed path we force https:// — exactly what backupstrategy-controller.endpoint does. But the admin-managed external-S3 case (provisionBucket: false) must keep .Values.backupStorage.endpoint verbatim, since it may legitimately be plaintext against a private store. The two therefore have to be told apart.

Endpoint presence cannot do that. ProjectBackupCredentials substitutes BACKUP_STORAGE_ENDPOINT when the source Secret is silent and then fails loud rather than projecting an endpoint-less Secret, so a projected endpoint key is always present and always scheme-stripped — for external S3 too. Classifying on its absence would force https:// onto a plaintext external store and never consult the CR, which is a regression against a supported, documented configuration.

Instead the hook asks whether the bucket the credentials name is one COSI actually provisioned: it matches the projected bucketName against .status.bucketName of any BucketClaim. That is keyed on data rather than names, so it is independent of backupStorage.namespace / .bucketName / bucketNameOverride — all supported Package-CR overrides the hook cannot see — and a claim-name lookup would misclassify a renamed bucket as external.

Resolution is then:

cluster state endpoint other coordinates
a live BucketClaim claims the bucket projected host, forced https:// projected Secret
no claim claims the bucket (external S3) Strategy CR, scheme verbatim Strategy CR
only a Terminating claim claims it refuse
BucketClaim API unreadable refuse

On the external path every coordinate comes from the CR, never a Secret/CR hybrid: strategy-etcd-default.yaml pairs bucket with credentialsSecretRef: cozy-backups-creds, so CR coordinates plus the projected credentials is exactly the pairing the cluster's real etcd BackupJobs already use.

Deliberate behaviour changes

Two states now refuse where main proceeded. Both are cases where the classifier cannot read its input, and adopting live etcd is irreversible, so it fails closed with an actionable message rather than guessing:

  • A Terminating BucketClaim on the projected bucket. Reachable via COSI → external reusing a retained bucket name, where the bucketclaim-protection finalizer wedges the corpse permanently. Both readings are reachable and guessing wrong in either direction also ends in a block — just with a confusing TLS/handshake error instead of a precise one. Remedy: let COSI reap the claim, or clear its finalizer, then re-run (migration 50 is idempotent).
  • A sustained BucketClaim API read failure on a cluster that has COSI installed. A genuinely absent CRD is still a definitive, zero-cost "external" answer and costs no retries.

The escape hatch is now reachable

The script has always supported ETCD_ADOPT_SKIP_BACKUP=1 for clusters that intentionally have no backup storage, but migration-hook.yaml passed no such variable, so the failure paths advised an action the platform made impossible. It is now plumbed as migrations.etcdAdoptSkipBackup, an explicit opt-in defaulting to off, and the operator-facing messages name that knob rather than a variable nobody can set. It also unblocks the two refuse states above.

The template normalises to the exact string the script matches: a bare bool renders true, which the script's = "1" test would accept into the Job spec and then silently ignore — the worst outcome for a valve reached for under duress. Anything unrecognised resolves to "0", so taking the hatch requires an affirmative spelling and never a typo. A Platform Package predating the key renders cleanly as off.

The script's own check was widened alongside it to accept 1|true|yes case-insensitively, so a hand-run image honours what an operator actually types. That is a behaviour change beyond plumbing, and it has a cost worth naming: if the template's normalisation were ever dropped, a raw bool would previously have failed safe (snapshot taken) and now fails unsafe (snapshot skipped). The rendered value is pinned by a unit test to prevent that.

migration-hook.yaml had no test coverage at all before this: the Job renders only when a lookup of cozystack-version reports a version below target, and lookup returns nil under helm template, so lint and CI never rendered the Job. That is structurally why an unreachable escape hatch shipped unnoticed. The new suite mocks the ConfigMap so the Job is rendered and its env asserted by value.

Tests

The bats fixture modelled cozy-backups-creds as carrying only the AWS keys, and test 1 asserted the broken plaintext endpoint as expected output — written against the implementation rather than the requirement, against a mock that did not match a real cluster, which is why this shipped green. The fixture now models what the projector and COSI really produce.

Added, each verified to fail before the corresponding fix and pass after:

  • platform path derives https:// from the projected coordinates, and the in-cluster host never appears
  • absent Strategy CR still resolves (mode 1)
  • external S3 keeps the CR endpoint's scheme verbatim
  • external S3 takes every coordinate from the CR, never a Secret/CR hybrid
  • overridden bucket coordinates still classify as COSI
  • a large multi-tenant claim list still classifies correctly (the match must not depend on list size or position)
  • a Terminating claim on the projected bucket refuses, and never scales or adopts
  • an unreadable BucketClaim CRD / list refuses
  • a live claim wins over an unrelated Terminating one
  • COSI keeps the projected bucket even when the CR names another
  • the skip-backup hatch is honoured, and an unrecognised value does not skip

Suite: 15 → 28, plus a new helm-unittest suite covering the hook's rendered env (chart: 81 → 88 tests).

Validation

Reproduced end-to-end on a throwaway 3-node stand (fresh v1.4.5 → v1.5.3 → this branch's build). Before: mode 1, version stamp stuck, cozy-backups Bucket Ready, backupstrategy-controller Ready, creds projected, zero Strategy CRs. After: the stamp advanced and the hook completed, with endpoint, bucket, region and path-style all derived from the projected Secret and no Strategy CR in existence. Failure was safe in both cases — it refuses before any mutation: etcd stayed 3/3 Running, the legacy CR un-adopted, no scale-down. The derived https:// endpoint reproduces the 1.6 chart helper's own output byte-for-byte, and the projected bucketName equals the BucketClaim's .status.bucketName on a live stand.

Follow-ups this PR deliberately does not do

Summary by CodeRabbit

  • New Features
    • Backup destination for migration 50 adoption can now derive from projected credentials when available.
    • Added migrations.etcdAdoptSkipBackup to control whether the migration job skips pre-adoption snapshot safety checks.
  • Bug Fixes
    • Improved COSI vs external S3 classification and endpoint normalization; clearer failures when destination can’t be resolved.
    • ETCD_ADOPT_SKIP_BACKUP now honors true/false and rejects unrecognized values (does not skip by accident).
  • Tests
    • Expanded end-to-end coverage for projected vs external S3 behavior, precedence rules, and non-destructive failure scenarios.
    • Added migration hook tests validating ETCD_ADOPT_SKIP_BACKUP env-var mapping and defaulting.

@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/bug Categorizes issue or PR as related to a bug labels Jul 17, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical failure in the platform upgrade process where migration 50 would block due to an unreachable or misconfigured etcd-adoption snapshot target. By shifting the source of truth for backup coordinates from the cluster-rendered Strategy CR to the dynamically projected bucket credentials, the migration becomes resilient to stale or missing CR states during upgrades. The changes ensure that the platform can reliably locate the correct S3 target while preserving support for custom external backup configurations.

Highlights

  • Migration 50 Backup Resolution: Updated the migration hook to derive S3 snapshot coordinates from the projected cozy-backups-creds Secret instead of relying on the potentially outdated or missing cozy-default-etcd Strategy CR.
  • Endpoint Reliability: Forced HTTPS for projected endpoints to ensure compatibility with the always-TLS S3 ingress, while maintaining the Strategy CR as a fallback for external S3 configurations.
  • Test Suite Enhancements: Updated BATS fixtures to simulate missing Strategy CRs and external S3 scenarios, ensuring robust validation of the new resolution logic.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@dosubot dosubot Bot added area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) area/testing Issues or PRs related to testing (e2e, bats, unit tests) backport Should change be backported on previous release labels Jul 17, 2026
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a1b08925-4c72-4879-bce9-06b3bdc8e15f

📥 Commits

Reviewing files that changed from the base of the PR and between 2a7ec86 and efa85a4.

📒 Files selected for processing (6)
  • hack/migration-50-etcd-adopt.bats
  • hack/testdata/migration-50/kubectl
  • packages/core/platform/images/migrations/migrations/50
  • packages/core/platform/templates/migration-hook.yaml
  • packages/core/platform/tests/migration_hook_skip_backup_test.yaml
  • packages/core/platform/values.yaml
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/core/platform/values.yaml
  • packages/core/platform/tests/migration_hook_skip_backup_test.yaml
  • packages/core/platform/templates/migration-hook.yaml
  • hack/testdata/migration-50/kubectl
  • packages/core/platform/images/migrations/migrations/50
  • hack/migration-50-etcd-adopt.bats

📝 Walkthrough

Walkthrough

Platform adoption now derives S3 snapshot arguments from projected credentials when available, falls back to Strategy CR values for missing fields, and adds configurable skip-backup behavior with expanded fixture, template, and end-to-end coverage.

Changes

Platform backup destination resolution

Layer / File(s) Summary
Projected credential and claim fixtures
hack/testdata/migration-50/kubectl
The fake kubectl fixture dynamically models projected credentials, Strategy CR coordinates, and BucketClaim lookup outcomes.
Migration destination resolution
packages/core/platform/images/migrations/migrations/50
The migration decodes Secret values, classifies bucket ownership, prioritizes projected S3 coordinates, falls back to Strategy CR fields when absent, and parses skip-backup values.
Skip-backup configuration wiring
packages/core/platform/values.yaml, packages/core/platform/templates/migration-hook.yaml, packages/core/platform/tests/migration_hook_skip_backup_test.yaml
A documented migration setting is normalized into ETCD_ADOPT_SKIP_BACKUP and tested across supported and unrecognized values.
Adoption resolution scenarios
hack/migration-50-etcd-adopt.bats
End-to-end tests cover projected coordinates, external S3 endpoint schemes, claim classification, unresolved destinations, precedence, fallback, and skip-backup adoption.

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

Sequence Diagram(s)

sequenceDiagram
  participant ETCDMIGRATE
  participant ResolvePlatformBackupArgs
  participant CredentialsSecret
  participant BucketClaim
  participant StrategyCR
  ETCDMIGRATE->>ResolvePlatformBackupArgs: resolve backup arguments
  ResolvePlatformBackupArgs->>CredentialsSecret: read projected credentials
  CredentialsSecret-->>ResolvePlatformBackupArgs: return S3 coordinates
  ResolvePlatformBackupArgs->>BucketClaim: classify bucket ownership
  BucketClaim-->>ResolvePlatformBackupArgs: return claim state
  ResolvePlatformBackupArgs->>StrategyCR: read missing fields
  StrategyCR-->>ResolvePlatformBackupArgs: return fallback values
  ResolvePlatformBackupArgs-->>ETCDMIGRATE: provide backup S3 flags
Loading

Possibly related PRs

  • cozystack/cozystack#3276: Its upgrade end-to-end lane uses the new migrations.etcdAdoptSkipBackup setting and migration hook environment wiring.

Suggested reviewers: androndo, ivanhunters, kvaps, lllamnyp, sircthulhu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: deriving the etcd-adoption snapshot target from projected bucket credentials.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/etcd-adopt-backup-endpoint

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.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request updates the migration 50 script to resolve platform backup S3 coordinates directly from the projected credentials secret instead of relying on the live Strategy CR, which may be absent or incorrect during a pre-upgrade. It also adds corresponding BATS tests and updates mock data. The review feedback points out a potential issue in the new secret_val helper where decoding empty or invalid base64 values could trigger a script failure under set -e, and suggests a robust alternative implementation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +217 to +220
secret_val() {
printf '%s' "$1" | jq -r --arg k "$2" '.data[$k] // ""' \
| base64 -d 2>/dev/null | tr -d '[:space:]'
}

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.

high

Under set -euo pipefail (enabled at line 20), if any command in a pipeline fails, the entire pipeline returns a non-zero exit status. If a key is missing from the Secret, jq will output an empty string (with a newline), which is passed to base64 -d. Depending on the environment and the base64 implementation, decoding empty/invalid input can exit with a non-zero status. This will cause the command substitution $(secret_val ...) to fail and immediately abort the migration script due to set -e.

To make this robust, we can first extract the raw base64 value, check if it is non-empty, and only then decode it. We can also append || true to the decoding pipeline to guarantee it never returns a non-zero exit status and crashes the script.

Suggested change
secret_val() {
printf '%s' "$1" | jq -r --arg k "$2" '.data[$k] // ""' \
| base64 -d 2>/dev/null | tr -d '[:space:]'
}
secret_val() {
local val
val=$(printf '%s' "$1" | jq -r --arg k "$2" '.data[$k] // ""')
[ -n "$val" ] || return 0
printf '%s' "$val" | base64 -d 2>/dev/null | tr -d '[:space:]' || true
}

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/etcd-adopt-backup-endpoint

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 size/XL This PR changes 500-999 lines, ignoring generated files size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files size/XL This PR changes 500-999 lines, ignoring generated files labels Jul 17, 2026

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 — fixes a real, well-evidenced upgrade-blocking bug in migration 50, with comprehensive tests that all pass and no regressions found.

Business context: migration 50 (etcd v1alpha1→v1alpha2 adoption) is a pre-upgrade hook that gates the whole platform upgrade behind a mandatory S3 safety snapshot; on v1.5.x clusters it resolved the snapshot target from the live cozy-default-etcd Strategy CR, which is either absent (bucketName-lookup race) or carries the plaintext :8333 endpoint that fails TLS — so the upgrade wedges with no reachable escape.

The fix is architecturally correct for a pre-upgrade hook: it takes coordinates from the projected cozy-backups-creds Secret (written at bucket-provisioning time, not chart-render time), classifies COSI vs external-S3 by matching the projected bucketName against BucketClaim .status.bucketName, forces https:// only on the COSI path, and keeps the CR endpoint verbatim for external S3. The two new refuse-states (Terminating claim / unreadable BucketClaim API) fail closed with actionable messages, which is right given live-etcd adoption is irreversible. The escape-hatch plumbing (migrations.etcdAdoptSkipBackup) closes a genuine latent bug — the env var was never set on the hook Job, so every failure message advised an action the platform made impossible.

Verification:

  • 28/28 bats and 88/88 helm-unittest pass locally; the template's bool/"true"/nil/garbage normalization is pinned by tests.
  • No docs drift: the platform chart has no generated values schema/README; the knob is an emergency break-glass documented in-place.
  • The red E2E run is unrelated to this diff (install failed at flux-shard-operator liveness, and the migration hook does not even render on a fresh install — it needs a pre-existing cozystack-version ConfigMap below target).

Non-blocking follow-ups

  1. The deferred if $bucketName lookup-race in strategy-etcd-default.yaml (called out in the PR) likely means default etcd backups are silently broken on 1.5.x clusters regardless of upgrade — worth its own tracked issue.
  2. tests/migration_hook_skip_backup_test.yaml asserts on positional env[3]; a content/name match would be less brittle if the env order ever changes.

@IvanHunters

Copy link
Copy Markdown
Collaborator

Verdict

LGTM with non-blocking notes

The fix is well-diagnosed and thoroughly tested; all 28 bats + 88 helm-unittest cases pass, both required regression tests are non-vacuous (verified by mutation), and no regression is introduced on fresh-install or upgrade. The only issues are process gaps in the PR body (empty release-note block, downstream checklist not walked).

Findings

[MINOR] .github/PULL_REQUEST_TEMPLATE.md (release-note) — required template sections left unfilled

The PR body contains no release-note fenced block (the template ships one and it is empty), and the Downstream repositories checklist is neither ticked nor addressed in prose. migrations.etcdAdoptSkipBackup is a new operator-facing values knob and a user-visible behaviour change to the escape hatch, so it warrants a filled release note and a decision on whether cozystack/website needs a docs follow-up (the template explicitly asks contributors to walk the trigger map rather than leave it blank). Non-blocking, but should be completed before merge.

Caveats

  • Static review only, hermetic (no live cluster): server-side admission, real BucketClaim .status.bucketName behaviour, and the end-to-end 3-node upgrade repro the PR body describes were not independently exercised here. The logic that depends on them is covered by the bats fixture, which I ran green, and the two deliberately-refuse states (Terminating claim, unreadable BucketClaim API) fail closed before any mutation.
  • Verified the upgrade path by reasoning (not a live replay): migration 50 already exists on main and migrations.targetVersion stays 53 in this PR, so no new migration is added and no rebase-stale/targetVersion-bump concern applies. The pre-fix bug was fail-closed (hard exit 1 blocking the upgrade before the version stamp), so an affected customer never advanced past version 50→51 and will re-run the corrected migration 50 on the next upgrade; a customer who already adopted successfully on the happy path is unaffected. Default etcdAdoptSkipBackup: false preserves the fail-loud behaviour, and the "key absent on a pre-existing Package CR" case renders OFF (helm-unittest dig … false default, verified).
  • The migrations image digest in packages/core/platform/values.yaml:16 (platform-migrations:v1.5.0@…) intentionally lags targetVersion/the in-tree script per the documented convention in that file's comment; the modified script only reaches customers when the image is rebuilt and re-pinned at release. That build step is out of scope for this static review.
  • chart_lint reported 1 render_error (OCIRepository … not found from templates/repository.yaml) and 2 missing_refs (registries.config, registries.mirrors): both are pre-existing lookup/runtime-injected-cozystack-values artifacts of rendering the platform chart outside a cluster, not introduced by this PR.

Verification performed this run:

  • Phase 5d non-vacuity, mutation 1 (template): reverting migration-hook.yaml to the pre-fix raw-bool shape (value: {{ $skipBackupRaw | quote }}) turned 5/7 assertions in tests/migration_hook_skip_backup_test.yaml RED (banana→expected "0", true→expected "1"). The suite genuinely guards the "bare bool silently ignored" trap.
  • Phase 5d non-vacuity, mutation 2 (script): dropping the ep="https://${ep}" line in migrations/50 resolve_platform_backup_args made the COSI path emit --backup-s3-endpoint=s3.example.com, turning the headline test ("platform path auto-derives … https") RED. The bats suite guards the core scheme-forcing fix.
  • Phase 5c corners: migrations.enabled × etcdAdoptSkipBackup ∈ {true, false, null, "true", "banana"} rendered and asserted by 7 helm-unittest cases (green); script-level COSI / external / absent-CR / Terminating-claim / unreadable-CRD / unreadable-list / no-endpoint / big-multi-tenant-list corners covered by 28 bats (green). Working tree restored clean after both mutations.
  • Escape-hatch reachability: _print_skip_backup_hatch names kubectl edit package.cozystack.io cozystack.cozystack-platform and the spec.components.platform.values.migrations.etcdAdoptSkipBackup path — both match the real Package (packages/core/installer/example/platform.yaml:5, structure spec.components.platform.values), so the previously-unreachable advice is now actionable.
  • Shell portability: migrations/50 is #!/bin/bash with set -euo pipefail, and the bats invokes it via bash "$MIG" (not sh), so the pipefail/local/case usage is safe on both the busybox-ash runtime image and the CI runner. It sources lib/cozystack-version.sh and calls stamp_cozystack_version 51 (N+1), satisfying the shared-helper convention.

…ojected bucket creds

Migration 50 is a pre-upgrade hook, so every chart-rendered resource it reads
still belongs to the version being upgraded FROM. It read the snapshot target
from the live cozy-default-etcd Strategy CR, which on a v1.5.x cluster is broken
in one of two ways — and the platform upgrade is gated behind this snapshot, so
neither can self-heal first:

1. Absent. strategy-etcd-default.yaml is guarded by `if $bucketName`, whose
   helper looks up the BucketClaim status. That BucketClaim is created by the
   same chart, so on first render the lookup is empty and the Strategy is
   silently skipped. Helm lookup only runs at install/upgrade time, so a cluster
   that reached 1.5.x in one hop never grows the CR on reconcile.

2. Present, carrying the static v1.5.x default
   http://seaweedfs-s3.tenant-root.svc.cozy.local:8333. Cozystack ships SeaweedFS
   with enableSecurity=true, so :8333 is a TLS listener and plaintext against it
   fails the handshake. Fixing the scheme alone is not enough: the Etcd Strategy's
   S3 schema has no caCert/insecureSkipVerify, so the self-signed in-cluster
   endpoint is unusable at any scheme and a trusted-cert endpoint is required.

Either way migration 50 hard-fails and blocks the upgrade, and the documented
ETCD_ADOPT_SKIP_BACKUP escape hatch cannot be reached — migration-hook.yaml
passes only NAMESPACE/CURRENT_VERSION/TARGET_VERSION.

Take the coordinates from cozy-backups-creds instead. The projector writes it
from the COSI-provisioned bucket's system credentials — bucket provisioning, not
chart values — so it describes the live bucket regardless of which version
rendered the charts, and it carries endpoint, bucketName, region and
forcePathStyle alongside the AWS keys. resolve_platform_backup_args already read
this Secret to validate those keys, so preferring it adds no new dependency. The
endpoint is a bare host fronted by the always-TLS S3 ingress, so force https://,
exactly as the 1.6 chart helper backupstrategy-controller.endpoint does.

The Strategy CR remains the fallback for the admin-managed external-S3 case
(provisionBucket: false), where .Values.backupStorage.endpoint is authoritative
and may legitimately be plaintext against a private store; that path is detected
by the absence of a projected endpoint and its scheme is preserved verbatim.

The bats fixture modelled cozy-backups-creds as carrying only the AWS keys, and
test 1 asserted the broken plaintext endpoint as expected output — the test was
written against the implementation rather than the requirement, which is why the
defect shipped green. The fixture now carries the coordinates the real projected
Secret has, test 1 asserts the derived https endpoint, and two cases are added:
an absent Strategy CR still resolving, and external S3 keeping the CR endpoint
verbatim.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
…d bucket

Deriving the safety-snapshot target from the projected cozy-backups-creds
Secret is right, but detecting the admin-managed external-S3 case
(provisionBucket: false) by "the projected endpoint is absent" is not:
that state does not exist. The projector substitutes
BACKUP_STORAGE_ENDPOINT and fails ReasonSourceMalformed when both sources
are empty, so a projected Secret always carries a non-empty,
scheme-stripped endpoint.

So for provisionBucket: false against a plaintext external S3 the
projected endpoint is a bare host, the script saw it non-empty, forced
https:// and never consulted the Strategy CR — a regression against main,
which read the CR verbatim and passed http:// through. Being a pre-upgrade
hook with no ETCD_ADOPT_SKIP_BACKUP plumbed through migration-hook.yaml,
that is a total upgrade block with no operator escape.

Classify on data instead: match the projected bucketName against the
.status.bucketName of any BucketClaim. The COSI driver assigns that name
and the projector republishes exactly it (packages/system/bucket
user-credentials.yaml reads BucketInfo spec.bucketName), so a match means
the creds describe a COSI bucket => the host is the always-TLS S3 ingress
=> force https, as the 1.6 chart helper does. No match means external S3.

Keying on data rather than on the claim's name and namespace matters
twice. backupStorage.namespace/.bucketName are supported Package-CR
overrides that this hook cannot see, so a name lookup would miss a renamed
bucket and fall back to the v1.5.x plaintext CR — the original P0. And a
BucketClaim wedged Terminating (its cosi bucketclaim-protection finalizer
outlives an uninstalled COSI controller) keeps answering a name lookup
while its status names some other bucket.

Split the two paths by which source is authoritative. On COSI the
projected coordinates win: the bucket is the COSI-assigned name the CR can
only reproduce through a live lookup that may never have run. On external
S3 the Strategy CR wins WHOLESALE, not just for the endpoint — the CR
renders backupStorage.* and points at this same cozy-backups-creds, so CR
coordinates plus these credentials is exactly what the cluster's own
BackupJobs already use, and the bucket an operator will look in. Keeping
the Secret's bucket while taking the CR's endpoint assembled a pairing
nothing else produces: the projector copies bucketName straight from the
admin-managed source Secret and never from backupStorage.bucketName, so
the two can disagree outright and the snapshot could land in a bucket the
platform never writes. Relying on the CR is safe on this path alone,
because bucketName short-circuits to values before the BucketClaim lookup
that races on the COSI path, so the CR is guaranteed present.

Match with a single jq `any`, never `jq -r | grep -q`: grep exits on the
first match and SIGPIPEs jq, and pipefail reports jq's 141 rather than
grep's 0, so a MATCH returns as a failure and the cluster is misclassified
external. It is a race on the list outgrowing one stdio write (~8KB) with
only a last-line match safe, so it strikes exactly the big multi-tenant
clusters that can least afford it, and backoffLimit 3 can decide
differently on each retry. Measured with the match first, 20 runs each:
300 claims went 0/20 correct (rc=141 every time) and 2000 likewise, while
`any` is 20/20 at 3, 300 and 2000.

Refuse rather than guess when the answer is not knowable. Three states
qualify: the BucketClaim API is unreadable; the list is not interpretable
(no producer emits that, but without a shape gate it reads as "no claim" =
external = the plaintext P0, silently); or the bucket is claimed ONLY by a
Terminating claim. That last one is ambiguous — an admin who moved COSI ->
external S3 reusing the bucket name, versus a still-COSI cluster whose
claim is mid-recreate — and both readings fail as the same confusing
handshake error when wrong, so a guess buys no reliability while reading
it as "external" specifically resurrects the plaintext-:8333 P0. A live
claim still wins over an unrelated Terminating one, so healthy clusters
are unaffected. Each refusal names the condition and the operator action;
the migration is idempotent, so re-running after resolving it is the
remedy.

Behaviour change worth noting: a cluster on external S3 that still has
COSI installed now refuses on a sustained BucketClaim-API failure where
main would have proceeded off the Strategy CR. That is the intended trade
— main proceeded only because it never had to tell the two apart — but it
does mean an apiserver outage can now block this hook where it previously
did not. ETCD_ADOPT_SKIP_BACKUP is still not plumbed through
migration-hook.yaml, so the messages deliberately point at resolving the
condition rather than at an escape hatch the operator cannot reach.

Force the scheme only onto a non-empty host: an absent endpoint must
degrade to the CR as main did, not to a literal "https://" that would pass
the final non-empty guard and reach etcd-migrate as a destination.

The old test modelled external S3 by dropping the projected endpoint
entirely — a state the projector cannot produce — so it green-lit the
regression. It now models what the projector really writes, and the suite
pins the stale-claim, overridden-coordinates, empty-endpoint,
300-claim-multi-tenant, Terminating-match, hybrid-coordinate and
unreadable/uninterpretable API cases.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Migration 50 has honoured ETCD_ADOPT_SKIP_BACKUP since it was written, and
its failure paths tell operators to "re-run with ETCD_ADOPT_SKIP_BACKUP=1".
But migration-hook.yaml passes only NAMESPACE/CURRENT_VERSION/
TARGET_VERSION, and the Job is a Helm hook the chart re-renders, so there
has never been a way to set it. The script advertised an escape the
platform made impossible. The endpoint-classification work landing
alongside this makes that worse: it adds refuse-rather-than-guess paths,
so more clusters can now legitimately stop here.

Plumb it through migrations.etcdAdoptSkipBackup, default false. Skipping
the pre-adoption snapshot rewires ownership of LIVE etcd storage with no
way back, so it stays opt-in and deliberate — the flag is the last resort
for a cluster that has nothing to fix, not the first response to a
refusal.

Render "1"/"0" rather than the bare bool. The script matches the literal
string "1", so a bool would render "true", sail through the Job spec
looking correct, and be ignored — a safety valve that is set but silently
disregarded is worse than one that was never offered, because the operator
believes they opted in. The value is normalised through a string, so a
bool, a quoted "true" out of the Package CR's Values JSON, or a numeric 1
all land on the same answer, while nil or garbage resolves to "0": taking
this hatch must require an affirmative spelling, never a typo. Resolved
with `dig` so a Platform Package predating the key renders "0" instead of
failing the render of the entire platform chart and bricking every
upgrade.

Widen the script's own check to accept 1/true/yes as well. The template
guarantees the GitOps path, but an operator running this image by hand
will reasonably type "true", and that is exactly the moment they are
already stuck. The two layers cover independent failure modes.

Emit the var unconditionally, including the "0" default, so the rendered
Job states the cluster's safety posture outright rather than leaving an
operator to diff values to find out whether a snapshot was skipped.

Point the failure messages at the values path that actually exists, and
describe it honestly: editing the Platform Package and letting Flux
re-render is a real operation, not a one-liner, and the flag has to be
reverted afterwards or the next etcd migration skips its snapshot too.
Fixing the condition the migration names remains the recommended path —
the migration is idempotent, so re-running after a fix is safe.

templates/migration-hook.yaml had no coverage at all, because the Job only
renders when a lookup of the cozystack-version ConfigMap reports a version
below targetVersion, and lookup is nil under helm template. The new suite
mocks that ConfigMap and asserts the RENDERED VALUE — enabled, disabled,
key absent, stringified true, garbage, and the pre-existing three env vars
— rather than merely that the variable exists, which is what let the trap
above go unnoticed in the first place.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil
myasnikovdaniil force-pushed the fix/etcd-adopt-backup-endpoint branch from 2a7ec86 to efa85a4 Compare July 20, 2026 06:31
@myasnikovdaniil
myasnikovdaniil merged commit 6338b0b into main Jul 20, 2026
15 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the fix/etcd-adopt-backup-endpoint branch July 20, 2026 09:49
@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-3335-to-release-1.5
git worktree add --checkout .worktree/backport-3335-to-release-1.5 backport-3335-to-release-1.5
cd .worktree/backport-3335-to-release-1.5
git reset --hard HEAD^
git cherry-pick -x 9ceccd2b07d1e9b7a34c8927e2d47489c35728ea 0ba53375264e55e1f50e7a58442f595cd62eb5b9 efa85a4f615c982258f2fe39294a6c218a860f98
git push --force-with-lease

myasnikovdaniil added a commit that referenced this pull request Jul 20, 2026
…e path (#3339)

## What this PR does

Cozystack v1.5.0 bumped the vendored SeaweedFS chart 4.0.405 → 4.31.0,
which renamed every workload after the Helm release (`<name>-system-*`).
StatefulSet names are immutable, so upgrades through 1.5.x stood up a
second, empty set beside the running one instead of renaming. #3282
pinned `fullnameOverride: seaweedfs` so 1.6 adopts running workloads in
place — this PR closes the remaining holes on that adoption path, found
by driving a disposable 3-node cluster through a real v1.4.5 → v1.5.3 →
main upgrade with SeaweedFS tenants planted in every reachable state.

- **The naming guard moves into `packages/system/seaweedfs`** — the
render a platform upgrade actually re-renders: the `<name>-system`
HelmRelease pulls this chart from a platform-managed ExternalArtifact,
so the previous guard in `extra/seaweedfs` was never in the path.
Upgrading a fresh-1.5.x tenant therefore created an empty chart-named
set beside its live data and flipped the guard's own legacy-data signal.
`extra/` keeps a sibling copy for operator visibility; a bats parity
suite pins the two detection blocks byte-identical.
- **The guard refuses instead of guessing when both naming generations
exist.** Nothing durable distinguishes a duplicate that never served
from one that served and crashed: claim timestamps invert during the
recovery runbook's own re-bind, `readyReplicas: 0` is a snapshot, and
Helm birth order answers "which generation is original", not "is the
other one empty". Exactly one generation present is decidable, and the
render proceeds (or refuses as class S) on its own.
- **Cluster-scoped RBAC is named per namespace again.** 4.31 named four
cluster-scoped objects after the release — identical for every tenant —
so all tenants collided on one ClusterRole/ClusterRoleBinding and only
the last-reconciled tenant's COSI provisioner kept its RBAC. Names
return to `global.seaweedfs.serviceAccountName`; three of four are
byte-identical to pre-4.31 and adopt in place.
- **The `seaweedfs-db` hand-over runs for every instance name.**
Migration 43 compared the owning release against the literal
`seaweedfs-system`, so an instance named `foo` was skipped and its CNPG
Cluster — the filer metadata for every object in that tenant's S3 — was
pruned on the next reconcile, PVC included. The comparison now matches
the `-system` suffix (shared `lib/seaweedfs-db-adopt.sh`), and new
migration 53 re-runs the hand-over for clusters already past 43, before
anything re-renders.
- **`hack/seaweedfs-naming-audit.sh` +
`docs/operations/seaweedfs-431-rename-recovery.md`** — what the guard's
refusal points operators at: read-only classification (`L` / `S` /
`MIXED`, naming the candidate duplicate from relative PV vintage, never
a clock window) and the recovery procedures.

**Scope.** The supported SeaweedFS deployment is the tenant module,
which hardcodes the instance name
(`packages/apps/tenant/templates/seaweedfs.yaml`); a tenant only enables
or disables it. The runbook and its selectors are scoped to that name;
instances created directly against the API under other names are
classified by the audit but routed to escalation. Zone/pool keys of ~40
or more characters fall outside the guard's reconstruction — an accepted
limit, recorded in `_naming.tpl` and the runbook.

**Upgrade impact.** 1.4.x → 1.6: no manual steps — one generation,
adopted in place. 1.5.x → 1.6 with SeaweedFS: migration 53 protects
every `seaweedfs-db` first; then the upgrade **refuses** for any tenant
holding both naming generations until the operator resolves the
duplicate. This is deliberate — which generation holds the data is not
decidable from inside a render, and guessing wrong destroys it. Release
notes should present the refusal as expected behavior.

**Testing.** 55 chart unit tests across both packages (including new
MultiZone zone-component guard cases — the suite previously had no zone
shapes), 11 audit bats, 8 guard-parity bats, migration bats. Validated
end-to-end on a disposable 3-node cluster driven v1.4.5 → v1.5.3 → main
with five tenants covering: never-saw-4.31 (renders untouched),
fresh-1.5.x (refused as class S), wedged duplicate, split duplicate, and
a non-default-named instance (its database survives only with this fix).
The audit classifies all five correctly, and its two independent signals
— revision-1 birth scheme and relative PV vintage — agree on every MIXED
tenant.

Related: #3282 (fullnameOverride pin), #3335 (etcd adoption backup gate
— separate, also required for the 1.5.x→1.6 path).

### Screenshots

Not a UI change.

### Downstream repositories

- [x] No downstream repository is affected by this change

Walked the trigger map against the diff: no package added/renamed under
`packages/{apps,extra}/`, the `packages/core/platform/values.yaml`
change is only the migrations `targetVersion` bump (no
`spec.components.platform.values.*` key changes), no
variant/bundle/component changes, no asset renames, no
ApplicationDefinition semantic changes.

### Release note

```release-note
fix(seaweedfs): the 1.6 upgrade no longer renames a SeaweedFS instance away from its data. The naming guard now runs in the chart a platform upgrade actually re-renders and refuses when both pre- and post-4.31 naming generations exist; hack/seaweedfs-naming-audit.sh and docs/operations/seaweedfs-431-rename-recovery.md guide recovery, and the refusal is expected for tenants that passed through 1.5.x. Cluster-scoped COSI RBAC is named per namespace again (the 4.31 release-based names collided across tenants), and the seaweedfs-db hand-over runs for every instance name — previously an instance not named `seaweedfs` had its filer metadata database pruned on upgrade; new migration 53 repairs clusters that already ran the old hand-over.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added fail-closed upgrade protections for SeaweedFS naming migrations
(mixed/damaged classification), including safer behavior when cluster
visibility is limited.
* Added cluster-scoped RBAC uniqueness safeguards to prevent
cross-tenant name collisions during rendering.
* Improved SeaweedFS database adoption/repair migrations and
strengthened post-delete cleanup ownership checks.
* **Bug Fixes**
* Hardened SeaweedFS 4.31 rename recovery and PV rebind flow, including
reclaim policy preservation and long/non-default instance-name edge
cases.
* **Documentation**
* Expanded the SeaweedFS 4.31 rename-recovery runbook with clarified
auditing, verification, and escalation.
* **Tests**
* Added/expanded integration and Helm rendering tests for the above
scenarios and refusal/fail-closed behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) area/testing Issues or PRs related to testing (e2e, bats, unit tests) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review backport Should change be backported on previous release 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.

3 participants