Skip to content

fix(etcd): complete v1alpha2 transition for in-cluster 1.5→1.6 upgrades - #3270

Merged
myasnikovdaniil merged 8 commits into
mainfrom
fix/etcd-3265-review-fixes
Jul 16, 2026
Merged

fix(etcd): complete v1alpha2 transition for in-cluster 1.5→1.6 upgrades#3270
myasnikovdaniil merged 8 commits into
mainfrom
fix/etcd-3265-review-fixes

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Consolidates the etcd v1alpha2 transition fix for in-cluster 1.5 → 1.6 upgrades into a single PR, rebased on current main. Supersedes #3265 and #3261 — their commits are carried here (authorship preserved), so those PRs can be closed once this lands. Fresh installs use the new shapes directly and are unaffected, so fresh-install CI does not catch these; the 1.5 → 1.6 e2e upgrade path is the authoritative regression guard.

What this PR does

1. Keep the legacy etcd-headless Service alive during adoption — packages/extra/etcd

etcd-migrate adopts legacy clusters in place: the Pods keep their original spec.subdomain: etcd-headless and are dialed at etcd-<i>.etcd-headless.<ns>.svc until they roll onto the operator's native <member>.etcd.<ns>.svc domain. The v1alpha2 operator only creates the native etcd Service and the legacy etcd-headless Service is pruned, so those per-pod names stop resolving (no such host), MemberList fails, and status.readyMembers never populates — the EtcdCluster never goes Ready even though etcd is healthy and in quorum. We ship a chart-managed transitional headless etcd-headless Service (selector mirrors the operator's native etcd Service via etcd-operator.cozystack.io/cluster, publishNotReadyAddresses: true) — the DNS counterpart of the legacy *.etcd-headless.<ns>.svc SAN already kept for this window. Removable together with that SAN once members roll onto the native subdomain.

2. Survive the immutable controller-Deployment selector on upgrade — packages/system/etcd-operator (#3242)

#2859 replaced the upstream etcd-operator chart with the cozystack-authored one, changing Deployment.spec.selector.matchLabels. spec.selector is immutable, so helm upgrade cannot patch the existing Deployment and the whole HelmRelease upgrade fails (field is immutable). A pre-upgrade hook (templates/pre-upgrade-selector-fix.yaml: ServiceAccount + Role + RoleBinding + Job) deletes the Deployment only when its live selector is the pre-1.6 one, so Helm recreates it cleanly. No-op when the selector already matches, never runs on fresh install.

3. Raise the operator's memory cold-start floor — packages/system/etcd-operator

Steady-state working set is ~250Mi; the static limits.memory: 128Mi OOMKills a Pod that starts before the VPA admission webhook rewrites it. Raise the floor to 256Mi (and VPA minAllowed to match) as defense in depth so the operator never depends on VPA timing to avoid crashing.

4. Make migration 50 (etcd adoption) robust in-cluster — packages/core/platform/images/migrations/migrations/50 (was #3261)

Exact server/peer cert-SAN match, Secret-gated wait on the adoption Secret, and in-cluster (IPv6-safe) kubeconfig handling, so the migration script drives the adoption reliably from inside the cluster. Covered by hack/migration-50-etcd-adopt.bats.

5. Hardening / review fixes (this PR's original scope)

  • Hook runAsUser: 65532. pre-upgrade-selector-fix.yaml set runAsNonRoot: true but no numeric runAsUser; clastix/kubectl's image user is the non-numeric name nonroot, which the kubelet cannot verify against runAsNonRoot — so the hook Pod fails admission and silently blocks the very upgrade it exists to unblock. Adds runAsUser: 65532, matching the postgres-operator webhook-ready hook that runs the same image.
  • Digest-pin the kubectl image. The values comment claimed digest-pinning but shipped a floating v1.32 tag. Reuses the digest postgres-operator vendors, adds the renovate annotation, and templates repo:tag@digest.
  • Tests. etcd-operator/tests/selector-fix-hook_test.yaml (hook wiring, weight ordering, namespaced least-privilege RBAC, numeric-non-root security context, digest-pinned image), etcd-operator/tests/deployment_test.yaml (256Mi cold-start floor), extra/etcd/tests/etcd-cluster_test.yaml (transitional etcd-headless Service). Each assertion was mutation-tested.

6. Derive the default S3 endpoint from the provisioned bucket — packages/system/backupstrategy-controller

Derive the default S3 endpoint (and per-driver scheme / TLS / secure_connection) from the provisioned bucket Secret instead of requiring it to be hand-set, so etcd (and other) backup strategies get a working endpoint by default. Docs in docs/operations/backup-classes.md; covered by tests/endpoint_form_test.yaml.

Verification

  • helm unittest green on current main: etcd-operator 18/18, extra/etcd 18/18, backupstrategy-controller 11/11.
  • Behaviours 1 & 2 were reproduced and confirmed live on a 1.5.2 → 1.6.0-rc.1 adoption (3-node cluster) — recreating the etcd-headless Service took the adopted cluster to readyMembers=3 / Available=True.
fix(etcd): complete the v1alpha2 transition on in-cluster 1.5→1.6 upgrades — keep the legacy etcd-headless Service alive so adopted members stay resolvable, delete the pre-1.6 operator Deployment via a pre-upgrade hook to get past the immutable selector, raise the operator's memory floor so it does not OOM before the VPA scales it, and make the etcd adoption migration robust in-cluster.

Summary by CodeRabbit

  • New Features

    • Improved backup storage endpoint handling across supported backup drivers, including provisioned and external S3 storage.
    • Added compatibility support for legacy etcd pod discovery during migration.
    • Added an automated upgrade safeguard for etcd operator deployments.
  • Bug Fixes

    • Improved certificate SAN detection and etcd migration authentication.
    • Increased the etcd operator’s minimum startup memory to prevent early restarts.
  • Documentation

    • Clarified backup endpoint, TLS, and driver-specific behavior.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Backup endpoint resolution

Layer / File(s) Summary
Endpoint resolution contract
packages/system/backupstrategy-controller/..., docs/operations/backup-classes.md
Adds provisioned-bucket endpoint derivation from the system Secret, HTTPS normalization, fallback behavior, and updated documentation.
Endpoint consumer wiring and validation
packages/system/backupstrategy-controller/templates/..., packages/system/backupstrategy-controller/tests/endpoint_form_test.yaml
Propagates the resolved endpoint to controller and strategy resources and tests HTTPS, HTTP, and external-S3 outputs.

Etcd adoption migration

Layer / File(s) Summary
Exact wildcard SAN handling
packages/core/platform/images/migrations/migrations/50, hack/migration-50-etcd-adopt.bats, hack/testdata/migration-50/kubectl
Adds retrying exact SAN checks and coverage for wildcard superstrings.
In-cluster migration authentication
packages/core/platform/images/migrations/migrations/50, hack/migration-50-etcd-adopt.bats
Generates a ServiceAccount kubeconfig and passes it to dry-run and apply migration commands.
Legacy pod DNS compatibility
packages/extra/etcd/templates/etcd-cluster.yaml, packages/extra/etcd/tests/etcd-cluster_test.yaml
Adds and validates a transitional headless Service for adopted etcd pods.

Etcd operator upgrade handling

Layer / File(s) Summary
Manager resource floor
packages/system/etcd-operator/values.yaml, packages/system/etcd-operator/tests/deployment_test.yaml
Raises manager and VPA minimum memory settings to 256Mi and tests the rendered limit.
Selector migration hook
packages/system/etcd-operator/templates/pre-upgrade-selector-fix.yaml, packages/system/etcd-operator/tests/selector-fix-hook_test.yaml, packages/system/etcd-operator/values.yaml
Adds a digest-pinned, hardened pre-upgrade Job with scoped RBAC and tests its rendered resources and ordering.

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

Possibly related issues

Possibly related PRs

Suggested labels: area/extra, area/platform

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

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main etcd v1alpha2 upgrade transition work and is specific enough for the changeset.
✨ 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-3265-review-fixes

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/L This PR changes 100-499 lines, ignoring generated files area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/bug Categorizes issue or PR as related to a bug labels Jul 10, 2026
@myasnikovdaniil
myasnikovdaniil marked this pull request as ready for review July 13, 2026 10:53
@dosubot dosubot Bot added the area/testing Issues or PRs related to testing (e2e, bats, unit tests) label Jul 13, 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 hardens the v1alpha2 transition for etcd by addressing critical deployment hook failures and improving security through image digest-pinning. It introduces extensive test coverage to prevent future regressions and refactors S3 endpoint resolution to provide consistent behavior across different backup strategies. Additionally, it enhances the reliability of migration scripts in restricted cluster environments.

Highlights

  • Hardened Upgrade Hooks: Added a numeric runAsUser (65532) and digest-pinning to the pre-upgrade selector-fix hook to prevent Pod admission failures and ensure image reproducibility.
  • Robust Testing: Introduced comprehensive helm-unittest suites for etcd-operator and extra/etcd, covering hook wiring, RBAC, security contexts, and deployment memory floors.
  • Centralized S3 Resolution: Implemented a new Helm helper to resolve S3 endpoints consistently across all backup strategies, ensuring correct TLS/ACME handling.
  • Migration Reliability: Improved etcd-migrate by synthesizing an in-cluster kubeconfig and adding strict SAN matching checks for certificate re-issuance.
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.

@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 introduces robust SAN checks and in-cluster kubeconfig synthesis for the etcd migration script, adds transitional headless service tests, and implements a helper to dynamically resolve S3 endpoints for backup strategies. It also pins the kubectl image digest and configures numeric user execution for the etcd-operator pre-upgrade hook. Feedback suggests trimming potential whitespaces when base64-decoding the backup endpoint secret, and adding resource requests and limits to the selector-fix hook container to ensure predictable scheduling.

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.

{{- else -}}
{{- $secret := lookup "v1" "Secret" .Values.backupStorage.namespace .Values.backupStorage.systemSecretName -}}
{{- if and $secret $secret.data (index $secret.data "endpoint") -}}
{{- printf "https://%s" (b64dec (index $secret.data "endpoint") | trimPrefix "https://" | trimPrefix "http://") -}}

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.

medium

When base64-decoding the endpoint from the Secret, there may be leading or trailing whitespaces or newlines (for example, if the secret was created with a trailing newline). It is safer to use the trim function before stripping the prefixes to ensure the endpoint URL is clean and doesn't cause malformed URLs in the generated resources.

{{- printf "https://%s" (b64dec (index $secret.data "endpoint") | trim | trimPrefix "https://" | trimPrefix "http://") -}}

Comment on lines 119 to 123
- name: selector-fix
image: "{{ .Values.kubectlImage.repository }}:{{ .Values.kubectlImage.tag }}"
image: {{ $img | quote }}
imagePullPolicy: {{ .Values.kubectlImage.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false

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.

medium

The selector-fix container is missing resource requests and limits. According to the repository style guide (rule 99), new workloads should have resource requests/limits defined to ensure predictable scheduling and prevent unbounded resource consumption.

        - name: selector-fix
          image: {{ $img | quote }}
          imagePullPolicy: {{ .Values.kubectlImage.pullPolicy }}
          resources:
            limits:
              cpu: 100m
              memory: 128Mi
            requests:
              cpu: 50m
              memory: 64Mi
          securityContext:
            allowPrivilegeEscalation: false
References
  1. Missing resource requests/limits on new workloads. (link)

@androndo

Copy link
Copy Markdown
Contributor

NOT LGTM

Diff scope: merge-base 33fa5e51 → head e06ab367 (5 commits, 17 files, +460/-30). All test suites pass locally (etcd-operator 17/17, extra/etcd 18/18, backupstrategy-controller 11/11, migration-50 bats 13/13). The main code paths are correct, but there are two blocking issues.

Blocking

1. docs/operations/backup-classes.md describes the OLD backup-endpoint behavior and now contradicts the code.

The headline change (the new backupstrategy-controller.endpoint helper) makes the S3 endpoint for a provisioned bucket derive from the COSI system Secret and force https://, with backupStorage.endpoint demoted to a fallback. The values.yaml comment was rewritten to say exactly this — but the user-facing doc was not, and now states the opposite:

  • Line 30: "the strategy templates … adapt the single backupStorage.endpoint value … to each consumer's contract" — no longer single/verbatim for provisionBucket: true.
  • Line 41: "Drivers that need the full URL pull from backupStorage.endpoint in chart values, not from the Secret." — after this PR, CNPG/Etcd/Velero pull the derived endpoint from the COSI Secret for provisioned buckets. This sentence is now precisely wrong.
  • Line 138: the endpoint values-table row still says "S3 endpoint baked into every default strategy CR + the Velero BSL" with no mention that it is only a fallback.

Lines 28–41 and the values table must be updated to describe the derive-from-Secret-and-force-https behavior and the fallback semantics, mirroring the new values.yaml comment.

2. The tests added prove the manifests, not the contracts the fixes are about.

The genuinely load-bearing claims in this PR are all runtime behaviors, but the new helm-unittest cases only assert static render output:

  • hook runAsUser: 65532 — the point is "a Pod with runAsNonRoot: true + the nonroot-named image user passes kubelet admission and the hook runs." selector-fix-hook_test.yaml only asserts the literal 65532 renders; it would pass for any numeric UID whether or not admission accepts it.
  • derived https endpointendpoint_form_test.yaml explicitly notes lookup returns nothing offline, so it exercises only the fallback path. The actual new behavior (read COSI Secret → force https → upload succeeds against the ACME endpoint) has zero coverage.
  • etcd-headless transitional Service — the real contract is "adopted Pods' etcd-<i>.etcd-headless.<ns>.svc keeps resolving so MemberList succeeds and the cluster goes Ready." The test only asserts the Service's fields render.

These belong in an e2e that drives the 1.5→1.6 upgrade / adoption / backup-upload flow. The migration-50 bats tests are the right shape by contrast — they run the actual 50 script against a fake kubectl and assert behavior (the two new cases, exact-SAN and synthesized-kubeconfig, are genuine and mutation-worthy).

Non-blocking / verified fine

  • Digest pin sha256:b9ef7d8dbe65… matches the digest already vendored for clastix/kubectl:v1.32 (harbor cleanup hook); $img templating (repo:tag@digest) is correct. (Minor: several apps/* packages still run the floating clastix/kubectl:v1.32 without a digest — follow-up, not this PR.)
  • 256Mi memory floor matches values.yaml resources.limits.memory.
  • documentSelector: kind=Service is unambiguous — the chart renders exactly one Service (etcd-headless); the operator-owned etcd Service is created at runtime.
  • _san_present / _secret_has_san — exact-match (grep -qxF) fixes the substring false-positive; empty-read retry logic is sound. Confirmed by bats test 12.
  • Synthesized kubeconfigserver: https://kubernetes.default.svc is IP-family-agnostic (avoids the unbracketed bare-IPv6 URL trap); confirmed by bats test 13. Minor nit: creation is gated on [ -f token ] but reads $_sa_dir/namespace unguarded — a missing namespace file yields an empty namespace:. Co-located in a real SA mount, so not a practical failure.
  • Endpoint helper — BucketInfo format — traced and not a live gap: for provisionBucket: false the helper returns .Values.endpoint verbatim; for provisionBucket: true the default bucket produces flat keys via bucket/templates/user-credentials.yaml. The only BucketInfo-only case is the transient pre-reconcile window (documented, Flux-re-rendered fallback). Note the helper duplicates half of the Go projector's parse logic — a future divergence risk.

Design note (not a code defect): the endpoint fix rests on the assumption that the COSI bucket's advertised spec.secretS3.endpoint is the external ACME-trusted S3 ingress, not the in-cluster SeaweedFS service — if it were the in-cluster host, forcing https:// would still hit the self-signed CA. Worth a maintainer confirming against the COSI driver config.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Jul 13, 2026
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Andrey Kolkov (@androndo) thanks for the thorough pass — both points are fair.

#1 (doc) — fixed in 23729ec7f. docs/operations/backup-classes.md now describes the derive-from-Secret behaviour: the "Endpoint format per driver" intro and the endpoint values-table row explain that for a provisioned bucket the endpoint is derived from the COSI system Secret and forced to https://, with backupStorage.endpoint demoted to the fallback for external S3 / offline renders — mirroring the rewritten values.yaml comment. The "pull from chart values, not from the Secret" sentence is corrected.

#2 (tests) — agreed. helm-unittest can only assert render output: lookup (the COSI-Secret derivation), kubelet admission (runAsUser/runAsNonRoot) and in-cluster DNS (etcd-headless resolution) don't exist offline, so the load-bearing runtime contracts genuinely can't live in unit tests — adding more render-only assertions would just give false confidence. Those belong in an upgrade-e2e lane that drives a real 1.5→1.6 adoption + backup-upload, alongside the migration-50 bats that already assert behaviour. Proposing to land that coverage in the e2e lane rather than in unittest.

For the record, all three runtime contracts were validated end-to-end on a real 1.5→1.6 upgrade during review:

  • the selector-fix hook ran — the operator Deployment was recreated with the new {name, control-plane} selector, no "field is immutable";
  • the adoption safety-snapshot uploaded to the derived https:// endpoint (~10s) and both etcds (a datastore-backing one and a standalone one) adopted in-place and went Available under the v0.5.2 operator;
  • the Kamaji control plane + its worker + an in-cluster marker workload stayed healthy throughout, and the Ubuntu→Talos worker roll completed.

(Also pinned the floating actions/checkout@v4 / create-github-app-token@v1 refs the pre-commit zizmor gate was erroring on — 8458634b4 — so pre-commit is green again.)

@IvanHunters

Copy link
Copy Markdown
Collaborator

Verdict

LGTM with non-blocking notes

The three fixes described in the body (numeric runAsUser on the kubectl hook, digest-pinned image, unit tests) are correct and well-mutation-tested; upgrade and fresh-install impact are safe. The blocking-severity concerns I checked (migration-50 rebase-stale, lookup-based endpoint on cold install) both resolved to "safe by existing design." The only issues are a PR-body scope mismatch and a couple of documented-caveat items around the new lookup helper.

Findings

[MINOR] PR body under-describes the actual diff — the highest-risk change is undocumented packages/system/backupstrategy-controller/templates/_helpers.tpl:78-103

The body lists only three items (hook runAsUser, digest-pin, tests). The branch delivers substantially more against the base: a new S3-endpoint derivation helper backupstrategy-controller.endpoint rewired into 4 strategy templates + Velero BSL + the controller Deployment env, workflow SHA pinning, and the merged-in migration-50 kubeconfig/SAN robustness from #3261. A reviewer reading only the body will miss the endpoint-behavior change, which is the single highest-risk item here (it changes what S3 endpoint every default backup Strategy CR targets on a provisioned bucket). Either split the S3-endpoint + workflow commits into their own PR, or extend the body's "What this PR does" to cover them.

Claim mismatches

[OK-for-record] "matches the postgres-operator webhook-ready hook that runs the same image" — verified: digest sha256:b9ef7d8dbe65bcc81a46c09b8dc7543103055021c4f43287bf59e92a8f4fe05c is byte-identical to packages/system/postgres-operator/values.yaml:17 and the manual printf "%s:%s" ... "%s@%s" templating shape matches postgres-operator/templates/webhook-ready-hook.yaml:39-41. No mismatch.

[PARTIAL] "extra/etcd/tests/... asserts the transitional etcd-headless Service" — the test is added by this PR but the Service itself lives in the base branch, so this PR's extra/etcd contribution is test-only. Body wording is accurate; noting for scope clarity only.

Caveats

  • Phase 5b — existing-customer upgrade (migration 50, modified not new): Migration 50 is already shipped in v1.6.0-rc.1/v1.6.0-rc.2 and this PR modifies it in place (packages/core/platform/images/migrations/migrations/50, +101 lines: _san_present/_secret_has_san retry-on-empty-read, etcd-migrate --kubeconfig synthesis). This is NOT a rebase-stale silent-skip: stamp_cozystack_version 51 runs only on success, and under set -euo pipefail a failed etcd-migrate --apply (the exact bug the kubeconfig fix repairs) aborts before the stamp, leaving cozystack-version=50 so the modified script re-runs idempotently on the next attempt. Verified by diffing v1.6.0-rc.2:migrations/50 (== main, no --kubeconfig) against HEAD and tracing the version-stamp/exit ordering. rc customers who had no legacy etcd clusters already stamped 51 and won't re-run, but they had nothing to migrate, so the robustness improvements are moot for them. Conclusion: no breakage. targetVersion correctly stays 52; the script sources lib/cozystack-version.sh and calls stamp_cozystack_version 51 (no inline heredoc) — migration-stamp convention satisfied.

  • Phase 5b — fresh install (backupstrategy-controller endpoint helper): The new backupstrategy-controller.endpoint helper uses lookup "v1" "Secret" ... for the provisioned-bucket path. On a cold install the COSI system Secret does not yet exist, so the helper falls back to .Values.backupStorage.endpoint (the plaintext in-cluster URL). The helper comment and values.yaml claim "Flux re-renders on spec.interval once the Secret exists, promoting the derived endpoint." helm-controller does not re-render on bare reconcile ticks — but this chart ALREADY ships the identical assumption in the pre-existing backupstrategy-controller.bucketName helper (_helpers.tpl:41-59), which resolves a BucketClaim.status.bucketName the same way; since backups are functional in the released RCs (no permanent NoSuchBucket), helm-controller is in practice re-running helm upgrade (re-evaluating lookup) for this HR on its interval. This PR extends an established, working pattern rather than introducing it. Conclusion: no new breakage. Verified bucketName is the precedent and that both helpers share failure semantics.

  • The provisioned-bucket (provisionBucket: true) derivation path — the actual production path — is not exercised by any unit test; lookup returns nil under helm template/helm-unittest, so endpoint_form_test.yaml covers only the fallback branch (the test comment says so explicitly). This is inherent to lookup and matches the untested bucketName path, so it is not a regression, but the live https-forcing behavior has no automated coverage — worth an e2e assertion if backup S3 wiring regresses.

  • The kubectl hook image is built via manual printf string concatenation rather than include "cozy-lib.image", so it is not subject to air-gapped registry rewriting. This matches the cited postgres-operator precedent (which also skips the helper) and the image is public + digest-pinned, so it does not block — but neither hook will be mirrored on air-gapped installs. Pre-existing chart-wide pattern, not introduced here.

Recommended follow-ups

  • Consider routing both the etcd-operator and postgres-operator kubectl-hook images through cozy-lib.image in a separate cleanup PR so air-gapped/mirrored installs pick up the registry rewrite (currently both bypass it).
  • Add an e2e assertion that a provisioned-bucket install ends up with https://<acme-ingress-host> in the rendered Strategy CRs (covers the lookup derivation path that unit tests structurally cannot reach).

@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.

Review — LGTM ✅

Reviewed in an isolated worktree pinned to the PR head; every load-bearing claim was executed rather than taken on trust. No bugs, no security issues, no regressions.

Verified (all passed)

  • helm-unittest — etcd-operator 17/17, extra/etcd 18/18, backupstrategy-controller 11/11 (the last is omitted from the PR body but passes).
  • migration-50 bats — 13/13, including the two new tests (exact SAN match, synthesized in-cluster kubeconfig).
  • kubectl digestsha256:b9ef7d8…4fe05c is the same image already pinned by postgres-operator/mariadb/harbor. ✔
  • workflow SHA pinsactions/checkout@34e1148… == v4, actions/create-github-app-token@d72941d… == v1. No supply-chain substitution.
  • S3 endpoint derivation (the riskiest change) — traced by hand since lookup returns nil under helm template: the producer (bucket/templates/user-credentials.yaml) writes a scheme-stripped endpoint key into bucket-cozy-backups-system-credentials (tenant-root), and the new helper (_helpers.tpl:92-103) reads exactly that key/namespace/format and re-forces https://. Key, namespace, and format all match.
  • fix vs. regression — SeaweedFS ships enableSecurity: true and consumers dial https://…:8333, so the old static default http://seaweedfs-s3.tenant-root.svc…:8333 (wrong namespace + plaintext against a TLS listener) could not have worked for provisionBucket: true. This is a fix, not a regression; the external-S3 (provisionBucket: false) path is unchanged and used verbatim.
  • docsbackup-classes.md updated and accurate; no leaked secrets (fixtures use fake tokens).

⚠️ Process flag (worth addressing before merge)

The PR description covers ~6 of the 21 changed files. Three substantial changes land on merge without being described — the backupstrategy-controller S3-endpoint derivation (behavior change for every default backup strategy CR + Velero BSL), migration-50 robustness, and workflow SHA-pinning. This comes from the PR being stacked on #3265. Either land #3265 first so the diff narrows to what the body describes, or expand the body so the backups behavior change isn't rubber-stamped.

Recommendations (non-blocking)

  1. The live endpoint-derivation path (provisionBucket: true → read Secret → force https) has no automated coverage — helm-unittest can't exercise it offline (lookup is nil). Best home is the backup e2e suite: assert the strategy CRs pick up the endpoint from the provisioned bucket. Verified manually here, which is why this is a recommendation and not a blocker.
  2. Minor: the helper's trimPrefix "https://" | trimPrefix "http://" is dead-defensive since the producer already strips the scheme — harmless insurance, just noting it never fires today.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Pushed 22d837db0 addressing the two review nits:

  • backupstrategy-controller/_helpers.tpl (Gemini (@gemini-code-assist)) — the decoded S3 endpoint is now trimmed before scheme-stripping, so a trailing newline in the Secret's endpoint key can't produce a malformed URL.
  • etcd-operator/pre-upgrade-selector-fix.yaml (styleguide rule 99) — the selector-fix hook container now declares resource requests/limits (10m/32Mi100m/64Mi), matching the postgres-operator webhook-ready hook it's explicitly modeled on. Added a unittest pinning the block so it can't regress.

backupstrategy-controller unittest 11/11, etcd-operator 18/18.

On the red E2E check — transient sandbox flake, not this PR

The two failing suites are kubernetes-latest / kubernetes-previous; both timed out on tenant-worker node-join because the workers' CDI boot-disk imports couldn't reach the talos-image-cache ClusterIP (dial tcp …:80: i/o timeout). The cache pod itself was verified healthy — serving HTTP 206 byte-ranges at suite start — and there's a corroborating No route to host to linstor-controller during install, so this is a pod→ClusterIP routing hiccup in the e2e sandbox CNI, not a defect here.

Evidence it's unrelated to this PR:

  • The etcd suite passed, and the migration-50 v1alpha2 adoption (the point of this PR) completed end-to-end — etcdcluster.etcd-operator.cozystack.io/etcd condition met, all members ready.
  • The diff touches none of the failing surface (no packages/apps/kubernetes, CDI, cilium, kube-ovn, or LINSTOR).
  • The same suite passed green on other branches in the same window.

A rerun should clear it.

@myasnikovdaniil
myasnikovdaniil force-pushed the fix/etcd-3265-review-fixes branch from 22d837d to d49b853 Compare July 14, 2026 08:05
Andrey Kolkov (androndo) and others added 8 commits July 15, 2026 19:11
…option

etcd-migrate adopts legacy clusters IN PLACE, so the Pods keep their original
spec.subdomain: etcd-headless and are dialed at etcd-<i>.etcd-headless.<ns>.svc
until they eventually roll onto the operator's native <member>.etcd.<ns>.svc
domain. The v1alpha2 operator only creates the native `etcd` Service, and the
legacy `etcd-headless` Service is pruned during the transition, so those names
stop resolving (no such host), the operator's MemberList fails, and
status.readyMembers never populates -- the EtcdCluster never goes Ready even
though the etcd processes are healthy and in quorum.

Ship a chart-managed transitional headless `etcd-headless` Service (selector
mirrors the operator's native `etcd` Service via
etcd-operator.cozystack.io/cluster, publishNotReadyAddresses: true). This is the
DNS counterpart of the legacy *.etcd-headless.<ns>.svc wildcard already kept in
the server/peer cert SANs for the same transition window -- the TLS half of the
compat was done, the DNS half was missing. Safe to remove, together with that
SAN, once the members have rolled onto the native `etcd` subdomain.

Verified live: recreating this Service on an adopted 3-node cluster restored
per-pod DNS and the cluster went readyMembers=3 / Available=True.

Refs: #3243

Signed-off-by: Andrey Kolkov <androndo@gmail.com>
…OM floor)

Two defects that break the etcd-operator on an in-cluster 1.5 -> 1.6 upgrade
(#2859 swapped the upstream chart for the cozystack-authored one):

- #3242: the controller Deployment's spec.selector.matchLabels changed
  ({instance,name} -> {name,control-plane}). spec.selector is immutable, so
  `helm upgrade` cannot patch the existing Deployment and the whole HelmRelease
  upgrade fails ("field is immutable") -- the operator and the v1alpha2 CRDs it
  serves never come up, which blocks the etcd v1alpha2 adoption. Fresh installs
  use the new selector directly, so fresh-install CI does not catch it. Add a
  pre-upgrade hook that deletes the Deployment ONLY when its live selector is
  the pre-1.6 one (lacks control-plane=controller-manager), so Helm recreates it
  cleanly. No-op when the selector already matches (rc.1 -> later) and never
  runs on a fresh install (pre-upgrade only). Keeping the selector stable in the
  chart is not an option: rc.1 already shipped the new selector, so aligning it
  back would merely move the immutable break to rc.1 -> next.

- Raise the manager's cold-start memory limit floor 128Mi -> 256Mi (and the VPA
  minAllowed to match). The steady-state working set is ~250Mi (the VPA's own
  recommendation); at 128Mi a Pod that starts before the VPA admission webhook
  rewrites it (e.g. a Deployment recreated out of band, or the webhook briefly
  unavailable during upgrade) OOMKills into a crash loop. Defense in depth so
  the operator never depends on VPA timing merely to avoid crashing; the VPA
  still scales it further under load up to maxAllowed.

Verified live on a 1.5 -> 1.6 adoption: the delete-Deployment step plus the VPA
re-applying 256Mi+ let the operator come up and the adoption complete.

Refs: #3242, #3243

Signed-off-by: Andrey Kolkov <androndo@gmail.com>
…bectl, tests

Addresses review on #3265:

- pre-upgrade-selector-fix hook: add runAsUser: 65532. clastix/kubectl's
  image user is the non-numeric name `nonroot`, which the kubelet cannot
  verify against runAsNonRoot: true — so the Pod fails admission and the
  hook never runs, silently blocking the very 1.5->1.6 upgrade it exists to
  unblock. Matches postgres-operator's webhook-ready hook (same image).

- Digest-pin the kubectl image (the comment already claimed digest-pinning
  but shipped a floating v1.32 tag). Reuse the digest postgres-operator
  already vendors, add the renovate annotation, and template repo:tag@digest.

- Tests (both charts have CI helm-unittest suites, none previously covered
  these):
  - etcd-operator/tests/selector-fix-hook_test.yaml: hook wiring, namespaced
    least-privilege RBAC, numeric-non-root securityContext (guards the
    runAsUser regression above), digest-pinned image.
  - etcd-operator/tests/deployment_test.yaml: assert the 256Mi cold-start
    memory floor so it can't silently drop back to an OOMKilling value.
  - extra/etcd/tests/etcd-cluster_test.yaml: assert the transitional
    etcd-headless Service (headless, publishNotReadyAddresses, member
    selector, client/peer ports).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
…luster

Migration 50 (etcd.aenix.io -> etcd-operator.cozystack.io/v1alpha2 adoption)
had two defects that blocked every in-cluster 1.5 -> 1.6 upgrade on a cluster
with an existing etcd:

1. Cert-SAN wait treated transient kubectl failures as "SAN absent".
   ensure_wildcard_sans checked/awaited the wildcard SAN with
   `kubectl get ... 2>/dev/null | grep`, so any transient GET failure (API
   discovery refresh, apiserver blip, throttling) produced an empty string
   indistinguishable from a genuine absence -> false miss, and the 120s wait
   never recovered. Replace the two ad-hoc checks with a _san_present helper
   that retries on an empty read (a real Certificate/Secret never has empty
   dnsNames/alt-names) and accepts the native wildcard from EITHER the issued
   Secret's cert-manager.io/alt-names annotation OR the Certificate
   spec.dnsNames (the source of truth for what cert-manager will issue).

2. etcd-migrate had no kubeconfig in-cluster.
   etcd-migrate only reads a kubeconfig file (-k/--kubeconfig, default
   /root/.kube/config) and, unlike kubectl, does not fall back to the mounted
   in-cluster ServiceAccount. The hook Job set no KUBECONFIG and passed no
   --kubeconfig, so both the dry-run and --apply aborted with
   "error building kubeconfig: stat /root/.kube/config: no such file".
   Synthesize an in-cluster kubeconfig from the mounted ServiceAccount and pass
   --kubeconfig to both etcd-migrate invocations.

Verified end-to-end on a 1.5.2 -> 1.6.0-rc.1 upgrade: the adoption now
completes in-place (pods never restarted, data intact) and the cluster reaches
readyMembers=3 / Available=True.

Refs: #3243, #3255

Signed-off-by: Andrey Kolkov <androndo@gmail.com>
…t-gated wait, IPv6 kubeconfig)

Review feedback on #3261 (gemini-code-assist, coderabbitai, myasnikovdaniil):

- Exact SAN match. _san_present read the Certificate SANs as {.spec.dnsNames}
  (a bracketed JSON blob that `tr ','` cannot split) and matched with a
  substring `grep -qF`, so a wildcard that is a substring of a longer SAN
  (e.g. *.etcd.<ns>.svc inside *.etcd.<ns>.svc.cluster.local) could
  false-positive and skip the re-issue. Read {.spec.dnsNames[*]} (space
  separated) and match exactly with `grep -qxF`, matching the Secret-annotation
  branch.

- Wait on the re-issued Secret, not the patched spec. The post-patch wait loop
  called _san_present, which returns as soon as the Certificate spec.dnsNames
  contains the wildcard -- but the patch just added it there, so the loop broke
  on the first iteration before cert-manager re-issued the Secret, and the
  "re-issued" log printed unconfirmed. Add _secret_has_san (Secret alt-names
  annotation only, same empty-read retry + exact match) and gate the wait on it.

- IPv6-safe kubeconfig server. The synthesized kubeconfig used
  https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT, which is an invalid
  URL on an IPv6-only cluster (a bare IPv6 host must be bracketed). Use
  https://kubernetes.default.svc -- IP-family-agnostic, validated by the mounted
  SA CA, and the same server URL kubectl synthesizes in-cluster.

Tests (the review blocker): extend the bats suite (and its fake kubectl) with
two cases pinning these contracts -- a superstring dnsName must NOT skip the
re-issue patch, and etcd-migrate must be invoked with a synthesized in-cluster
kubeconfig whose server is kubernetes.default.svc (not the bare IPv6 host),
authenticating via the SA token file + CA. Both the kubeconfig path and the SA
directory are now env-overridable (ETCD_MIGRATE_KUBECONFIG, ETCD_ADOPT_SA_DIR)
so the synthesis is exercisable off-cluster; production defaults are unchanged.

Signed-off-by: Andrey Kolkov <androndo@gmail.com>
The default backupStorage.endpoint is http://seaweedfs-s3...svc:8333, but
Cozystack ships SeaweedFS with global.seaweedfs.enableSecurity=true, so its
in-cluster S3 serves TLS on :8333 behind the self-signed "SeaweedFS CA".
Every cozy-default backup (etcd/mariadb/velero/fdb/cnpg) therefore hits a TLS
listener over plaintext and fails the handshake. This is fatal for the etcd
v1alpha2 adoption migration: its mandatory pre-upgrade safety snapshot writes
to this endpoint, so migration 50 hard-fails and blocks the 1.5->1.6 upgrade
on any cluster with a legacy etcd. The Etcd Strategy S3 schema has no
caCert/insecureSkipVerify field, so it cannot target the self-signed
in-cluster endpoint at all.

Add a "backupstrategy-controller.endpoint" helper that, for a provisioned
bucket, resolves the endpoint from the COSI bucket's system credentials
Secret (backupStorage.systemSecretName) — the external S3 ingress with an
ACME cert, the same trusted endpoint COSI advertises and every backup
operator can verify — and forces the https:// scheme. All Strategy CRs, the
Velero BackupStorageLocation and the controller Deployment env are routed
through it. It falls back to .Values.backupStorage.endpoint for external S3
(provisionBucket=false) and for offline `helm template`/unit renders and the
pre-reconcile first install, where the Secret lookup returns nothing (Flux
re-renders on spec.interval once the Secret exists).

helm-unittest extended to assert every consumer picks up the resolved
endpoint (CNPG/Etcd/Velero keep the full URL, MariaDB/FDB strip the scheme
and derive their secure flag) plus the external-S3 verbatim fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The `backupstrategy-controller.endpoint` helper changed how the S3 endpoint is
resolved (for a provisioned bucket it derives from the COSI system Secret and
forces https://, with `backupStorage.endpoint` demoted to a fallback), but
docs/operations/backup-classes.md still described the old single-verbatim-value
behavior and directly contradicted the code:
- the "Endpoint format per driver" intro said the templates adapt "the single
  backupStorage.endpoint value";
- it claimed drivers "pull from backupStorage.endpoint in chart values, not
  from the Secret" — now precisely backwards for provisioned buckets;
- the values-table `endpoint` row omitted the fallback/derive semantics.

Rewrite those to match the new helper + the values.yaml comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
…k resources

- backupstrategy-controller: trim() the decoded S3 endpoint before scheme
  stripping so a trailing newline in the Secret can't yield a malformed URL
- etcd-operator: give the pre-upgrade selector-fix hook container resource
  requests/limits (matches postgres-operator webhook-ready), +unittest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil
myasnikovdaniil changed the base branch from fix/etcd-v1alpha2-transition to main July 15, 2026 14:20
@myasnikovdaniil
myasnikovdaniil dismissed Andrey Kolkov (androndo)’s stale review July 15, 2026 14:20

The base branch was changed.

@myasnikovdaniil
myasnikovdaniil force-pushed the fix/etcd-3265-review-fixes branch from d49b853 to 4af5a82 Compare July 15, 2026 14:20
@myasnikovdaniil myasnikovdaniil changed the title fix(etcd): harden v1alpha2 transition — hook runAsUser, digest-pin kubectl, tests fix(etcd): complete v1alpha2 transition for in-cluster 1.5→1.6 upgrades Jul 15, 2026
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Rebased PR on main to use new e2e testsuite Andrey Kolkov (@androndo) please rereview this it is basically same diff but tested over fresh main

@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

This is a carefully-scoped, well-documented set of fixes for the in-cluster 1.5→1.6 etcd upgrade path. I traced every load-bearing wiring assumption to the actual code and found no bugs, no security issues, no regressions, and no documentation drift.

What I verified (the claims that would silently no-op the fix if wrong)

  • etcd-headless Service selector is correct. The transitional Service selects etcd-operator.cozystack.io/cluster: etcd. The EtcdCluster in packages/extra/etcd/templates/etcd-cluster.yaml:14 is hardcoded name: etcd, and the operator's selector form is confirmed by the CRD doc (packages/system/etcd-operator-crds/templates/etcdclusters.yaml:3289etcd-operator.cozystack.io/cluster=<name>). So the selector resolves the real member Pods. Headless + publishNotReadyAddresses: true is right for restoring etcd-<i>.etcd-headless.<ns>.svc per-pod records. No name collision with the operator-owned native etcd Service.
  • Pre-upgrade hook targets the real Deployment. etcd-operator-controller-manager matches deployment.yaml:6. The 1.6 selector carries control-plane: controller-manager (deployment.yaml:15-16), and the hook deletes only when that label is absent — correct no-op on 1.6→later, correct delete on pre-1.6. runAsUser: 65532 fix is legitimate (clastix/kubectl's nonroot user is non-numeric and fails the kubelet runAsNonRoot check without it).
  • 256Mi floor actually flows. The manager container consumes .Values.resources (deployment.yaml:71), and containers[0] is manager, so the deployment_test.yaml assertion targets the right container.
  • The endpoint helper's Secret key exists. index $secret.data "endpoint" is a real key: packages/system/bucket/templates/user-credentials.yaml writes a scheme-stripped bare-host endpoint into bucket-cozy-backups-system-credentials in backupStorage.namespace (tenant-root) — the exact {namespace, name} the helper looks up. The b64dec | trim | trimPrefix | printf "https://%s" chain correctly reconstructs https://<host>, and the render-time lookup is the same pattern already used by the established backupstrategy-controller.bucketName helper (run by Flux's helm-controller, which has the cluster read access).
  • Migration-50 shell logic is sound. _san_present/_secret_has_san retry only on a genuinely-empty read (never legitimately empty on a real object) and match exactly with grep -qxF — the superstring false-positive is really closed. The "patch when unsure" direction is safe (patch is idempotent). The synthesized kubeconfig uses kubernetes.default.svc (IPv6-safe) and tokenFile/certificate-authority from the SA dir; heredoc expansion is correct. #!/bin/bash, so return (bare) returning the last command's status behaves as intended.

Test coverage

Good for everything that is unit-testable. Both valid and fallback/edge paths are covered: the endpoint suite asserts full-URL delivery to CNPG/Etcd/Velero, scheme-flip for MariaDB/FDB, and the external-S3 verbatim path; the migration bats suite adds the exact-SAN superstring guard and the synthesized-kubeconfig contract (including the "no bare IPv6 in the URL" negative assertion); the hook and Service suites pin the rendered shape.

Notes / recommendations (not blockers)

  1. Two runtime contracts have no automated regression guard, by nature. (a) The endpoint derive-from-Secret path relies on helm lookup, which returns nothing under helm-unittest — the unit tests exercise only the fallback, as the suite's own NOTE admits. (b) The hook's delete-if-pre-1.6 shell logic can't be exercised by helm-unittest (it only renders). Both are genuinely e2e-only contracts, verified live on a 1.5.2→1.6.0-rc.1 adoption per the PR body, and consistent with this repo's philosophy that the plugin↔cluster contract belongs in e2e, not in manifest-parsing unit tests. Recommendation: make sure the named 1.5→1.6 e2e upgrade job is actually wired into CI as the authoritative guard the PR body claims it to be — that is the only thing standing behind both behaviors.
  2. The endpoint helper unconditionally forces https:// for provisioned buckets. Correct for the SeaweedFS/COSI ACME-ingress assumption this PR documents, but the producer strips the scheme into the Secret, so the original scheme is lost and a hypothetical future COSI driver advertising a plain-http endpoint would be silently upgraded to https. Documented assumption; fine for the supported config, worth a mental note if external-COSI support widens.

@myasnikovdaniil
myasnikovdaniil merged commit f6a03e9 into main Jul 16, 2026
17 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the fix/etcd-3265-review-fixes branch July 16, 2026 07:37
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/testing Issues or PRs related to testing (e2e, bats, unit tests) kind/bug Categorizes issue or PR as related to a bug size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants