Commit Graph
43 Commits
Author SHA1 Message Date
Aleksei Sviridkin cbbb69afc3 fix(platform): stop treating a failed ConfigMap read as a fresh cluster
run-migrations.sh decided whether cozystack-version exists by the exit
status of a silenced kubectl get, so a timeout or a dropped connection
counted as "no ConfigMap": the runner stamped the target version and
exited 0, and every pending migration was skipped for good. The hook Job
only renders when the ConfigMap already exists, so in practice an error
was the only way to reach that branch.

--ignore-not-found suppresses NotFound alone; any other error keeps its
non-zero status and now fails the pod, so the Job retries the read.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-07 12:06:18 +03:00
Aleksei Sviridkin bcda5c893a feat(kubevirt)!: drop HotplugVolumes for declarative volume hotplug (#4453)
## What this PR does

This turns off the deprecated KubeVirt `HotplugVolumes` gate and moves
the platform to declarative volume hotplug. It changes how every volume
hotplug on the platform works, so the title carries `!`. Closes #4032.

This PR is stacked on #4451 and has to merge after it. #4451 adds the
kubevirt-csi flag this PR flips.

The replacement gate is already on in KubeVirt 1.9, and the old gate is
the only thing keeping it off. `DeclarativeHotplugVolumes` is Beta at
v1.9.0, and Beta gates are on by default. virt-controller skips
`handleDeclarativeVolumeHotplug` while `HotplugVolumes` is on, so
dropping the old gate is what switches the new one on.

The kubevirt-csi fallback goes off in the same change. With the old gate
off, virt-api rejects the VMI-level `removevolume` on a VM-owned VMI, so
the chart now passes `--enable-vmi-hotplug-fallback=false`. The comments
next to the gate and the flag point at each other.

This needs the rebuilt kubevirt-csi image. A driver built before #4451
does not know the flag and exits on it at startup. The image is stamped
at release preparation and ships in the same release as this change, so
only a `make apply` from main before then would crash-loop the CSI
controller.

What changes at runtime on an upgrade:

- The VM-level `addvolume` and `removevolume` calls now edit `VM.spec`
directly. virt-controller then makes the VMI hotplug volumes match it,
instead of going through `VM.status.volumeRequests`.
- Volumes hot-plugged through the VM subresource are in `VM.spec`, so
they stay attached.
- During a tenant teardown, when the VM goes before its VMI, the driver
no longer calls the VMI subresource. It waits for the VMI status to drop
the volume, and the garbage collector deletes the VMI with its VM.
Upstream waits up to two minutes. If the VMI is still there after that,
the unpublish fails and is retried.

A new platform migration keeps old VMI-only volumes attached. On its
first pass virt-controller removes every hotpluggable VMI volume that is
missing from `VM.spec`. Until July 2025 kubevirt-csi attached volumes at
the VMI level (the Role then granted
`virtualmachineinstances/addvolume`), and live migration carries such a
VMI across KubeVirt updates. Without help, a tenant node not recreated
since then would lose a mounted disk on upgrade.

Migration 59 copies each such volume and its disk from the VMI into the
VM template before anything else changes. Adding a hotplug volume to the
VM spec is a valid live update in KubeVirt, so no VM needs a restart. It
runs as the platform chart's pre-upgrade hook, before the upgrade
re-points `cozystack-packages`. The KubeVirt CR and the tenant
kubernetes charts both render from that source, so the gate and the flag
cannot change before the migration has finished. It only touches VMs
that need it, logs each one, and a second run changes nothing.

The migration also makes ephemeral hotplugs permanent. A volume a user
added at the VMI level, for example with `virtctl addvolume` without
`--persist` from an older virtctl, has the same shape as kubevirt-csi's
old attachments, because virt-api builds both the same way. There is
nothing reliable to tell them apart by, so both are copied. Leaving the
user's volume out would not keep it ephemeral: the first reconcile would
unplug it from the running VM. After the upgrade it is part of `VM.spec`
and attaches again on a restart, and `virtctl removevolume` drops it.

Ephemeral hotplug stops working on VMs. With the old gate off, virt-api
refuses VMI-level `addvolume` and `removevolume` on a VMI that a VM owns
(`vmiVolumePatch` in v1.9.0). A volume on such a VMI is added and
removed through the VM from then on. A VMI without a VM is not affected.

Only running VMIs are copied. virt-controller reconciles no other VMI,
and a stopped VM builds its next VMI from `VM.spec`, so copying from a
stopped one would bring back volumes the restart was going to drop.

Migrations run only on the isp-full, isp-full-generic and isp-hosted
variants. KubeVirt comes from the platform only on isp-full and
isp-full-generic, so those are covered. On the default variant, where
packages are installed by hand, nothing runs the migration.

This optional check lists the VMIs and volumes the migration would copy.
It uses the same rule. On the default variant, run it before upgrading
and recreate each tenant node it lists:

```bash
kubectl get vm,vmi --all-namespaces --output json | jq --raw-output '
  ([.items[] | select(.kind == "VirtualMachine")
    | {key: "\(.metadata.namespace)/\(.metadata.name)", value: [.spec.template.spec.volumes[]?.name]}]
    | from_entries) as $vm
  | .items[] | select(.kind == "VirtualMachineInstance")
  | ([.metadata.ownerReferences[]? | select(.controller == true and .kind == "VirtualMachine" and (.apiVersion | startswith("kubevirt.io/"))) | .name][0]) as $owner
  | "\(.metadata.namespace)/\($owner)" as $key
  | select(.status.phase == "Running" and $owner != null and $vm[$key] != null)
  | .spec.volumes[]? | select(.dataVolume.hotpluggable == true or .persistentVolumeClaim.hotpluggable == true)
  | .name as $n | select($vm[$key] | index($n) | not)
  | "\($key) \($n)"'
```

Empty output means nothing is affected. I checked the query against the
migration's test fixtures only. Neither the query nor the migration has
run on a live cluster.

#4287 also adds a migration 59. If it merges first, this one moves to
60: the file, the version it stamps, `migrations.targetVersion`, the
bats file (its `run_migration` argument and expected `STAMP`) and
`hack/testdata/migration-59-vmi-hotplug`.

A VM with a pending hotplug request at upgrade time may also get stuck.
virt-controller still processes `VM.status.volumeRequests` before the
declarative handler, and it applies them through the VMI subresource,
which virt-api rejects once the old gate is off. The sync then fails
before the VM spec is updated, so nothing clears the request, and every
later sync of that VM fails the same way. A retry through the VM
subresource edits the spec and clears it, which covers kubevirt-csi's
own requests. A one-shot request stays stuck until the VM restarts. I
read this from the v1.9.0 code and haven't reproduced it. Waiting until
this lists nothing before upgrading narrows the window, but a request
made during the rollout can still hit it:

```bash
kubectl get vm --all-namespaces --output json | jq --raw-output '.items[] | select((.status.volumeRequests // []) | length > 0) | .metadata.namespace + "/" + .metadata.name'
```

The two halves live in different HelmReleases, so there is a short
window during the rollout where the driver and the gate disagree. In
that window an unpublish fails and is retried until both are updated.

A tenant cluster whose `kubernetes` HelmRelease is suspended or failing
keeps its old driver for longer. Drivers from before July 2025 attach
through the VMI subresource, so on such a tenant every attach is refused
until its release reconciles. Such a driver can also attach a volume
after the migration has run and before the gate flips. The migration
doesn't see that volume, and the first declarative reconcile unplugs it
from the running VM.

The migration lists the VMs once and patches each one with a
`resourceVersion` test, so a VM that changes in between fails the hook
Job. The Job has `backoffLimit: 3`, and each retry lists again and skips
the VMs already patched. Re-reading only the conflicting VM would avoid
the retry; I left that out.

The new gate is listed in the CR even though it is on by default. With
the old gate gone it is the only hotplug path, and the chart refuses a
render that disables it. I found no other user of `HotplugVolumes` in
the platform or in the downstream repositories.

The VMI-level fallback may have been failing already. The kcsi Role has
no rule for the VMI-level `removevolume` (#4452), so that call likely
got 403 with or without the gate. I haven't checked that on a cluster.

`hack/migration-59-vmi-hotplug-volumes.bats` runs the migration against
a fake kubectl: a VM that needs the copy, VMs and VMIs that must be left
alone (a VMI that is not Running among them), a second run, a cluster
without KubeVirt and the fail-closed paths. The helm-unittests for the
KubeVirt CR pin the whole gate list, accept `HotplugVolumes` in
`disabledFeatureGates` and refuse `DeclarativeHotplugVolumes` there. The
CSI test pins the flag at false, and the `make update` idempotency test
still passes. None of that exercises hotplug itself. The real test is
e2e with volume attach and detach on a tenant cluster, including a
teardown. No CI lane upgrades from the previous release yet, so an
upgrade with a live VMI-only volume is untested; #4463 tracks that test.

### Screenshots

No UI change.

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(kubevirt)!: KubeVirt now runs with DeclarativeHotplugVolumes instead of the deprecated HotplugVolumes feature gate, and kubevirt-csi no longer uses the VMI-level hot-unplug. Volume hotplug requests are applied through VM.spec. The upgrade copies volumes that older kubevirt-csi versions attached only to the VMI into VM.spec, so they stay attached; the check in the PR description is optional. This also applies to ephemeral volumes added to running user VMs at the VMI level, such as with virtctl addvolume without --persist: they become part of VM.spec and attach again after a VM restart. virt-api now refuses VMI-level addvolume and removevolume on a VMI owned by a VM, so such volumes are added and removed through the VM. A tenant cluster whose kubernetes release is suspended or failing keeps its old kubevirt-csi, and a driver from before July 2025 cannot attach volumes there until that release reconciles, and a volume it attaches during the upgrade itself can be unplugged again. A values override that puts DeclarativeHotplugVolumes into disabledFeatureGates now fails the kubevirt render. Clusters on the default variant do not run platform migrations and should run that check before upgrading.
```





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

## Summary by CodeRabbit

* **New Features**
  * Declarative hotplug volumes are enabled by default for KubeVirt.
* Running virtual machines keep eligible hotplugged volumes and disks
when their configuration is migrated.
* **Bug Fixes**
* The migration avoids adding duplicate volumes or disks and does not
mark the migration complete if required operations fail.
  * The Kubernetes CSI driver no longer uses the VMI hotplug fallback.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-06 17:06:03 +03:00
Aleksei Sviridkin 3a07885b70 fix(platform): resume a retried migration Job from the stamped version
The migration hook Job gets CURRENT_VERSION once, at render time, and a
failed pod is retried with that same environment. Each migration that
finished has stamped the cozystack-version ConfigMap by then, yet the
retry started from the rendered version and ran those migrations again.
Migrations are written to run once, so a re-run is not safe in general.

The runner now starts from the version stamped in the ConfigMap. A
failed read aborts the pod, since falling back would bring the re-run
back and the Job retries anyway. The rendered value is kept only when
the ConfigMap holds no integer version, which is also the case where
the chart could not derive a real one from it. An all-digit stamp past
the shell's integer range aborts too: its comparison fails inside an
if, out of reach of set -e, and the Job would go green having run
nothing, while the chart renders that stamp as 0.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-02 21:54:46 +03:00
Aleksei Sviridkin fd8ccb2fcb feat(platform): keep VMI-only hotplugged volumes across declarative hotplug
Dropping the HotplugVolumes gate lets virt-controller rebuild each
running VMI's hotplug volumes from VM.spec, and it removes every
hotpluggable VMI volume the VM spec does not list. kubevirt-csi
attached tenant volumes at the VMI level until mid-2025 and live
migration carries such a VMI across KubeVirt updates, so a tenant node
that was never recreated can still hold a mounted disk only its VMI
knows about; the first reconcile after the upgrade would unplug it.

Migration 59 writes each such volume and its disk into the VM template
as they are on the VMI, before the upgrade re-points cozystack-packages
and so before the KubeVirt CR and the kubevirt-csi flag change. Hotplug
volume changes are a valid live update, so no VM needs a restart.

An ephemeral hotplug a user made at the VMI level, such as virtctl
addvolume without --persist, has the same shape, because virt-api gives
every VMI-level addvolume that shape. It is copied too and becomes
permanent: leaving it out would not keep it ephemeral, since the first
reconcile would unplug it. Only Running VMIs are considered, matching
virt-controller, which reconciles no other; a stopped VM's next start
builds its VMI from the VM spec, so its VMI-only volumes are not
resurrected.

Only the variants with migrations enabled run it. The default variant
renders no migration hook, so a cluster there that installed KubeVirt
by hand has to check for such volumes itself before upgrading.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-28 23:37:48 +03:00
Myasnikov Daniil edd54252c3 test(tests): exercise OIDC and certificate fixture branches
OIDC values do not reach tenant realm-group templates unless Helm also
knows the Keycloak API. Harbor's Gateway setting bypasses its Ingress
certificate branches, so certificate fixtures need the ingress path.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-09-25 09:46:43 +05:00
Myasnikov Daniil 97053d125e test(tests): verify render fixtures and lookup skip failures
A fixture with unused scheduling keys can render successfully without
exercising scheduling. A skip caused by an unrelated failure can also
survive after its original lookup restriction is gone. These cases need
checks of the rendered result and the failure reason.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-09-25 09:46:42 +05:00
Myasnikov Daniil 0cf2d5b34c test(tests): exercise shared-service render fixtures
An empty shared-service namespace disables templates that tenants with
inherited services rely on. Rendering only that state leaves those
templates unchecked.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-09-25 09:46:42 +05:00
Myasnikov Daniil 31bade054d test(tests): add offline app chart render checks
helm-unittest suites can select individual templates, leaving other
templates unchecked even when the chart's tests pass. Rendering the full
chart with the platform's injected values catches those failures before
a cluster is needed.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-09-25 09:46:41 +05:00
Aleksei Sviridkin 9efe78ce44 test(e2e): hold the tenant-cluster install wait to an ordering rule (#3802)
A test that creates a tenant `Kubernetes` cluster must not end while
that release is still installing. The kind drops the install wait on its
main resources, but the chart's blocking post-install hook still holds
the install open, so a test that asserts and ends hands the rest of it
to teardown. Teardown then reds on a step that tests nothing. Reading
the cluster mid-install is fine. Ending mid-install is not.

`hack/e2e-chainsaw/_lib/run-kubernetes.sh` already waits for the parent
release before its tenant-cluster assertions, and that wait is older
than this change. Nothing held it there. This adds the guard that does.

The guard reads the script, not Chainsaw fixtures: the cluster comes
from a heredoc, so a guard walking `apply.file` would find nothing to
check and pass. It requires the CR, then a readiness wait on the parent
release, then the assertions the test ends on. The wait has to block,
and it has to be unconditional in the body that holds it. One more case
holds this script to being the only creator of that kind, since a second
one would be governed by no rule here.

The ordering is read within one function body, so splitting
`run_kubernetes_test` reds the unit lane until the rule is pointed at
the new shape.

Timeout length stays with review. The floor depends on where the wait
sits, and the script text does not carry that.

Which spellings each matcher takes, which rewrites it refuses instead of
judging, and the blind spots it names are written in the guard's header
next to the code, each with its mechanism and whether it fails loudly or
quietly. Fixtures mark their own lines, so a probe resolves by name
instead of by a line number that drifts.

No runtime code here: the guard, its fixtures, and the conventions that
state the rule.

```release-note
NONE
```
2026-09-22 19:00:52 +04:00
Timofei Larkin c398e0e116 Merge branch 'main' into chore/drop-http-cache
main took migration 57 and moved migrations.targetVersion to 58 while this
branch was open, so that line is the one conflict: 57 at the merge base, 58
on main, 59 here. Resolved as 59, which is what this branch's migration 58
requires, since run-migrations.sh loops up to targetVersion - 1.

Merged rather than rebased because the branch carries commits that are not
mine to rewrite.

Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-09-18 13:20:03 +03:00
Timofei Larkin 2cbc02bd10 fix(platform): freeze HTTPCache on the artifact the cluster runs
Pinning the frozen source to a digest written into the migration picks one
release for every cluster, and v1.6.0 is not even the last one that ships
http-cache: v1.6.1 through v1.6.3 and v1.7.0-alpha.1 all do, and 1.6 patch
releases may keep doing so. A cluster on any of those would be rolled back
to an older chart by the upgrade that was meant to leave its instances
alone, and an air-gapped mirror resolves the constant only if someone
mirrored that exact manifest.

The hook runs before the upgrade re-points cozystack-packages, so the live
object still names the release the instances render from, in whatever
registry the operator serves it from and with whatever pull secret and
verification they configured. Reading the digest off it makes the freeze a
no-op by construction and needs no knowledge of which tag was last.
spec.ref.digest is used when the installer pinned one; a tag or semver ref
goes through status.artifact.revision, whose digest half is the manifest
digest. status.artifact.digest is the checksum of the downloaded tarball
and cannot stand in. A source with neither has nothing to freeze on and the
migration stops there. The copy also carries the no-delete label the
platform chart puts on the original, because it is the only chart source
those instances have left.

The tenant grant travels with the freeze as well. cozy:tenant:admin:base
enumerates the apps.cozystack.io resources a tenant owner may write, and an
application that has left the catalog has no business in that list: the
next sweep over it takes the entry as dead config, and the failure is
silent and remote, a Forbidden for the one person who still needs to remove
what they have. The chart drops httpcaches, and a cluster that freezes gets
a ClusterRole under the tenant admin aggregation label instead, applied
before the upgrade drops the rule so the access never lapses and carrying
the verb set the chart had so nothing changes for the owner. A cluster with
no instance is granted nothing.

The Package delete on the no-instance path has to block. The operator
reconciles off an informer cache, so a reconcile in flight against the
pre-delete entry re-creates the release it owns, and with it the
ApplicationDefinition and the Helm storage the following lines remove.
Returning immediately made the ordering a property of the command log
rather than of the cluster.

The preset check's SIGPIPE note named http-cache-rd as its example of a
large enum, and that chart goes away here; kubernetes-rd carries the same
enums.

Numbered 58 rather than 57: main already carries a migration 57, the
SeaweedFS volume-size-limit one, and its targetVersion is already 58.

The whole shape of this retirement was worked out here rather than
anywhere reusable, so docs/deprecating-applications.md writes it down as a
procedure: how to pick between migrating onto a successor and freezing,
what the prune takes with it, and what the migration owes a cluster on
each branch. The next application to go should not have to rediscover it.

Assisted-by: LLM
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-09-18 13:12:56 +03:00
Aleksei Sviridkin a2db2aeb8c fix(seaweedfs): raise the number of buckets a volume server can serve
A volume server started with -max 0 takes a slot count derived from disk
rather than a fixed one, replication 001 spends a slot on each of the
two servers per logical volume, and a collection claims COPY_2 volumes
whenever it grows. So the bucket ceiling is set by the product of the
size limit and COPY_2, and moving either number alone moves it. The
mechanism is upstream in MaybeAdjustVolumeMax (weed/storage/store.go)
and findVolumeCount (weed/topology/volume_growth.go).

Do not divide these out of the nominal PVC size, because the count
follows the space the filesystem makes available and not the volume's
advertised size: the shipped 10Gi volume PVC was seen to give nine slots
per server at the upstream 1000 and 19 at 500, which puts 400 at 23 to
24 by the same division, or eight collections against two. The span is
the observation's own, since the free space behind 19 at 500 fits more
than one filesystem geometry and those divide differently at 400; eight
either way, because a collection that cannot get a whole batch of COPY_2
keeps the volumes it did get and writes into them. The division itself
is measured: on a 1 GB image the master reports max as the filesystem's
available space over the limit in every run, 25 slots and nine
collections at the 40 and 3 that scale to this pair. Two is what the
platform's own cozy-backups bucket and a single tenant bucket already
use. Past that every S3 PUT to a new bucket times out while the disk
still reads as nearly empty, and no COSI object reports anything wrong,
because the claim and the access are both granted.

400 and 3 make the budget 1200 MB per collection. Splitting the
reduction across both numbers rather than taking it out of the size
limit alone keeps the cost off the curve that grows: open descriptors
track stored bytes divided by the limit, so a 10 TiB server keeps
roughly 52k of them at this limit against 21k upstream, where moving the
size limit alone would have put it at 105k. A .dat and an .idx stay open
per volume; the .vif beside them is written and closed. The ceiling
reached is the same either way.

Both figures describe the shipped replicationFactor of 2. The master
picks its growth count from the replication the filer asks for, so at 1
and 3 copies it reads COPY_1 and COPY_3 instead and this COPY_2 does not
apply; lowering the size limit still raises the ceiling there.

The size limit was out of an operator's reach: it lived in the
data-plane chart, which is pulled by ExternalArtifact from a component
that declares no install section, so a Package-level values override for
it is dropped before any merge. The app chart now declares
master.volumeSizeLimitMB and sends it on every release, and 400 is set
there. The data-plane chart keeps upstream's 1000 as its fallback on
purpose. A platform upgrade can re-render that release from its artifact
before the parent has sent anything new, and a lower fallback would then
reach the master first and drop the limit ahead of an operator's own
setting. COPY_2 is sent by nothing and lives in the data-plane chart, so
it does land in that window, where a grow costs 3000 MB against the 6000
MB it cost before.

The app chart also writes maxVolumes into dataDirs on every release, and
because dataDirs is a list Helm replaces the data-plane chart's copy
wholesale, so the zero that makes the slot count auto-derived at all
comes from there. Losing it drops the server to the subchart's fixed
seven without failing any render.

The field is bounded at both ends, because either end takes an instance
down. Zero is not a smaller limit but an off switch: the volume server
leaves its slot calculation untouched at zero, keeps reporting no
capacity, and nothing is ever grown. Past 30000 the master does not
start at all. The bound sits in the values schema rather than in a
template guard, so Helm refuses the value before any template runs. The
apps API does not: it reads that schema only for defaults, so it accepts
a value out of range, and the release then fails to render. An explicit
null gets past both bounds, because Helm drops the key before it
validates, and would render an empty -volumeSizeLimitMB= that the master
exits on at start, measured on the shipped image, so the template
requires the value.

No bound against the disk goes with it, though the field invites one. A
limit at or above the volume's own size does not break an instance:
MaybeAdjustVolumeMax floors the slot count at one, measured on 4.31 with
a 255 MiB disk against a 1000 MB limit, which reported Max 1 and served
a write it then read back. Such a server is capped at a single slot
rather than dead, so refusing it at render would be a capacity policy
that stops a working release on every reconcile, not only on the
upgrade. The field's description carries the relation instead, where the
value is chosen.

The growth count is deliberately not exposed beside the limit. The
master picks which of its four growth counts to apply from the
replication the filer requests, and the filer takes that from
replicationFactor, so a field wired to COPY_2 would do nothing at 1 or 3
copies while still appearing to be a setting. A knob that is silently
inert for two of the three supported values is worse than no knob.

Two further prices. A collection absorbs the budget before it needs
another grow, so growth requests arrive five times as often as at the
upstream pair. A collection also starts with half as many writable
volumes, and a volume server takes the writes into each volume one at a
time (syncWrite holds that volume's dataFileAccessLock), so a heavily
parallel writer spreads over fewer volumes until the collection grows
again; that holds for instances kept at 1000 too. And lowering the limit
on an instance that already holds data carries the other one: its
volumes between the new limit and the old go oversized on the next
heartbeat and leave the master's writable set, and each affected
collection needs one successful grow before its next write lands.
Nothing is lost and the volumes stay readable; the room left inside them
for further writes is given up and does not return as capacity.

Those grows compete for one pool, and the pool is smaller than it looks.
An oversized volume is charged no headroom, while one below the limit is
charged all of its unfilled room; a grow then claims a whole COPY_2
batch and takes as much of it as is there, and the volumes it creates
are empty and charge their headroom straight back. Measured on a local
SeaweedFS 4.31 setup: two oversized collections with two free slots
between them, the first to write took both, and the second stayed
refused with the disk still reporting a quarter of itself free.
Collections that need no grow are not free either, since their volumes
below the new limit were created six at a time and each can hold back a
whole limit. Also measured there, with the limit and batch scaled down:
a server with free space for one batch per collection refused the grow
its one affected collection needed, because a second, nearly empty
collection held the room, and the same free space without it let the
grow through. The minFreeSpacePercent reserve only comes into it on a
server whose writable volumes have all passed the limit, where nothing
is charged headroom: from 8000 MiB of filesystem upward, a 10Gi PVC
measuring 9979, five percent is at least 400 MB, and such a server then
refuses a grow only once its disk is down to the reserve. Everywhere
else the headroom charge decides, and a refusal can arrive with the disk
far from full whatever the reserve. Deleting a collection gives the room
back a slot per volume, however little it stored, since the headroom
charged to those volumes leaves with them.

So, on a variant that runs platform migrations, the upgrade does not
change the limit on an existing instance unless its operator already set
one, which the previous chart accepted and ignored. Platform migration
57 writes master.volumeSizeLimitMB: 1000 into every existing SeaweedFS
instance that has not chosen a limit, and 400 applies only to instances
created from here on. It leaves alone only an instance whose master is
not an object, which no v1 chart renders, and names that one in its log.
The default variant runs no platform migrations, so a cluster there that
installed SeaweedFS by hand gets no pin, and the document below gives
the same patch to apply before upgrading. A chosen limit is a whole
number the new chart accepts; anything else counts as not chosen and is
replaced with 1000, which is what the previous chart ran in its place,
since it ignored the key and the new chart would refuse to render it. A
valid value already set is kept, and since the previous chart ignored
it, the upgrade is when it starts to apply; the document below lists the
ones that change the limit ahead of it. Raised values are listed as
well: existing buckets keep writing, but every volume below the new
limit is charged up to it, and on a local SeaweedFS 4.31 setup seven
nearly empty volumes left no free slot once the limit went up tenfold,
so a new bucket was refused. The value goes into the application's
HelmRelease, whose spec.values is the application spec, for an instance
created through the API and for one the tenant module renders alike. The
tenant module never renders spec.values on that release, and a Helm
upgrade leaves a key alone that neither manifest carries: measured with
Helm 4.3.0 on client-side, server-side and forced server-side apply, and
read in createPatch at the v4.1.1 that helm-controller v1.5.0 runs.
Where the migration runs, it is a pre-upgrade hook of the platform
release, and the cozystack-packages source every application chart comes
from is a manifest of that same release, applied only after its hooks;
so no instance meets the new chart unstamped, and a failed migration
stops the platform upgrade before that source moves. That holds while
the platform source is pinned by digest, as it is out of the box; one
that follows a tag, a semver range or a branch lets source-controller
move cozystack-packages on its own schedule. Until cozystack-packages
moves, each patched release keeps its old chart, which ignores the key,
since no v1 release reads it or closes the master object in its schema.
An instance kept at 1000 still gains, because at a replication factor of
2 a grow now claims three volumes rather than six, so it has room for
roughly twice as many new buckets.

Lowering an existing instance is left to its operator, and
docs/operations/seaweedfs-lowering-the-volume-size-limit.md is the check
for it. It lists each server's volume files and adds one limit for every
volume below the limit and a batch for every collection with no volume
at or below 90 percent of it, since the master grows such a collection
on its own schedule without a write; measured the same way, a collection
with every volume in that band and no writes got two new volumes per
server within one pass. Where each disk type is served by as many volume
servers as the replication factor, as the shipped two at a factor of 2
are, each collection's volumes sit on every server of that type, and
that bounds the claim from above without assuming how the collections
grew. The master places a new volume on any server of the requested disk
type with room rather than where its collection already lives, whichever
group or pool that server belongs to, and it reads an empty disk type as
hdd; under MultiZone it also grows each zone on its own. So the document
keeps zoned instances, and any disk type served by more servers than
copies, at 1000.

BREAKING CHANGE: on the default variant, which runs no platform
migrations, set master.volumeSizeLimitMB to 1000 before upgrading on
every SeaweedFS instance that has no valid value and whose master is an
object, with the step in
docs/operations/seaweedfs-lowering-the-volume-size-limit.md. On the
variants that run platform migrations, migration 57 does this. If the
step is skipped, every such instance drops to 400 on the upgrade: each
of its volumes at or above 400 MB leaves the master's writable set, and
a bucket whose volumes have all crossed that line takes no writes until
it can grow.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-17 00:54:48 +03:00
Andrei Kvapil 4953b512a5 chore(platform): freeze HTTPCache on the last artifact that ships it
The earlier shape of this migration orphaned every instance into plain
objects, which left its owner without a way to manage or remove it. A
cluster that still runs HTTPCache instead keeps the application, served
from a copy of the packages OCIRepository pinned to the digest of the
last release that contains it. The copy carries no owner references and
no Helm metadata, and the PackageSource is repointed at it, annotated
keep and disowned, so neither Helm nor the operator prunes it. Helm
reads that annotation off the live object during the upgrade prune,
which is what makes disowning in a pre-upgrade hook work at all.

Names are deliberately unchanged: the ApplicationDefinition inside that
artifact hardcodes its chartRef, so renaming the PackageSource would
leave every release pointing at an artifact nobody produces.

A cluster with no instance keeps nothing. The naas bundle installs the
rd component regardless of use, so freezing unconditionally would strand
a pinned source on clusters that never touched the application.

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-09-16 11:00:54 +02:00
Andrei Kvapil c0f9f08de7 chore(platform): detach HTTPCache instances before the package is pruned
Pruning the http-cache PackageSource takes its chart artifacts with it,
so without a migration every http-cache HelmRelease loses its chart and
stays Ready=False for good, with HelmReleaseNotReady firing on every
cluster that runs the naas bundle. Deleting the leftover Package by hand
is worse: it takes the ApplicationDefinition with it while the pods of a
surviving instance are still running.

Migration 57 orphans each tenant release instead, the way migration 55
does, so the workloads stay in the tenant namespace as plain objects,
and then removes the platform side. The Package goes before
hr/http-cache-rd because the operator re-creates a release it owns for
as long as that Package exists.

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-09-16 00:34:51 +02:00
Aleksei Sviridkin 5fe595d4c0 test(e2e): hold the tenant-cluster install wait to an ordering rule
A test that creates a tenant Kubernetes cluster cannot END while that
release is still installing. A Helm release cannot be uninstalled
mid-install, and for this kind the install can outlive the fast
assertions: the kind carries helm-install-disable-wait, which drops
the wait on the main resources but not on the chart's blocking
post-install hook. A test that asserts and ends hands the remainder of
the install to teardown, where it is reported red on a step that tests
nothing.

The rule is about ending, not about touching the cluster at all. The
hook polls for the admin kubeconfig in the modes that have one, and
that Secret is what the test needs to reach the tenant API, so
bringing up a worker or waiting on a child release happens while the
parent is still installing. Reading mid-install is fine.

run-kubernetes.sh already does the right thing: it waits for the
parent release to reach Ready before the tenant-cluster assertions.
Nothing held it there. This adds the guard that does, and states the
rule where the e2e conventions live.

The guard reads the script rather than Chainsaw fixtures, because no
fixture applies this kind and a guard walking apply.file would pass by
having nothing to check. A case asserts that this stays the only
script creating it, and the sweep behind it keys on the apiVersion and
kind together so that an indented apply.resource block counts as a
creator too. It reads scripts and manifests only, and that rule is
pre-emptive rather than a response to what the root holds today: a
manifest quoted in prose or parked in a `.disabled` file creates no
cluster, and this repository writes the first shape in docs that lie
outside the root.

The rules it holds the script to -- what counts as a wait with teeth,
where in the body it has to stand, what may not switch errexit off
above it, and which call shapes count as an end-assertion -- are in
the file's header beside the code that enforces them, each with the
spellings it accepts and what it cannot see. How to size the budget is
derived once in the e2e conventions.

Nothing is added at the wait itself: hack/e2e-chainsaw/_lib is shared
by every Chainsaw suite, so a comment there escalates this change from
no e2e suites to the full suite, and the guard already reds on the
move it would have warned about.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-11 09:06:19 +03:00
IvanHunters 3d1a83f419 fix(migrations): make the unadopted-pools record publish best-effort
The durable-record ConfigMap write sat under set -euo pipefail right before the
version stamp, so a permanent failure (e.g. the record exceeding etcd's object
size limit at a very large skip count) would abort the migration and deadlock
the fleet upgrade with no path out -- even though every skipped pool was already
pinned prune-proof earlier in the sweep, so the workers are safe regardless of
whether the record lands. The record is a convenience artifact, not a safety
mechanism, so swallow its failure with a warning instead of failing closed.
Covered by a bats case that fails the publish and asserts the run still stamps.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-25 12:38:28 +03:00
IvanHunters 952901776a fix(migrations): adopt unlabelled parent kubernetes releases and record unadopted pools in migration 56
The worker-adoption sweep selected parents by the apps.cozystack.io/
application.kind label, which postdates v1.2. A parent kubernetes HelmRelease
older than that, or one restored by hand, lacks the label: the sweep skipped
it, stamped, and the parent's control-plane-only re-render then pruned its
un-pinned workers with no pin and no warning. Discover such releases by their
chartRef instead and restamp the three application labels before the sweep
(additive, cannot corrupt a release, and repairs it for cozystack-api too).

Every warn+pin+skip branch leaves a pool pinned prune-proof but unadopted,
recorded only in the Job log, which the next upgrade reaps; the migration does
not re-run, so an operator cannot find those pools afterward. Accumulate the
skips and publish them as configmap/cozystack-migration-56-unadopted before
the stamp.

Extend the fake kubectl and add bats coverage for the restamp (positive and
negative) and the unadopted-ConfigMap paths.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-25 12:28:46 +03:00
IvanHunters 05c967bcaa test(migrations): move the worker-pool adoption bats to migration 56
The main merge renumbered the Phase 2 worker-pool adoption migration from 54
to 56 (behind main's redis-adopt 54 and fluxcd-orphan 55), but its bats and
fakebin did not move: hack/migration-54-adopt-{path,values}.bats still pinned
MIG at migrations/54, which is now the redis-operator adoption, so both went
red on "RedisFailover CRD not present; nothing to adopt".

Rename the two bats and hack/testdata/migration-54 to 56, repoint MIG/FAKEBIN
at migration 56, and update the version references (stamp 55 -> 57). The bare
migration-54 testdata name also collided with the migration-54-redis and
migration-55-fluxcd siblings; 56 disambiguates it.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-20 14:31:44 +03:00
IvanHunters f035da8a0f Merge branch 'main' into feat/kubernetes-nodes-phase2b
Resolve conflicts introduced by main:
- Drop the FluxCD tenant addon (API type, deepcopy, values schema, RD) in
  favour of the GPU type main added; main removed the fluxcd addon wholesale.
- Renumber the Phase 2 worker-pool adoption migration 54 -> 56, behind main's
  redis-operator (54) and fluxcd-removal (55) migrations; targetVersion 55 -> 57.
- Accept the branch's removal of the in-chart talos-reconcile Job; it moved to
  the kubernetes-nodes chart, main only touched its comment.
- Regenerate kubernetes values.schema.json / README and the kubernetes-rd
  ApplicationDefinition schema.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-20 14:04:32 +03:00
Andrei Kvapil 851a86dcf1 feat(platform): orphan tenant FluxCD HelmReleases on upgrade
Add migration 54 for the removed addons.fluxcd feature. For every tenant
that had it enabled, the FluxCD HelmReleases in the management cluster
are suspended, stripped of their Flux finalizer and deleted, so the
delete does not trigger a Helm uninstall inside the tenant cluster. The
Flux release already running in the tenant keeps running untouched.

Runs as a pre-upgrade hook, before the addon-less Kubernetes chart
reaches the tenant HelmReleases. Mirrors the orphan idiom of migration
29. Bumps migrations.targetVersion to 55.

Fail closed. Dropping the finalizer is the only thing that keeps the
delete inert, and migrations never re-run, so the script runs under
`set -euo pipefail` with no `|| true` on the critical path and gates the
delete on the finalizer actually being gone. The verify read uses
`--ignore-not-found` rather than `2>/dev/null || true`, so a failed read
aborts instead of reading as an empty finalizer list that passes the
gate on an object whose state was never checked. A migration that stops
is recoverable; one that half-orphans a fleet is not.

Cover it with a docker + fake-kubectl unit test running the real script
by path inside the migrations image's own alpine base (busybox ash, jq
baked on), so it exercises the shipped interpreter and load-bearing
pipefail. The suite pins ordering and selection (only the addon
HelmReleases are touched, each suspend -> finalizer-drop -> delete in
order) and the fail-closed paths (a surviving finalizer, a failing
verify read, and a failing fleet scan each abort before deleting and
before stamping).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
2026-08-20 13:37:09 +03:00
Andrei Kvapil 3197c48bc5 test(migrations): cover migration 54 redis adopt (label+pin, CRD probe fail-closed)
Drive migration 54 end-to-end against a fake kubectl in a jq-enabled build of
the migrations image's alpine base (busybox ash, the production interpreter),
mirroring the docker+fake-kubectl migration-test harness.

Pinned properties:
  - an absent CRD stamps 55 without patching anything;
  - a failing CRD probe aborts under set -euo pipefail before stamping (a
    one-off apiserver blip must not read as "absent" and skip adoption);
  - every RedisFailover across namespaces is labelled onto the freshworks
    operator-group, while the engine is pinned to redis:6.2.6-alpine only where
    the image is empty (both roles for Harbor; sentinel only where redis.image
    is preset, which is left untouched; label-only when both are preset);
  - a failed adoption patch aborts before stamping so the Job retries.

Auto-discovered by the Makefile hack/*.bats wildcard; no registration needed.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
2026-08-19 10:15:59 +02:00
IvanHunters c010745e8e Merge branch 'main' into feat/kubernetes-nodes-phase2b
Bring the worker-pool split branch up to date with main (111 commits),
picking up the node-join and CI fixes it was missing: the 18m tenant
node-join deadline, guest-fsync-off on ephemeral CI disks (#3455),
kubevirt-csi client rate-limit raise (#3428), the Cilium ingress-IP
race guard (#3430), and etcd-operator v0.5.4.

Conflict resolution: main's #3535 (render the talos-reconcile Job for
the default md0 group) modified packages/apps/kubernetes/templates/talos/
talos-reconcile-job.yaml, which this branch deletes because the worker
split moves that Job into the kubernetes-nodes chart. Kept the parent
template deleted and dropped the parent-scoped test
talos_reconcile_nodegroups_test.yaml: the empty-nodeGroups md0-default
gap it guards cannot occur in the per-pool child chart, and migration 54
materialises the implicit md0 into an explicit child HelmRelease.

targetVersion resolves to 55 (migration 54 on top of main's 53).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-08-04 21:52:36 +03:00
Timofei Larkin f0b99da43a test(kubernetes): make kubectl_wait_retry set -e safe
The chainsaw scripts source run-kubernetes.sh under `set -eu`, where the
wrapper's bare `_out=$(kubectl wait ... 2>&1)` on a failing wait aborts
the whole sourcing script at the assignment -- before `_rc=$?`, before
the retry loop, before anything prints, with the error text captured
into the substitution and lost. Every `kubectl wait` failure therefore
killed the suite instantly and silently, and the transient-retry
machinery the wrapper exists for (etcd leader flap et al.) has never
been reachable. This is how the kubernetes-latest suite died in CI with
an empty stderr: the wait it was executing failed, and the script
simply stopped.

Guard the assignment (`_rc=0; _out=$(...) || _rc=$?`) so the wrapper's
own logic decides: allowlisted transient errors retry with a visible
notice, everything else prints and propagates, exactly the documented
contract.

New unit suite executes the wrapper in a `sh -eu` subshell against a
fake kubectl, the same way chainsaw runs it: a transient etcd error
must be retried with the script surviving (against the previous lib
this exits 1 after one call with empty output), and NotFound must not
be retried, must fail, and must be printed.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-31 16:11:08 +03:00
Myasnikov Daniil 1eded27900 test(release): scope the trim pin to the parse step, fix two stale references
Three review nits, all verified against the tree, plus a tighter scope than the
first attempt at them.

The behaviour suite asserted "promote-rc does not trim the rc tag" against the
whole file. promote-rc.yaml has no .trim() today, so it passed, but any unrelated
.trim() added anywhere in the workflow would have failed a test about tag policy.
Scoping it to the parse JOB was not enough either: that job is ~180 lines, most
of them the e2e gate's github-script, whose string handling has nothing to do
with tag policy. Both greps are now scoped to the parse STEP, and run through a
code_lines filter copied from the contract suite so a YAML comment mentioning
.trim() cannot fail them.

Mutation-checked in both directions: a .trim() injected into the parse step fails
the test, one injected into the e2e-gate step of the same job does not.

- The Step 2 checklist entry still said "must be main" while Step 2 itself now
  lists three valid starting points (main, detached at the release tag, detached
  at the rc tag being promoted). The checklist is what an agent ticks off, so it
  was the half that mattered; it now points at Step 2 rather than re-encoding the
  count, so a fourth configuration cannot desync it. The heading said "Checking
  current branch", which two of the three states do not have.
- hack/testdata/needs-semantics.yaml named release-changelog-contract.bats as its
  consumer; the only consumer is release-changelog-behaviour.bats.

50/50 tests green across the three suites (11 + 24 + 15).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-27 14:03:49 +05:00
Timofei Larkin 730d006ce8 fix(platform): skip pinning names no object can have
The pin path composed "kubernetes-<cluster>-<group>" and read it back,
but a group key that cannot form a valid object name (a slash, a space,
an underscore...) provably never had objects applied under it -- the
old chart's apply would have been rejected by the apiserver. Worse,
kubectl rejects a name containing '/' client-side, deterministically,
so pin_keep's fail-closed read classified it as a real failure and the
migration exited 1 on every retry: a permanent fleet-wide pre-upgrade
deadlock over a pool that cannot exist.

Lean on the invariant instead of the read: pin_pool_objects statically
checks the composed name against the DNS-1123 subdomain shape and skips
with a note when it cannot name an object -- nothing to pin, no kubectl
call to misread. Keys that are label-invalid but still valid object
names (e.g. dotted) may have live objects and keep today's pin
behavior; the pin-path transient-error test now uses a dotted key so it
still exercises a genuine read.

The fake kubectl reproduces real kubectl's client-side slash rejection,
and a new bats case pins the skip: a "pool/a" key must warn, pin
nothing, apply nothing, and still reach the version stamp. Red against
the previous script, which deadlocked at the first pin read.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-25 02:40:10 +03:00
Timofei Larkin a54d5ca4a5 fix(kubernetes-nodes): unpin absence via --ignore-not-found
Same two holes as the migration's read classification, in the unpin
hook. Capturing stderr into $pol (2>&1) contaminated the success path:
kubectl warns on successful reads, so an unannotated object read as
"pinned" and drew a spurious annotate call -- one per named object.
And the *"not found"* pattern classified a transient conversion-webhook
failure as absence, letting the Job report success while the
keep-annotated objects leaked past the uninstall.

--ignore-not-found makes the whole classification unnecessary here:
absence folds into the same empty-output no-op branch as "present but
unannotated", which is exactly the semantics unpin() wants, and any
non-zero exit is a real failure that set -e turns into a failed Job.
The case/esac disappears; stderr stays on the Job log where it is
visible instead of parsed. This also avoids tempfile juggling under the
container's readOnlyRootFilesystem.

The bats suite gains a previously missing failure-path case and
regression tests for both holes, red against the previous commit: a
failed KMT listing must fail the Job (FAKE_LIST_FAIL), a read failure
whose message contains "not found" must fail the Job, and a
success-path stderr warning must not fake a pinned state
(FAKE_WARN_STDERR). Absence is now modeled the way real kubectl
behaves under --ignore-not-found: success with empty output.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-25 02:07:43 +03:00
Timofei Larkin bdadb3ff04 fix(platform): detect absence via --ignore-not-found
The stderr-based classification this branch introduced had two holes of
its own. Capturing stderr into the data variable (2>&1) contaminates
the SUCCESS path: kubectl prints warnings on successful reads (API
deprecation headers -- deterministic for a deprecated GVK, and CAPI
v1beta1 is one -- plus client-side throttling notices), so the warning
glued onto the owner annotation would trip adopt_one's foreign-owner
refusal on every tenant and permanently deadlock the pre-upgrade hook.
And the *"not found"* pattern over-matches: a transient
conversion-webhook outage fails a read with '... service
"capi-webhook-service" not found', which the classifier would treat as
absence -- skipping the keep pin and re-opening the exact fail-open
prune hazard this branch exists to close.

Detect absence with --ignore-not-found instead: a missing object is
SUCCESS with empty output, so a non-zero exit can only be a real
failure and aborts before the version stamp, with no stderr parsing at
all. adopt_one reads -o json and extracts the owner annotation with jq
(already a dependency), keeping absence distinct from present-but-
unannotated; pin_keep reads -o name. list_kmts still needs stderr for
the one legitimate classification -- the two deterministic missing-CRD
messages -- so it captures stderr to a temp file, never into data, and
no longer accepts NotFound-shaped text for a list.

The fake kubectl honors --ignore-not-found the way real kubectl does,
gains FAKE_WARN_STDERR (success-path stderr warnings) and
FAKE_GET_FAIL_MSG (transient error with arbitrary text). Two new bats
tests, each red against the previous commit: a deprecation warning must
not break adoption or fake a foreign owner, and a failure whose message
contains "not found" must still abort without stamping.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-25 02:07:28 +03:00
Timofei Larkin 1ca947e2fb fix(kubernetes-nodes): fail unpin reads closed
The pre-delete unpin hook's existence check swallowed every kubectl get
failure (`... >/dev/null 2>&1 || return 0`), four lines below the
comment declaring "Failures are NOT swallowed". kubectl exits 1 for
NotFound and for a transient apiserver error alike, so a timeout during
uninstall read as "object absent": the unpin was skipped, the Job
succeeded, and Helm then skipped the still-keep-annotated
MachineDeployment -- leaking the adopted pool's worker VMs into the
tenant namespace, running and unmanaged, after the pool was deleted.
The KMT-listing path was already hardened for exactly this class; the
existence check was the remaining instance.

Classify by stderr: NotFound (native pool, or already gone) stays a
no-op; any other failure fails the Job, bounded by backoffLimit and
activeDeadlineSeconds, with logs retained. The existence check and the
annotation read fold into a single request.

The embedded script's behavior cannot be asserted from the manifest, so
a new bats suite renders the chart, extracts the Job's script, and
executes it against a fake kubectl: NotFound no-ops and succeeds, a
transient named-get error fails the Job (red against the previous
script, which printed "unpin done" and exited 0), keep-annotated
objects are unpinned with the 6-hex hash anchor respected, and an
unpinned existing object is left untouched.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-25 01:45:44 +03:00
Timofei Larkin f8258c8859 fix(platform): fail migration 54 reads closed
kubectl exits 1 for NotFound and for transient server failures (timeout,
429, etcd hiccup) alike, and migration 54 treated every non-zero read as
"object absent". A transient error while reading a worker object would
skip the keep pin and the ownership rewrite, let the migration complete
and stamp version 55 (so it never re-runs), and the parent's
control-plane-only upgrade would then prune the un-pinned live
MachineDeployment -- deleting every running worker VM in the pool. The
doubly-masked KubevirtMachineTemplate listings (2>/dev/null plus
`|| true`) had the same shape: a failed list adopted or pinned zero
KMTs, permanently stranding a preserved older-revision KMT still
referenced by an in-flight MachineSet, since the child chart only
re-emits KMTs already annotated with its own release name. The
`cur=$(... || true)` owner read additionally bypassed the foreign-owner
refusal guard.

Classify reads by stderr, the migration-47 precedent: NotFound means
genuinely absent and is skipped; anything else exits 1 so
run-migrations.sh leaves CURRENT_VERSION at 54 and retries on the next
upgrade. adopt_one and pin_keep fold the existence check into the
annotation read (one request instead of two); KMT listings go through a
capture-first list_kmts helper because `for x in $(...)` discards the
substitution's exit status even under set -e, leaving `|| true` to
cover only the empty-grep-match case, mirroring the unpin hook. The
child-HR existence check stays deliberately tolerant: its fallback is
an idempotent apply that itself fails closed. adopt_one now also stamps
meta.helm.sh/release-namespace so an object whose Helm annotations were
stripped out-of-band cannot fail the child's ownership validation.

The fake kubectl now reports absent objects the way the real apiserver
does (NotFound on stderr, exit 1) and gains FAKE_GET_FAIL to inject
transient errors; four new bats tests pin the classification: NotFound
still completes and stamps, while a transient error on the adopt path,
the KMT listing, or the pin path aborts before the version stamp. Each
red against the previous script.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-07-25 01:45:32 +03:00
Myasnikov Daniil dd308644c8 ci(release): execute the release-changelog logic in tests, not just grep it
The existing suite asserts on workflow YAML. That catches a rename or a
deleted `if:` and nothing else: it cannot tell whether the shell inside a
`run:` block does what the step name claims, and it stayed green while the
logic underneath was wrong — twice, in the two preceding rounds.

Add hack/release-changelog-behaviour.bats, which runs things for real.

The GitHub Actions semantics the whole non-blocking design rests on are
now verified by executing them. hack/testdata/needs-semantics.yaml mirrors
the promote / changelog / open-pr shapes with a fatal (non-AI) step in the
changelog job, and `act` runs it. Result: open-pr DOES run when a job it
needs has failed, `needs.changelog.result` is `failure`, and — the part
that could not be read off the YAML — a failed job's outputs come back
EMPTY, while a successful job's propagate. That empty-output behaviour is
precisely why open-pr re-establishes changelog presence from disk instead
of trusting needs.changelog.outputs. Deleting the `if:` from the fixture
makes open-pr get skipped, so the gate is demonstrably load-bearing.

The git logic moves into hack/changelog-preserve.sh and is exercised
against real bare remotes: a branch that exists, one that does not, one
with no changelog, one with an empty changelog, and the full
preserve/checkout -B/restore/force-push cycle promote actually performs.
The last of these directly reproduces the regression the preserve step
exists to prevent. An empty changelog is deliberately NOT preserved — it
would suppress generation and then publish as blank notes.

finalize's release-body selection runs under node across all three inputs
(good, whitespace-only, absent) and the resulting decision table is
compared as a whole.

Replace the shell overlay with a sparse second actions/checkout of the
dispatch ref at .release-tooling. The overlay had a bootstrap problem I
introduced: the script that fetches missing tooling was itself missing
from the rc tree, so it could never have worked for exactly the trees that
need it. actions/checkout comes from the runner, not the repo, so it has
no such circularity. .release-tooling is gitignored because promote runs
`git add -A`, and the contract suite pins that.

All 21 tests mutation-checked. Breaking changelog-preserve.sh, making it
return an empty file, removing the fixture's `if:`, dropping the tooling
checkout, and renaming its path each fail the suite and pass on restore.

Not covered, and stated in the suite header rather than implied: the AI
generation step, the real GitHub API calls, and whether the sparse
checkout resolves on GitHub's runners.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-24 11:34:17 +05:00
IvanHunters c8ba404646 fix(platform): guard migration 54 against RFC-1123-invalid pool names
The 53-char overflow branch exists because exiting the migration deadlocks the
platform pre-upgrade hook for every tenant. The equivalent deadlock via name
validity was unguarded: the old nodeGroups map key was never schema-constrained
(map[string]NodeGroup, no propertyNames pattern), so a stored parent HR can
carry a key like 'My_Pool' whose own render already failed at apply time. The
migration then built child_name=kubernetes-nodes-<cluster>-My_Pool, kubectl
apply rejected the RFC-1123-invalid HelmRelease name, the script exited 1 under
set -e, and every subsequent platform upgrade re-ran and re-failed.

Validate child_name against the RFC-1123 label pattern alongside the length
check and route failures through the same warn + pin + skip path. Factor the
pin-and-skip logic shared by both guards into pin_pool_objects. Cover the new
branch in migration-54-adopt-path.bats (the fake kubectl apply now rejects an
invalid metadata.name, so the case fails without the guard).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-22 18:34:28 +03:00
IvanHunters 8f24cfb56c fix(platform): point migration fixtures and docs at renumbered migration 54
The Phase-2 worker-pool adoption migration was renumbered 52 -> 54 during
the rebase onto main (main added migrations 52 linstor-scheduler and 53
seaweedfs). Rename the adoption bats tests and their testdata directory,
and update the kubernetes-nodes pre-delete hook comment and the
kubernetes README so they reference migration 54. The bats MIG path
pointed at migrations/52 (now the linstor migration), so without this the
adoption unit tests would exercise the wrong migration.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-21 23:01:00 +03:00
Ivan Okhotnikov 386053ba8f fix(platform): harden migration 52 (name overflow, images, adoption tests)
Address review on #3315:
- Name-length overflow no longer exits 1 (which would deadlock the platform
  pre-upgrade hook for every tenant). Instead pin the pool's worker objects
  with resource-policy=keep, skip the child HR, warn loudly, and continue.
- Copy only images.kubectl into the child values, not the parent's whole
  images map (waitForKubeconfig/talosCsrSigner are undeclared in the schema).
- Note that the pre-upgrade window is not quiet (a parent HR edit mid-upgrade
  renders the old chart and self-heals once the new artifact lands).
- Cover the adoption path directly: annotate keep + release-name, idempotent
  skip, unexpected-owner refusal, and the 6-hex KMT anchor rejecting a sibling
  pool's template.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-21 22:57:22 +03:00
Ivan Okhotnikov c11e3d6682 test(platform): guard migration 52 copies version/talos/images into child HR
Drives migration 52 against a fake kubectl and asserts the adopted
child HelmRelease carries the tenant's non-default talos.version,
version and images (plus group fields and storageClass fallback). Fails
against the pick(o;...) filter-parameter bug that dropped those keys and
rolled live worker VMs; passes against the pick($o;...) fix.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-21 22:57:06 +03:00
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