Commit Graph
9 Commits
Author SHA1 Message Date
Myasnikov Daniil 47e24f01d1 fix(platform): hand seaweedfs-db over for every instance name, not just the default
Migration 43 performs the v1.5.0 db-split hand-over: it re-owns Cluster/seaweedfs-db
from <name>-system to the new <name>-db release and stamps
helm.sh/resource-policy: keep, so the <name>-system upgrade — whose post-split
chart no longer renders the Cluster — does not prune it as a removed resource.

It compared the owning release name against the literal "seaweedfs-system".
SeaweedFS is a user-creatable kind, so an instance named `foo` is owned by
`foo-system` and was silently skipped: no re-own, no keep. The <name>-system
upgrade then deleted the Cluster, CNPG took its PVC with it, and the filer
metadata — the index for every object in that tenant's S3 — was destroyed. Data
loss, not an outage: the volume PVCs still hold the bytes, nothing can find them.

Verified live on the upgrade stand, which is a natural control group. All five
tenants sit in the SAME failed-upgrade loop (<name>-system last-deployed is
rev 1, a pre-split revision whose manifest still contains the Cluster, so every
retry recomputes the same deletion):

  tenant-root/dsplit/l   instance `seaweedfs`  owner=seaweedfs-db  keep   Cluster alive
  tenant-named           instance `foo`        no Cluster at all          Cluster GONE

The only difference is that migration 43 fired for the default-named instances
and skipped `foo`. That also answers whether `keep` is sufficient: three tenants
survive the identical prune loop with it.

Correction to the reported mechanism: this is the 1.4->1.5 db split (PR #2601,
v1.5.0), not a 1.5->1.6 change — v1.5.3 and main are identical on every
db-split file. And the split does protect the object; it just only protected the
instances someone happened to name `seaweedfs`.

- lib/seaweedfs-db-adopt.sh: shared hand-over matching the `-system` SUFFIX, so
  `foo-system` -> `foo-db` exactly as `seaweedfs-system` -> `seaweedfs-db`.
  Idempotent, and refuses a release named literally `-system` rather than
  annotating an owner of `-db` that no release would claim.
- Migration 43 sources it: covers clusters upgrading from before 43 (1.4.x).
- Migration 53 (new) re-runs it: migrations never re-run, so any cluster already
  at >= 44 ran the hardcoded version and is still exposed. As a pre-upgrade hook
  it lands before <name>-system re-renders, closing the window on THIS upgrade.
  This is the one that matters for 1.5.x -> 1.6.
- targetVersion 53 -> 54.

Tests fail on unfixed code: the non-default-name case produces no ANNOTATE at
all against the hardcoded 43, while the default-name case passes — which is
precisely the bug's shape. They drive the real migration scripts against a fake
kubectl, following hack/migration-50-etcd-adopt.bats.

LIMIT, stated plainly: a Cluster that is already deleted cannot be recovered by
either migration — there is nothing left to re-annotate and the PVC went with
it. Such a tenant needs seaweedfs-db restored from a backup. Runbook Step 0 adds
the ownership audit that distinguishes at-risk from already-lost, and records
that <name>-db can report Ready while its Cluster is gone (it rendered fine; a
later <name>-system prune removed it and Flux has not re-checked) — so the
audit trusts `kubectl get cluster`, not the HelmRelease status.

Review round 2 — two ways this still lost the database:

- OWNERSHIP IS NOT SAFETY. The helper skipped any Cluster already owned by
  <name>-db, treating ownership as proof the hand-over was done. It is not: where
  the hand-over was skipped, <name>-system prunes the Cluster and <name>-db
  RECREATES it under its own ownership with NO keep, while <name>-system's prune
  baseline still lists it — so the next reconcile deletes it again. That is the
  observed delete/recreate loop, and the skip walked straight past it. Verified on
  the stand: tenant-l and tenant-root are seaweedfs-db-owned AND their
  <name>-system deployed revision (rev 1) still contains the Cluster, so only keep
  saves them; tenant-fresh is seaweedfs-db-owned without keep but was installed
  after the split, so its baseline never had one. Telling those apart needs the
  release's deployed manifest, which this script cannot read cheaply or reliably.
  The costs are asymmetric — a needless keep leaves an orphan on delete (now
  reclaimed by the cleanup hook), a missing one loses the database — so keep is
  stamped on every Cluster owned by either side of the split.

- FAIL OPEN, THEN STAMP. `set -euo pipefail` does not abort on a failing command
  substitution in a `for` word-list: the loop ran zero times, the script continued,
  and stamp_cozystack_version ran anyway. Migrations never re-run, so one throttle,
  RBAC hiccup, or not-yet-established CNPG apiservice behind the pre-upgrade hook
  permanently left every at-risk tenant exposed with no later migration to catch
  them. Reproduced: against the old helper a failing fleet scan still logged
  STAMP 54. Now every kubectl failure is fatal EXCEPT the two that genuinely mean
  "nothing to do" — the CNPG resource type not being served (the fail-open that IS
  load-bearing: a cluster without CNPG must still upgrade) and a Cluster vanishing
  between scan and read. An unreadable annotation is no longer indistinguishable
  from an absent one, and the unowned case now warns instead of passing silently.

- ORPHAN ON DELETE. `keep` is permanent (removal deferred to the 1.7 batch
  migrations) and also survives the <name>-db release's own uninstall, so
  `kubectl delete seaweedfs <name>` left the CNPG Cluster, its Postgres pods and
  its PVCs behind — the cleanup hook selects
  app.kubernetes.io/instance=<name>-system, which the db PVCs
  (cnpg.io/cluster=seaweedfs-db) do not carry. Migration 53 widened that from
  pre-1.5 default-named tenants to every instance name, so the hook now deletes
  the Cluster explicitly; its PVCs carry ownerReferences to it and follow.

- Tests assert the stamped VERSION (44 / 54), not a bare "STAMP": a wrong number
  would loop run-migrations.sh forever and the old assertion could not tell.

Review round 3 — the reclaim introduced a data-loss path of its own:

- DELETING ONE APP DELETED ANOTHER APP'S DATABASE. The db chart hardcodes the
  Cluster name `seaweedfs-db` for every instance, and the reclaim was scoped by
  that name in the namespace rather than by what the release owns.
  templates/seaweedfs-db.yaml gates the <name>-db HelmRelease on
  `topology != "Client"`; the cleanup hook had no such gate. Verified by render:
  `helm template s3-remote . --set topology=Client` emits ZERO s3-remote-db
  HelmReleases yet still emitted the Role granting delete on clusters/seaweedfs-db
  AND `kubectl delete cluster.postgresql.cnpg.io -n tenant-root seaweedfs-db`. So
  in a namespace running a server instance plus a Client, deleting the CLIENT
  destroyed the SERVER's filer metadata — CNPG takes the PVC via ownerReferences —
  for an app the operator never touched. Reachable the same way via a second Simple
  instance, whose <name>-db loses Helm's ownership check but which still installs
  and still carries the hook. main's hook was instance-scoped; this was new, and
  mine.
  Now gated exactly as seaweedfs-db.yaml is, and the Job re-checks
  meta.helm.sh/release-name == <release>-db at run time before deleting. The gate
  stops the permission being granted where it can never be legitimate; the runtime
  check stops the delete where it can.

- `|| echo` turned any API/authz/admission failure into exit zero, so backoffLimit
  never retried and hook-delete-policy removed the only cleanup Job while the
  keep-protected Cluster stayed orphaned. A failed reclaim now fails the Job.

- TEST SHELL. The migrations run under /bin/sh = busybox ash (the image is FROM
  alpine, run-migrations.sh is #!/bin/sh), but the tests drove them with bash — so
  the fail-closed guarantees were asserted in a shell that never runs them, which
  matters more than usual given the bug they cover was a fail-open. The scripts are
  now invoked via `sh` and the fake kubectl is POSIX sh. Verified directly against
  the production base image (busybox 1.37.0 / alpine:3.24): `set -o pipefail` is
  supported, every script passes `ash -n`, and the failing-fleet-scan path exits
  non-zero without stamping under real ash. The recipe is in the bats header.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-20 15:40:48 +05:00
Myasnikov Daniil 0ba5337526 fix(migrations): key the etcd-adoption endpoint scheme off the claimed 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>
2026-07-20 11:29:09 +05:00
Myasnikov Daniil 9ceccd2b07 fix(migrations): derive the etcd-adoption snapshot target from the projected 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>
2026-07-20 11:29:09 +05:00
Andrey Kolkov 47d239c519 fix(platform): address review on migration 50 (exact SAN match, Secret-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>
2026-07-15 19:11:35 +05:00
Andrey Kolkov d0740ed8c7 test(etcd): gate S3 backup round-trip behind ETCD_E2E_S3_ROUNDTRIP
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2026-07-06 14:51:48 +04:00
Andrey Kolkov cc5d688e15 feat(platform): migrate to etcd-operator v1alpha2
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2026-07-06 14:41:33 +04:00
Andrei Kvapil 5d7e56bffe [tests] refactor tests and remove e2e.applications
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2025-04-25 12:28:43 +02:00
klinch0 b9e80b9a91 add endpoint for http checks (#469) 2024-11-05 17:06:35 +01:00
klinch0 f06f653744 add values and checks (#454)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new configuration file for Kubernetes deployments,
enhancing clarity on parameters and settings.
- Added common parameters for NATS, including external access and
persistent volume settings.

- **Bug Fixes**
- Improved error handling and feedback in Helm release management
scripts.

- **Chores**
- Reduced verbosity in test output by removing unnecessary echo
statements in the testing Makefile.
- Added success return statements in various check scripts to ensure
proper termination.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2024-11-04 18:43:36 +01:00