When a tenant Kubernetes cluster hits the 12m node-Ready deadline with
fewer than two nodes joined, the failure path dumped only `describe
nodes` and `get hr`. That cannot tell apart the two node-join failure
sub-modes: a worker VM that never booted (virt-launcher Pending or
OOMKilled) versus a VM that booted fine but whose kubelet never
registered a Node. The tenant cilium-operator HelmRelease reports
"InProgress" identically in both, so the current dump is not enough to
diagnose the failure or design a fix.
Dump scoped, guarded diagnostics before failing fast:
- worker VM/VMI/virt-launcher state on the management cluster, which is
what actually splits the two sub-modes;
- tenant kubelet CSRs plus the talos-csr-signer sidecar log, which reveal
a boot-but-no-Node stall.
In-guest Talos/kubelet capture is deliberately left out: talosctl needs a
tenant-cluster talosconfig the runner does not have (the tenant workers
use a separate Talos CA from the management sandbox), so the gap is
documented in the failure path rather than shipped as a call that would
fail mTLS and capture nothing.
Every capture is guarded with `|| true` so a capture failure never masks
the real exit.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The inter-test cleanup hook deletes the tenant Kubernetes CR and waits
only for the CR to disappear. That wait finalizes as soon as the CR's
finalizers clear, which merely TRIGGERS teardown of the CAPK worker VMs
and their DataVolume-backed disk PVCs -- it does not await it. The
virt-launcher pods keep their guest RAM reserved until the VMIs are
gone, so the next back-to-back tenant test's worker VMs begin scheduling
against a sandbox the previous tenant has not yet vacated. Under the
constrained CI sandbox this starves memory, a worker VM misses the
node-join budget, and the run flakes on worker-node-join (the
MachineHealthCheck cannot remediate when both workers are unhealthy).
Block cozy_cleanup until the tenant VMs, VMIs and disk PVCs have
actually drained from tenant-test before it returns, so the next test
starts on a freed sandbox. The wait is bounded (5m) and best-effort:
cozytest runs cozy_cleanup wrapped in `|| true` and the waiter returns
loudly on timeout, so a stuck teardown can never hang the job longer
than the deadline. The probe is namespace-scoped -- tenant-test is
provisioned with etcd/monitoring/seaweedfs disabled so it carries no
baseline PVCs, and e2e apps run sequentially each cleaning up after
themselves, so the only VMs/VMIs/PVCs present belong to the tenant being
torn down.
The pure drain exit-condition (cozy_tenant_drained) is unit-tested in
hack/run-kubernetes-drain_test.bats, including the invariant that a
failed probe's "err" sentinel is never misread as a completed drain.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
## What this PR does
Every `*.bats` file in the repo runs through `hack/cozytest.sh`, not
real bats — the e2e files via `packages/core/testing/Makefile` and the
`hack/*.bats` unit tests via `make bats-unit-tests`. cozytest runs each
`@test` under `set -e`, and POSIX errexit is suppressed for a pipeline
beginning with `!`. So a bare `! cmd` must-not-happen assertion never
fails the test: when the forbidden condition actually occurs (the
command succeeds), the negated pipeline returns non-zero but errexit
ignores it and the test passes anyway. Ten such assertions across six
files were silently vacuous.
This converts each to `if cmd; then echo "<reason>"; false; fi`. The
`if` condition is exempt from errexit (so the probe itself never aborts
the test), and the `false` in the taken branch is a plain command under
`set -e`, so it aborts and fails the test with a legible message when
the forbidden condition holds. This is the must-not-happen inverse of
the `cmd || { echo …; false; }` pattern already used in the suite (e.g.
harbor.bats, per docs/agents/e2e-testing.md). Original redirections and
quoting are preserved, so happy-path behavior is unchanged — only the
failure path now actually fails.
Converted assertions:
- `bucket`: a read-only user must not be able to upload (access-control
regression guard)
- `gateway`: a child tenant must not have its own gateway HelmRelease
(inheritance)
- `kuberture`: external-dns public/internal hostname isolation
- `e2e-install-cozystack`: tenant-name validation must reject rather
than warn; the version ConfigMap must be gone after delete
- `build-matrix` (unit test): talos/installer must be excluded from the
parallel matrix
- `capture-dataplane` (unit test): kube-dns and pending rows must be
filtered out
A separate in-flight etcd test change owns two more such assertions in
`etcd.bats`; they are left out of this PR to avoid a conflicting edit.
### Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Tests**
* Strengthened several end-to-end/Bats assertions to fail more
explicitly when expected cluster conditions aren’t met.
* Improved matrix validation to reliably confirm excluded components are
absent.
* Updated dataplane load balancer filtering, bucket readonly upload, and
gateway inheritance checks to avoid false/ambiguous pass cases.
* Tightened DNS hostname isolation probes and cozystack installer
validations (tenant name dashes, deletion protection) with clearer
“expected vs found” failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
These bats files are all run by hack/cozytest.sh, which executes each
@test under `set -e`. POSIX errexit is suppressed for a pipeline that
begins with `!`, so a bare `! cmd` must-not-happen assertion never fails
the test: when the forbidden condition actually occurs (cmd succeeds) the
negated pipeline returns non-zero but errexit ignores it and the test
passes anyway. These assertions were silently vacuous.
Replace each with `if cmd; then echo "<reason>"; false; fi`, the idiom
already used and documented elsewhere in these files — the if-condition
is exempt from errexit and the `false` in the taken branch fails the test
loudly. The forbidden condition now fails the test as intended:
- bucket: read-only user must not be able to upload (access control)
- gateway: child tenant must not have its own gateway HelmRelease
- kuberture: external-dns public/internal hostname isolation
- e2e-install: tenant-name validation must reject (not warn); the version
ConfigMap must be gone after delete
- build-matrix: talos/installer excluded from the parallel matrix
- capture-dataplane: kube-dns and pending rows are filtered out
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
cozytest.sh — the runner for every bats file here — is not real bats: it
never invokes setup()/teardown(). So these hooks were dead. Cleanup ran
only via each test's success-path inline delete, and a failing test
leaked its resources into the shared tenant-test namespace (and, for
kuberture, cluster-scoped RBAC that persists across sandbox re-runs).
Wire the cleanup to cozy_cleanup(), the hook cozytest does run at suite
exit and on the first failing test:
- external-dns: dead teardown() -> cozy_cleanup() deleting all ExternalDNS
CRs. ExternalDNS is a stateless Deployment with the inmemory provider,
so a plain delete fully reclaims it — no drain needed.
- kuberture: dead teardown() -> cozy_cleanup() routed through the existing
_cleanup(), so the probe Pods, SA, and cluster-scoped ClusterRole and
ClusterRoleBinding are reclaimed on the failure path too.
build-matrix_test: drop the dead setup() entirely — it only cd'd to the
repo root, which cozytest already runs from, so the relative
hack/build-matrix.sh calls resolve without it.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
## What this PR does
After deleting a `Qdrant` app CR, the StatefulSet data PVC
(`qdrant-storage-<release>-0`) was left orphaned in the tenant
namespace,
consuming storage permanently. StatefulSet-templated PVCs are never
garbage-collected on StatefulSet deletion by default.
The vendored upstream Qdrant chart does **not** support
`persistentVolumeClaimRetentionPolicy` (verified: its `statefulset.yaml`
never
renders that field), so the OpenBAO-style value passthrough is not
possible
here. Instead this adds a `post-delete` PVC cleanup hook (modeled on the
MariaDB
hook) that deletes the release's PVCs by label.
The chart's volumeClaimTemplate only stamps `app: qdrant` (shared by
every
Qdrant in a namespace), so to select PVCs safely per release this also
passes
`persistence.additionalLabels.app.kubernetes.io/instance: <release>`
through the
HelmRelease — giving the data PVC a release-unique label the hook
selects on.
The cleanup Job is hardened: runs as non-root (uid 65534), read-only
root fs,
all capabilities dropped, `seccompProfile: RuntimeDefault`; RBAC scoped
to
`persistentvolumeclaims`. A real cleanup failure is logged (not silently
swallowed) but never blocks namespace teardown.
> [!WARNING]
> **Deleting a Qdrant app now permanently removes its data PVC and the
vector data it holds.**
> This is intentional and fixes the storage leak; the hook only fires on
full
> release deletion (not on scale-down). Back up before deleting.
Fixes#3059.
### Release note
```release-note
fix(qdrant): garbage-collect the StatefulSet data PVC when a Qdrant app is deleted, instead of leaving it orphaned. WARNING: deleting a Qdrant app now permanently removes its data PVC and the data it holds.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added a post-delete cleanup hook for Qdrant to remove leftover
StatefulSet data PVCs after deletion.
* Included hardened Kubernetes settings for the cleanup job to improve
runtime safety.
* **Tests**
* Added Helm chart tests that validate the post-delete cleanup job and
its RBAC permissions.
* Enhanced end-to-end verification to confirm the data PVC is fully
removed after deleting a Qdrant instance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this PR does
Introduces a tenant-facing `SecurityGroup` resource in a new
`sdn.cozystack.io/v1alpha1` API group, served by the Cozystack
aggregated API server as a 1:1 projection of a `CiliumNetworkPolicy`. A
SecurityGroup attaches to one of the tenant's managed applications by
reference (`spec.targetRef: {apiGroup, kind, name}`) and declares
allow-list ingress/egress for that application. Tenants manage network
policy for their own apps entirely through
`securitygroups.sdn.cozystack.io`, without being granted any access to
the `cilium.io` API group.
The translation is synchronous and stateless — the API server converts
each SecurityGroup into a CiliumNetworkPolicy in the same namespace on
write and back on read; there is no controller. The backing policy's
`endpointSelector` is **derived** from `targetRef` (matched on the
application's lineage labels
`apps.cozystack.io/application.{group,kind,name}`), never copied from
tenant input, so the selector is machine-generated and can only ever
match the referenced application's own pods in the tenant's namespace.
Policies owned by this API carry an `sdn.cozystack.io/securitygroup`
marker label that the storage always re-asserts and hides from the view,
so it only ever surfaces or mutates its own policies and never touches
platform-managed network policies.
The value is self-service network policy on a Cozystack-native,
RBAC-scoped surface, without exposing Cilium's version-coupled CRDs to
tenants. Because the selector is derived from an authorized application
reference rather than free-form tenant input, the RBAC boundary is
structural, not selector-validated: a tenant cannot express network
policy against arbitrary or platform-owned pods. The full design —
selector derivation from lineage labels, the structural boundary, the
additive-policy reality (a SecurityGroup widens a managed application's
allowed traffic but cannot sever its platform-managed management plane,
and equally cannot tighten below the platform baseline), the in-tree
CiliumNetworkPolicy mirror that avoids a Cilium Go-module dependency,
RBAC, and deferred extensions (raw-pod targeting, cluster scope,
reusable groups) — is in `pkg/apis/sdn/DESIGN.md`.
Implements the attachment model from #1614.
### Release note
```release-note
feat(api): add the SecurityGroup resource (sdn.cozystack.io/v1alpha1) — a tenant-facing, namespace-scoped firewall that attaches to a managed application by reference and projects 1:1 onto a CiliumNetworkPolicy with a machine-derived endpoint selector, letting tenants manage their application's network policy without cilium.io access.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added the `SecurityGroup` API resource (`sdn.cozystack.io` v1alpha1)
with a namespaced projection backed by Cilium network policies.
* Enabled the SDN `APIService` and wired SDN routing, scheme, and REST
support.
* Expanded RBAC to include SecurityGroup access and required Cilium
policy permissions.
* **Documentation**
* Added a design document describing projection behavior and rule
semantics.
* **Bug Fixes**
* Improved API rule validation coverage for missing list-type fields.
* **Tests**
* Added SDN round-trip, fuzzing, RBAC, and comprehensive REST
create/get/list/update/delete/watch/resource-version tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The post-delete hook selected PVCs with an unanchored name prefix
(qdrant-storage-<release>-). Because release names are qdrant-<app>, deleting
release "qdrant-test" matched not only its own PVC
(qdrant-storage-qdrant-test-0) but also a sibling "qdrant-test-2"'s PVC
(qdrant-storage-qdrant-test-2-0) in the same namespace — irreversibly deleting
the sibling's data (or wedging it in Terminating via pvc-protection).
Anchor the ordinal with grep -E "...-[0-9]+$" so only the release's own
data PVCs match. A release name that is a prefix of a sibling no longer
collides.
- cleanup-pvc.yaml: grep -E with anchored "-[0-9]+$".
- tests: assert the anchored selector and add a notMatchRegex guard against
regressing to the unanchored prefix.
- e2e: gate the post-delete wait on a NotFound result specifically, so a
transient API/auth error is not misread as "PVC reclaimed".
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Алексей Артамонов <alexeyartamonov1987@gmail.com>
## What this PR does
The e2e runner `hack/cozytest.sh` is not real bats — it converts `@test`
blocks to shell functions and recognises a `cozy_cleanup()` hook, but
never calls `setup()`/`teardown()` and never opens fd 3. So
`etcd.bats`'s `setup()` never ran (etcd leaked its HelmRelease and data
PVCs into the shared `tenant-test` namespace) and `dump_diagnostics`
wrote to an unopened fd 3, silently dropping failure diagnostics. The
teardown's `kubectl wait --for=delete` lines also passed
`--ignore-not-found`, which `wait` does not accept.
This moves teardown into an `etcd_drain()` helper, run three ways: a
pre-clean at the start of the first scenario, inline at the end of the
last scenario (under `set -e`, so a teardown that does not settle fails
the suite), and as `cozy_cleanup` (the best-effort safety net cozytest
runs at suite exit and on the first failing test). The drain reclaims
what a plain CR/HR delete leaves behind:
- the StatefulSet's retained `data-etcd-*` PVCs (not owned by the Etcd
CR) — a later install otherwise reuses the same-named volumes, boots the
previous member identity, never forms a fresh quorum, and the chart's
`helm --wait` fails; and
- the cluster-scoped Kamaji `DataStore`, whose leaked finalizer wedges
the HR uninstall — cleared each pass until the Etcd CR, HR, DataStore,
and PVCs are all gone, bounded by a 4-minute deadline that fails the
drain on timeout.
Remnants are probed by name so a transient API error is never mistaken
for a successful delete. Diagnostics now go to stdout, which cozytest
captures and prints on failure.
### Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved end-to-end cleanup so leftover resources are removed more
reliably between runs.
* Made failure diagnostics easier to capture when tests fail.
* Added stronger cleanup handling at the start and end of the test flow
to reduce flaky suite behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What this PR does
Lays the foundation for migrating tenant LoadBalancer exposure off the
deprecated `Service.spec.externalIPs` (KEP-5707) and, eventually, off
MetalLB onto Cilium-native announcement — the mechanism-agnostic shape
prescribed when the earlier chart-side approach (#2468) was reverted in
#2603.
It adds a new `network.cozystack.io/v1alpha1` API group and a controller
that translates intent into whichever LoadBalancer backend the cluster
admin configured:
- `ExposureClass` (cluster-scoped, admin): binds a logical class name to
a backend (`externalIPs` | `metallb` | `cilium` | `robotlb`) and its VIP
address scope — the StorageClass/IngressClass pattern.
- `ServiceExposure` (namespaced, tenant intent): "expose this Service
via class X"; status reports the resolved backend and assigned IPs.
- A `serviceexposure` controller renders the backend's pool + announcer
once per class (MetalLB `IPAddressPool` + `L2Advertisement`; Cilium
`CiliumLoadBalancerIPPool` + `CiliumL2AnnouncementPolicy`), scoped to
the namespaces that opted in. Backends are pluggable behind a small Go
interface, so adding BGP or another cloud LB is one implementation, not
an API change. The controller never mutates the target Service — the
chart still owns its shape.
Platform wiring: `publishing.exposureClass.*` renders the admin
`ExposureClass` and the host ingress publishes through it. The default
(no class set, `externalIPs`) leaves every existing cluster's behaviour
byte-for-byte unchanged; the MetalLB→Cilium announcer migration becomes
a single `ExposureClass.spec.backend` change.
This is the foundation only — Part of #2586. Converting per-app
exposure, flipping Cilium `l2announcements` on, changing the default,
and removing MetalLB are deferred to later phases.
### Release note
```release-note
feat(network): add ExposureClass and ServiceExposure for mechanism-agnostic LoadBalancer exposure. Opt-in foundation; the default externalIPs publishing path is unchanged.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added new `ExposureClass` and `ServiceExposure` custom resources with
support for multiple backends (externalIPs, MetalLB, Cilium, RobotLB),
including optional L2 and address configuration.
* Added Helm wiring and templates to enable service/ingress publishing
via `ServiceExposure`, selecting a configured (or default)
`ExposureClass`.
* **Bug Fixes**
* Improved reconciliation, finalizer-based cleanup, and status updates
for readiness and assigned IP reporting, including safer handling when
backends or managed resources change.
* **Tests**
* Added end-to-end and unit test coverage for backend rendering,
readiness, and cleanup flows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The chart shipped a default nodeGroups.md0 in values.yaml. Helm deep-merges
values, so the default md0 was re-added on top of any user-supplied
nodeGroups; combined with Kubernetes stripping null values, md0 could never
be removed by configuration. Move the default into the template
("kubernetes.nodeGroups" helper) so it is applied only when nodeGroups is
empty. User-supplied node groups now fully replace the default, letting users
name and omit groups (including md0) freely.
Test fixtures that relied on the chart-default md0 to supply diskSize via
values merge now set it explicitly.
Signed-off-by: mattia-eleuteri <mattia@hidora.io>
The previous approach routed a release-unique label through
qdrant.persistence.additionalLabels, which the upstream chart renders into
the StatefulSet's volumeClaimTemplates[].metadata.labels. That field is
immutable on an existing StatefulSet, so for any already-deployed Qdrant the
API server would reject the upgrade and the <release>-system HelmRelease
(retries: -1) would retry-fail forever — permanently wedging reconciliation.
Label changes on a volumeClaimTemplate also never apply to PVCs that already
exist, so the cleanup hook could not reclaim those instances anyway.
Switch the post-delete hook to select PVCs by the deterministic StatefulSet
naming prefix (qdrant-storage-<release>-) instead. PVC names derive purely
from the release name, so this needs no StatefulSet change (upgrade-safe) and
also reclaims PVCs from instances created before the hook existed.
- qdrant.yaml: drop the additionalLabels passthrough entirely.
- hooks/cleanup-pvc.yaml: delete by name prefix via kubectl get -o name | grep.
- tests: assert the name-prefix command instead of the label passthrough.
- e2e: assert qdrant-storage-<name>-0 is gone after delete (async-waited).
BREAKING CHANGE: deleting a Qdrant app now permanently removes its data PVC
and the vector data it holds.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Алексей Артамонов <alexeyartamonov1987@gmail.com>
The e2e runner (hack/cozytest.sh) is not real bats: it never invokes
setup()/teardown() and never opens fd 3. So etcd.bats's setup() never ran
and dump_diagnostics wrote to an unopened fd 3, silently aborting on
failure — and etcd leaked into the shared tenant-test namespace.
Move teardown into etcd_drain() and run it inline at the end of the last
scenario (under set -e, so a teardown that does not settle fails the
suite) and as cozy_cleanup (the best-effort hook cozytest runs at suite
exit and on the first failing test). The first scenario also drains up
front so a dirty namespace cannot taint it.
etcd_drain reclaims what a plain CR/HR delete leaves behind:
- the StatefulSet's retained data-etcd-* PVCs (not owned by the Etcd CR);
a later install otherwise reuses them, boots the old member identity,
never forms a fresh quorum, and the chart's helm --wait fails; and
- the cluster-scoped Kamaji DataStore, whose leaked finalizer wedges the
HR uninstall — cleared each pass until the Etcd CR, HR, DataStore, and
PVCs are all gone, bounded by a deadline that fails the drain on
timeout. Remnants are probed by name so a transient API error is never
mistaken for a successful delete.
Drop --ignore-not-found from the teardown waits (not a valid wait flag);
the drain polls for existence instead. Diagnostics now go to stdout,
which cozytest captures and prints on failure.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Add a deterministic e2e for the externalIPs backend: it creates an
ExposureClass, a Service with pinned externalIPs, and a ServiceExposure,
then asserts the controller resolves the class, renders no MetalLB/Cilium
pool, reports the assigned IPs, and cleans up via its finalizer on delete.
The externalIPs path is chosen so the test creates no backend pool — it
cannot collide with the cluster's existing LoadBalancer address pools or
depend on real L2 reachability. The metallb and cilium rendering paths are
covered by the Go unit tests.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Pin the PR #2872 B1 fix: when the management cluster exposes only remote
LINSTOR StorageClasses whose names sort after "kubevirt" and none is named
the configured storageClass (default "replicated"), the tenant default must
be chosen among the propagated classes and never the legacy "kubevirt" alias.
helm-unittest cannot reach this branch (no live cluster -> lookup returns
empty -> the storageClasses map collapses to the "replicated" fallback), so
it is exercised in e2e via a single server-side dry-run render of the
kubernetes chart against the live management cluster: two remote LINSTOR
classes are added and "replicated" is removed for the one render, then the
rendered -csi HelmRelease storageClasses map is asserted to carry exactly one
default among the propagated classes, never the "kubevirt" alias.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
- csi_test.yaml: assert the fallback storageClasses map yields a default
"replicated" plus the non-default "kubevirt" alias, and that the
alias tracks the storageClass selector.
- kubevirt-csi-node: add a Makefile test target (otherwise the package
is silently skipped by the unit-test runner) and storageclasses_test
covering per-entry rendering, the single-default invariant, and the
absence of any enforcement allowList in driver-config.
- run-kubernetes.sh: verify in e2e that "replicated" propagates as the
default kubevirt-CSI class, the "kubevirt" alias is retained, and the
node-local "local" infra class is filtered out.
Refs: #2094
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
## What this PR does
> **Supersedes #2610.** Same work, rebased cleanly onto current `main`
so it now sits on top of #2867 (the `kube-controller-manager` VAP
type-checker fix). That fix is what was killing the base install in
#2610's E2E *before* the `kubernetes` app test ever ran, leaving the
Talos worker-bootstrap rewrite verified only by code reading. #2610's
67-commit branch is squashed here into 8 logical commits with original
authorship preserved (@kvaps and @IvanHunters as authors); the tree is
**byte-identical** to #2610's head — no code changes, just the rebase
onto `main` (to pick up #2867) plus history cleanup. The detailed review
history lives on #2610.
Phase 1 of the Kubernetes-app split design (see cozystack/community#8):
replace the Ubuntu+kubeadm worker bootstrap path with Talos, driven by
`cluster-api-bootstrap-provider-talos` (CABPT) and a
`clastix/talos-csr-signer` sidecar embedded in the Kamaji control-plane
pod. Existing tenants keep working — old machines roll out and get
replaced by Talos workers without manual intervention.
Highlights:
- Add CABPT (v0.6.12) as a second `BootstrapProvider` alongside the
existing kubeadm one in `capi-providers-bootstrap`.
- Bump the Kamaji control-plane provider with an upstream-bound patch
that exposes `KamajiControlPlane.spec.network.additionalServicePorts`,
used to publish trustd (50001/TCP) on the apiserver service.
- Generate Talos PKI (Ed25519 CA + trustd TLS) via cert-manager and
stable random Talos secrets (`token`, `clusterId`, `clusterSecret`,
`bootstrapToken`) using the helm lookup-and-reuse pattern.
- Materialise a kubeadm-format `bootstrap-token-<id>` Secret inside the
tenant `kube-system` via a Helm post-install/upgrade Job.
- Render a `TalosConfigTemplate` (worker machineconfig, `generateType:
none`) and switch the `MachineDeployment.bootstrap` reference from
`KubeadmConfigTemplate` to `TalosConfigTemplate`, gated on Talos secrets
and the Kamaji apiserver service being ready.
- Boot workers from the Talos openstack image via a CDI `DataVolume`
(`source.http.url` → `factory.talos.dev`), expose the system disk as
virtio-blk with `blockSize.custom: logical=512, physical=4096` so
4Ki-native block storage backends (LINSTOR/DRBD) play nicely with QEMU's
O_DIRECT writes and SeaBIOS still boots, drop the separate kubelet disk
(Talos lays out EPHEMERAL itself).
- Pin pod/service CIDRs in the worker machineconfig to the tenant ranges
(`10.243.0.0/16` / `10.95.0.0/16`) to avoid Talos's address-overlap
diagnostic against the host pod CIDR.
- Set `cluster.controlPlane.endpoint` and `cilium.k8sServiceHost` to the
Kamaji apiserver Service ClusterIP (looked up at render time), so
bootstrap survives the chicken-and-egg moment where tenant DNS does not
exist yet and the host CoreDNS does not serve the tenant zone.
End-to-end on a dev cluster: a fresh tenant brings up a Talos worker,
kubelet CSRs get approved (apiserver-client by Kamaji, kubelet-serving
by the talos-csr-signer sidecar), Cilium initialises with hostNetwork
against the apiserver ClusterIP, CoreDNS comes up, and the node
transitions to `Ready`.
### Release note
```release-note
feat(kubernetes): bootstrap tenant workers with Talos Linux instead of Ubuntu+kubeadm. Existing clusters roll over to Talos workers automatically; new tenants come up on Talos from the start. Powered by cluster-api-bootstrap-provider-talos and the talos-csr-signer sidecar in the Kamaji control-plane pod. Note: the tenant `kubernetes` HelmRelease now reports Ready as soon as Helm completes (Install/Upgrade DisableWait) and no longer blocks on worker/addon readiness — worker-rollout health is tracked by WorkloadMonitor, not the HelmRelease.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
## Release Notes
**New Features**
* Added Talos worker configuration for `talos.version` and
`talos.schematicID`
* Introduced `nodeHealthCheck` (`maxUnhealthy`, `nodeStartupTimeout`)
for worker remediation tuning
* Added image overrides for `kubectl` and `talosCsrSigner`
* Exposed additional tenant control-plane Service ports support
**Breaking Changes**
* Removed Kubernetes v1.30; supported versions start at v1.31
* Renamed `nodeGroups.ephemeralStorage` → `nodeGroups.diskSize` with
consolidated Talos system-disk semantics
**Removals**
* Removed Ubuntu container-disk images and related containerd patch
mechanisms
* Air-gapped tenant workers temporarily unsupported during the Talos
transition
**Documentation**
* Updated chart docs, parameters, and GitOps upgrade guidance for
version bumps
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What
Raise the `kafka` and `zookeeper` components in the Kafka e2e test from
the `nano` preset (250m / 128Mi) to `c1.small` (1 CPU / 1Gi), the chart
default.
## Why
128Mi cannot hold the Strimzi Kafka 3.9.1 and ZooKeeper JVMs. The
brokers OOMKill on startup (exit 137) and enter CrashLoopBackOff, so the
pods never reach Ready, the `Kafka` CR and its HelmRelease never go
Ready, and the 5m readiness wait times out. It presents as a flake
rather than a hard failure because 128Mi sits right at the JVM-start
threshold — occasionally a broker becomes Ready before its RSS crosses
the cgroup limit.
`c1.small` (1Gi) comfortably fits both JVMs and matches the chart
default, so the test exercises the documented default sizing. The e2e
sandbox VMs are 8 CPU / 24Gi, so 1Gi per pod (4Gi total) across the four
pods (kafka-0/1, zookeeper-0/1) is well within budget.
## Scope
Test-only; values changed, test structure untouched. MariaDB's e2e test
also uses `nano` but was deliberately left as-is: it runs MariaDB (C) +
a Go exporter with no JVM, its chart default is already `t1.nano`
(128Mi), and it shares the `nano` preset with the passing
postgres/redis/qdrant tests — not the same OOM class.
## What this PR does
**Problem**
The `foundationdb` e2e test fails intermittently with a `.status`
jsonpath poll timeout even though the FoundationDB cluster is fully
healthy and reconciled. The `generations.reconciled`,
`hasListenIPsForAllPods`, `health.fullReplication` and `health.healthy`
gates polled their status fields with a 60s ceiling, while the
strictly-earlier `health.available` gate already allowed 300s. The
operator stamps `generations.reconciled` only after every process holds
its role for `minimumUptimeSecondsForBounce` (60s, set in
`packages/apps/foundationdb/templates/cluster.yaml`), which makes
`reconciled` — and the health fields gated on it — structurally the last
status fields to converge. The short ceiling closed the gate seconds
before the field landed; a snapshot taken a few seconds after a failure
shows the live object fully reconciled (`generations.reconciled: 1`,
`health.healthy: true`).
**Fix**
Raise those four late-converging gates to 300s, matching the existing
`health.available` budget. The waits stay event-driven (`until …; do
sleep …; done`) — only the ceilings widen, no new retries or fixed
sleeps. A comment records why `reconciled` converges last so the budget
ordering stays legible to future readers. The early-converging 60s gates
(`configured`, `connectionString`, `databaseConfiguration.*`,
`desiredProcessGroups`) and the spec-field gates are left untouched,
since they settle before `health.available` and 60s is already adequate.
**Why not just rerun**
Rerunning masks a deterministic ordering bug: the latest-converging
field carried the tightest budget. The cluster was healthy on every
"failure" — the test was racing its own success criteria, so the failure
recurs until the budget ordering is corrected. This change touches only
the test harness; the platform behaviour it asserts is unchanged.
### Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Increased waiting time in one end-to-end FoundationDB setup test so
cluster status checks have more time to stabilize.
* Reduced flaky failures by allowing key health and reconciliation
fields to converge before the test continues.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Rewrite the SecurityGroup e2e for the membership model: assert the backing
CiliumNetworkPolicy's endpointSelector is the group's own membership label,
that attachments ride a storage-owned annotation hidden from the view, and
that fromApp/fromSG peers project to lineage- and membership-label endpoint
selectors the real cilium.io/v2 CRD accepts. A second test creates a
managed-labeled pod and asserts the securitygroup-controller stamps the
membership label on attach and clears it on detach.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The kafka e2e test pinned both the kafka and zookeeper components to the
legacy "nano" preset (250m CPU / 128Mi memory). 128Mi cannot hold the
Strimzi Kafka 3.9.1 and ZooKeeper JVMs: the brokers OOMKill on startup
(exit 137) and CrashLoopBackOff, so the pods never reach Ready, the Kafka
CR and its HelmRelease never go Ready, and the 5m readiness wait times
out. It surfaced as a flake because 128Mi sits right at the JVM-start
threshold — occasionally a broker passes readiness before its RSS crosses
the cgroup limit.
Raise both components to "c1.small" (1 CPU / 1Gi), the chart default,
which comfortably fits the JVMs. The e2e sandbox VMs are 8 CPU / 24Gi, so
1Gi across the four pods is well within budget.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The two bucketaccesses waits used the bare jsonpath form, which also
matches the literal "false"; require =true so a never-granted access
fails the wait instead of passing against an ungranted object. Also
print the objectstorage-controller pod status in the bucketReady
failure block so controller-side issues are visible from the test log.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The reconciled, hasListenIPsForAllPods, fullReplication and healthy gates
polled status fields with a 60s ceiling, while the strictly-earlier
health.available gate had 300s. The operator stamps generations.reconciled
only after every process holds its role for minimumUptimeSecondsForBounce
(60s), making reconciled and the health fields that depend on it the last
status fields to converge. The short ceiling closed the gate seconds before
the field landed on a healthy, fully-reconciled cluster, causing intermittent
jsonpath poll timeouts.
Raise those four gates to 300s to match the health.available budget. The waits
stay event-driven; only the ceilings widen.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The bucket suite waited on '{.status.bucketReady}' without an expected value;
a bare jsonpath match is satisfied by the literal "false", so the wait passed
against an unready claim. Both suites also allowed 300s/600s, long enough to
tolerate the propagation race rather than catch it, so they stayed green
whether or not the BucketClaim ever converged.
Assert bucketReady=true on a 120s bound. Convergence is now controller-driven
(the controller requeues the claim until the backend Bucket's readiness
propagates) and completes within tens of seconds; a claim frozen by the
propagation race never converges and now fails the wait fast instead of being
masked.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Add helm-unittest suites for the new Talos templates: MHC maxUnhealthy int-vs-percent rendering, the Talos<->Kubernetes support-matrix guard, the talos templates, and the ported kubelet-reservation math; restore the gpu=on node-label assertions. Remove the obsolete containerd-config-path test and fixtures. Raise tenant control-plane wait timeouts in the e2e runner for Talos PKI + sidecar boot.
Co-authored-by: Andrei Kvapil <kvapss@gmail.com>
Co-authored-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The unmarked CiliumNetworkPolicy fixture carried only an endpointSelector,
which the cilium.io/v2 CRD rejects — spec requires at least one of
ingress/ingressDeny/egress/egressDeny (anyOf), so kubectl apply failed
before the invisible-to-SecurityGroup assertion could run. Add a minimal
ingress rule so the fixture applies as a valid, unmarked policy.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Update the REST storage fixtures and e2e bats to the targetRef shape, and
add cases for the new behaviour: the derived endpointSelector matchLabels,
the apiGroup default to apps.cozystack.io, an empty-targetRef rejection,
and invalid-label-value rejection (over-length or malformed kind/name/
apiGroup). The e2e asserts the dotted-key lineage labels serialise and are
accepted by the real cilium.io/v2 CRD, and that the view reconstructs the
targetRef.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The aggregated API server translates each SecurityGroup into a single
CiliumNetworkPolicy in the same namespace and back. Policies owned by the
SecurityGroup API are marked with the sdn.cozystack.io/securitygroup label,
which the storage always re-asserts on every write and hides from the
SecurityGroup view, so it only ever surfaces or mutates its own policies,
leaves platform-managed policies untouched, and cannot be orphaned by a
tenant overwriting the marker through spec labels. Mutating verbs run the
genericapiserver admission hooks, and updates replace labels/annotations
rather than merging them, matching Kubernetes PUT semantics.
To avoid importing the full Cilium module — which pins a Kubernetes
version incompatible with this project's apimachinery fork — the backing
object is a minimal in-tree CiliumNetworkPolicy mirror that reuses
SecurityGroupSpec as its spec body, making the projection a near-identity
translation in both directions.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Co-authored-by: Timofei Larkin <lllamnyp@gmail.com>
The per-test failure snapshots (host in cozytest.sh, tenant in run-kubernetes.sh)
ran `crust-gather collect` unbounded. On a contended/degraded cluster the collect
can hang indefinitely — observed wedging the E2E install step for ~2h35m after a
test failure until the job-level cancel, with no output after the capture line.
`|| true` only catches a bad exit code, never a hang.
Wrap both invocations in `timeout -k 30 300`: a 5-minute cap (hard SIGKILL 30s
later if the collect ignores SIGTERM) bounds the hang; a partial snapshot still
beats a multi-hour wedge, and timeout's exit 124 is already swallowed by `|| true`.
Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
kubernetes-{latest,previous} intermittently failed their node-Ready wait
with the tenant API "connection refused" on localhost, while the cluster was
in fact healthy (host snapshot: both CAPI Machines NodeHealthy=True, TCP
Ready 2/2). Root cause: `kubectl port-forward service/kubernetes-<name>`
pins to ONE of the two Kamaji control-plane pods; when that pod's
kube-apiserver container restarted once (observed restartCount=1), the
forward broke and did not recover, so the 12m wait ran against a dead socket
and the failure-path describe/snapshot also got nothing.
Expose the tenant API with a test-scoped LoadBalancer Service selecting the
Kamaji control-plane pods instead. It load-balances across ALL ready apiserver
endpoints, so a single pod's apiserver restart is routed around transparently;
the host runs MetalLB on the sandbox /24 (pool 192.168.123.200-250) so the IP
is directly routable from the test (the in-tenant LB test already curls such
an address). The kubeconfig points at the LB IP with insecure-skip-tls-verify
(the MetalLB IP isn't in the apiserver cert SANs; e2e only) and the CA dropped.
Bonus: the LB endpoint is stable until teardown, so the on-failure tenant
crust-gather snapshot can finally reach the tenant (the port-forward was dead
by snapshot time, which is why every prior tenant snapshot came back empty).
Removes the port-forward supervisor + pidfile machinery. Verified against the
real hack/cozytest.sh under dash (CI's /bin/sh) in ubuntu:22.04: no pipeline
deadlock, the in-subshell snapshot trap fires and captures the tenant, and the
LB Service is cleaned up on both the success path and cozy_cleanup.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
crust-gather can only reach a tenant Kamaji cluster through the test's
localhost port-forward (it connects directly to the kubeconfig server URL —
there is no apiserver-proxy mode — and the in-cluster URL is unreachable from
the runner). cozytest.sh's generic EXIT trap runs in the parent shell AFTER
the failing test subshell has exited and its port-forward is reaped, so a
collect there hits a dead socket and crust-gather bails before writing
anything (every tenant snapshot so far came back empty).
Capture the tenant from an EXIT trap registered INSIDE run_kubernetes_test
instead: it fires during THAT subshell's exit, while the supervised
port-forward is still alive. Verified end-to-end against the real
hack/cozytest.sh under dash (CI's /bin/sh) in an ubuntu:22.04 container —
the in-subshell trap fires on failure with the PF alive, the tenant snapshot
lands in the same snapshots/<test>/ dir as the host snapshot, the generic
cozy_cleanup still runs, and there is no pipeline deadlock.
Drop the now-dead tenant loop from cozytest.sh's generic trap (it could only
ever see a reaped PF); it keeps the host snapshot.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
A single `kubectl port-forward` to the tenant Kamaji API silently dies on
an SPDY/connection drop and never reconnects. The tenant apiserver itself
stays healthy (control-plane pods observed at 0 restarts while localhost
refused connections), so this is a client-side port-forward death, not a
control-plane outage. Once it dies every later tenant kubectl op wedges:
a long `kubectl wait` burns its full timeout against the dead socket
(observed: nfs-test-pod hit its 10m deadline) and the failure-path
`describe`/`get events` print 'connection refused', destroying the
diagnostics too.
Run the port-forward under a respawn loop so a drop self-heals within ~1s;
`kubectl wait` retries across the gap and the in-test failure dumps work
again. Track the supervisor PID in a pidfile so cozy_cleanup() can stop it
on a failed run (the success path kills it directly), and reap the child
port-forward with pkill in both paths.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Harbor's registry BucketClaim wait timed out at 300s on an overloaded
runner even though the underlying Bucket reached bucketReady=true and the
BucketAccess was granted — only the central COSI controller's propagation
of readiness back onto the namespaced BucketClaim lagged (all HelmReleases,
harbor included, were Ready by end-of-run). Raise the wait to 600s to match
the dependent harbor-system HelmRelease budget and remove the flake.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
(cherry picked from commit 074501f999)
cozytest.sh runs each .bats as one invocation, exits on the first failing @test, and never invokes bats setup/teardown. Add a single EXIT-trap hook: on failure, snapshot the host AND every nested tenant cluster (tenantkubeconfig-*) with crust-gather into per-app dirs BEFORE cleanup — so each failure is independently inspectable (crust-gather serve) instead of one end-of-suite host-only dump; then ALWAYS run the file's cozy_cleanup() if defined.
run-kubernetes.sh gains cozy_cleanup() (delete tenant clusters, wait for PVCs to free): a failed kubernetes test otherwise leaves tens of GiB of worker-VM PVCs in tenant-test, exhausting the shared tenant-quota and cascade-failing every storage-heavy app after it. The kubernetes .bats now source run-kubernetes.sh at file scope so the parent-shell trap can reach the hook.
Ship pinned crust-gather 0.15.2 in the e2e-sandbox image; fold per-test snapshots into cozyreport.tgz.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
kubernetes-latest flaked because the worker-readiness wait was split into 8m-to-join + 3m-to-become-Ready. Under sandbox load a slow KubeVirt VM boot consumes most of the join budget, then the tenant cluster's cilium CNI needs several more minutes to make the freshly-joined nodes Ready, overflowing the fixed 3m Ready window even though the CNI converges fine (its HelmRelease reached True shortly after). Replace with one 12m deadline polling for >=2 Ready nodes, robust to wherever the time goes. Same single-deadline shape as the LINSTOR post-install fix (#2928).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Reconfiguring the Etcd CR triggers a HelmRelease upgrade that renders the EtcdBackupSchedule; hr/etcd and etcdcluster can report ready on the prior revision before the new resource materializes, so the immediate get raced to NotFound. Poll for it (timeout 120s), mirroring the CronJob wait already used later in the same test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The last three gateway tests create nested tenants (gwprop,
gwparent/gwchild, rparent/rchild) and deleted them fire-and-forget,
with parent and child deletes back-to-back. The file exits in ~25s
while the uninstalls run on for minutes: each tenant uninstall blocks
on a cleanup Job in cozy-system, and a parent deleted alongside its
child wedges on the child's still-terminating namespace.
On the tenants helm-controller shard (--concurrent=5) the leftovers
from one gateway.bats run — three stuck parent uninstalls, one stuck
child uninstall, and the rchild HR still mid-install — occupied
exactly 5 workers, so the next test in line (harbor) sat unreconciled
for its whole 5m HR-Ready budget and failed without ever erroring.
Sequence the teardown instead: delete child, wait for its HR to go
away, then delete parent and wait again. The waits are hard failures
on purpose — if a tenant uninstall genuinely wedges, gateway.bats is
the test that should go red, not whichever app test runs next.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
## What this PR does
Upgrades Flux v2.7.3 → v2.8.0 across both the vendored tenant chart and
the embedded management-cluster manifests, and folds in the chart fixes
that become hard errors under the new strict helm-controller v1.5.
Flux v2.8's helm-controller v1.5.0 ships:
- **Server-Side Apply with `--force-conflicts`** — strict CRD schema
enforcement; misplaced fields (silently dropped on v2.7) now fail the
apply.
- **kstatus-based health checking** by default — parent HR waits for
every applied resource (including child HRs) to be Ready before flipping
its own Ready, surfacing latent ordering deadlocks.
- **Helm v4 embedded** — `upgrade.force: true` is deprecated and now
conflicts with SSA.
- **`HelmRelease.spec.healthCheckExprs`** — prerequisite for proper
readiness gating (used in PR #2601 split).
Folds in PR #2612 (kubevirt-instancetypes null TPM fix) since the same
Flux upgrade triggers it.
## Commits
**Flux upgrade itself:**
- `feat(fluxcd)`: bump `flux-operator` / `flux-instance` vendored charts
to v0.48.0; web UI opt-in.
- `feat(flux)`: regenerate embedded management-cluster manifests via
`make update` in `packages/core/flux-aio` (timoni bundle build).
**Chart fixes for strict SSA — fields the chart sent that v2.7 silently
dropped, v2.8 rejects:**
- `fix(kubevirt-instancetypes)`: drop persistent strip that produced
null `preferredTPM` (folds #2612).
- `fix(foundationdb)`: relocate `faultDomain`, `imageType`, `labels`,
`minimumUptimeSecondsForBounce` from inside `automationOptions` to
direct children of `spec`.
- `fix(kafka)`: place `enableServiceLinks` under `template.pod`, not a
phantom `template.spec`.
- `fix(vm-instance)`: emit `disk: {}` (not `disk:`/null) when no bus is
set.
- `fix(platform)`: drop deprecated `upgrade.force: true` from
HelmReleases; fix `kafka` WorkloadMonitor `replicas` paths.
**Ordering / deadlock fixes under v2.8 kstatus:**
- `fix(vpa)`: break circular wait between parent install and nested
`vpa-for-vpa` HR.
- `fix(kubernetes)`: drop lookup-guarded parent-HR `dependsOn` on tenant
addon child HRs (parent waits on child via kstatus, child waited on
parent — deadlock).
**E2E waits for v2.8 kstatus timing:**
- `test(e2e)`: bump app HR-Ready waits to 5m (was 20s–100s under v2.7's
faster dispatch).
- `test(e2e)`: wait for parent HR Ready before downstream asserts in
`run-kubernetes.sh` and `vminstance.bats`.
## Scope discipline
This PR is part of the split of #2619 (the consolidated CI fixes branch)
into review-friendly pieces. Companion PRs:
- **PR #2601** (seaweedfs split) — folded into this PR (commits
`29c6afc8`, `0e8b46d7`, `7157158c`, `dccdeb52`, `f880b324`): the
seaweedfs-system → seaweedfs-db + seaweedfs-system split, its adoption
migration 43 (targetVersion 44), and the configurable db resources all
land here, because the strict-SSA `upgrade.force` removal and the
kstatus parent-HR timeout bump only make sense together with the split.
#2601 is superseded.
- **PR #2558** (drop 3× retry on `Run E2E` + `Install Cozystack`) —
independent, lands separately.
- Several smaller standalone fixes lifted out of #2619 (startup probes,
cert-manager `dependsOn`, prepull machinery, CSI HR timeout, NFS/OIDC
test improvements) — opened as separate PRs.
## Verification
- `helm template` renders cleanly for both `fluxcd` and
`fluxcd-operator` packages with `web.enabled=false` (default) and
`web.enabled=true`.
- Embedded `cmd/cozystack-operator` binary contains the v1.5.0 / v1.8.0
/ v2.1.0 controller image strings.
- No references to the v0.39-removed `--disable-wait-interruption` flag
anywhere in `packages/` or `internal/`.
### Release note
```release-note
Flux upgraded to v2.8.0 (helm-controller v1.5 — Helm v4 Server-Side Apply with --force-conflicts, kstatus health checking).
When upgrading existing clusters:
- Kubernetes 1.33+ is now required for the platform (management) cluster, and for any tenant cluster that enables the optional (default-off) Flux addon — that addon ships the bumped Flux too.
- HelmReleases no longer set `upgrade.force: true`. Helm v4 SSA resolves field-ownership conflicts automatically (`--force-conflicts`), but that is not the old client-side replace: immutable-field changes (e.g. StatefulSet volumeClaimTemplates/serviceName) no longer self-heal and require manual recreation — delete the object (e.g. `kubectl delete sts <name> --cascade=orphan`) and let Flux recreate it.
- KubeVirt: persistent TPM/EFI is re-enabled for the Windows 11/2k22/2k25 preferences (KubeVirt 1.8 VMPersistentState); each affected VM provisions an extra RWO backend-storage PVC from the default StorageClass.
- KubeVirt: the EOL centos.7*/centos.stream8* preferences are retained as deprecated, hidden aliases (`tags: hidden`, `instancetype.kubevirt.io/deprecated: "true"`) — existing VMInstances on these profiles keep rendering and need no action on upgrade, but the profiles are no longer offered for new VMs; repoint to centos.stream9/10 when convenient. The gn1.* GPU instancetypes are likewise retained.
- FoundationDB: imageType now reaches the operator (silently dropped pre-SSA); it is pinned to `split` to match the value existing clusters effectively ran, so upgrades stay non-disruptive. Set `imageType: unified` to migrate deliberately.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Flux Status web UI: optional web server, config secret, service port,
HTTPRoute/Ingress, network policy, and RBAC roles.
* New SeaweedFS DB Helm chart and optional managed DB release.
* **Improvements**
* CRD/schema enhancements: new provider kinds, validations, variant
option, and external checksum refs.
* Raised Kubernetes prerequisite to 1.30+; extended e2e timeouts for
reliability.
* **Chores**
* Bumped Flux Operator and Flux versions; documentation links updated to
fluxoperator.dev.
* **Bug Fixes**
* Removed aggressive HelmRelease force-upgrade/install flags.
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2602?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->
## What this PR does
Adds [kuberture](https://github.com/lexfrei/kuberture) as an optional
system package. Kuberture is a small controller that translates the
`default/kubernetes` EndpointSlice into headless Services with
external-dns-style annotations, closing the gap that external-dns cannot
use EndpointSlice as a source directly:
```text
EndpointSlice (kubernetes) → kuberture → headless Service(s) with annotations → external-dns → DNS
```
The package is opt-in via `bundles.enabledPackages:
[cozystack.kuberture]`. Operators populate `config.outputs` per
HelmRelease — each output targets a specific external-dns instance by
its `annotationPrefix`, so a single kuberture install can serve multiple
external-dns instances (e.g. one publishing to the public zone, another
to an internal corporate prefix) simultaneously.
The vendored chart at `packages/system/kuberture/charts/kuberture/` is
pulled from `oci://ghcr.io/lexfrei/kuberture/charts/kuberture` and
pinned by both version AND OCI manifest digest in the Makefile
(mirroring the ouroboros lockstep pattern). The controller image at
`ghcr.io/lexfrei/kuberture` is pinned by tag+sha256 in the shim
values.yaml. Both pins advance in lockstep with each upstream release;
the procedure is documented inline.
Install-order dependencies declared in `PackageSource`:
`cozystack.networking` (CNI before pod scheduling) and
`cozystack.prometheus-operator-crds` (ServiceMonitor CRD before the
chart's enabled-by-default ServiceMonitor manifest). External-dns is
deliberately NOT a dependency — kuberture creates annotated Services
regardless of whether external-dns is installed; external-dns is a
downstream consumer, not a runtime prerequisite.
Coverage:
- **helm-unittest**
(`packages/system/kuberture/tests/kuberture_test.yaml`, 10 cases):
fail-fast on empty outputs, ServiceMonitor enabled by default, source
pinned to `default/kubernetes`, multi-output prefix isolation,
empty-string `annotationPrefix` schema rejection, omitted-prefix
fallback to controller default, ConfigMap → Deployment volume mount,
controller image pinned by tag+sha256 digest, Role permits full Service
lifecycle, metrics containerPort derived from `metricsBindAddress`.
- **bats e2e** (`hack/e2e-apps/kuberture.bats`): applies a `Package
cozystack.kuberture` with two outputs (different `annotationPrefix`
values), waits for HelmRelease and controller readiness, asserts both
output Services are headless and carry the right
`hostname`/`target`/`ttl` annotations under their configured prefix,
cross-checks per-output isolation (each Service only carries its own
prefix's annotations).
### Screenshots
<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->
Not applicable — no UI changes.
### Release note
<!-- Write a release note:
- Explain what has changed internally and for users.
- Start with the same `type(scope):` prefix as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->
```release-note
feat(platform): add kuberture as an optional system package — translates the default/kubernetes EndpointSlice into annotated headless Services so external-dns can publish the Kubernetes API endpoint to DNS. Opt-in via bundles.enabledPackages: [cozystack.kuberture]; populate config.outputs to target one or more external-dns instances by annotationPrefix.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Adds kuberture as an optional system package to publish the Kubernetes
API endpoint to DNS via configurable external-dns outputs; includes Helm
chart, RBAC, NetworkPolicy, PDB, ServiceMonitor, and validated chart
values/schema.
* **Tests**
* Adds Helm unit tests and an end-to-end smoke test validating
controller readiness, headless Service annotations, and external-dns
routing/probe behavior.
* **Documentation**
* Adds a full README with usage, configuration examples, and
vendoring/pinning guidance.
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2647)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---
Documentation companion:
[cozystack/website#539](https://github.com/cozystack/website/pull/539).
The two external-dns bats tests already poll for the user-facing ${name}
HelmRelease before calling kubectl wait, but they immediately wait on the
${name}-system HR without the same backstop. The system HR is materialised
asynchronously by the controller, so kubectl wait can race and exit non-zero
when the object does not exist yet. Apply the same existence poll pattern
to both occurrences.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Two timing bugs unmasked by Flux v2.8 kstatus, with the same root shape:
the bats steps assumed a side-effect ordering ("by the time X is true,
the parent HR's helm install is done") that held under v2.7's faster
dispatch but breaks under v2.8 where helm-controller walks kstatus on
every applied resource before flipping the HR Ready.
run-kubernetes.sh:
After every addon HR (cilium, coredns, csi, vsnap-crd, ingress-nginx)
reaches Ready, the remediation guard reads `.status.history` on the
parent kubernetes-${test_name} HR. The parent's helm install can still
be Reconciling at that point because kstatus is finishing its full
sweep; `.status.history` is empty until install completes, so the
guard exits 1 with "empty .status.history on a Ready HR". kubernetes-
latest masked this because the ouroboros assertions add ~10m of
buffer; kubernetes-previous (no ouroboros) hit the guard with the
parent still installing. Add an explicit
kubectl wait hr/kubernetes-${test_name} --for=condition=ready
before the guard.
vminstance.bats:
The test waited 120s for `kubectl get vmi vm-instance-$name` to
return a VMI with an assigned IP, then waited for the parent HR Ready
separately afterwards. Under v2.7 the parent HR's helm install
applied the VirtualMachine CR fast enough that kubevirt had created
the VMI before the 120s budget expired. Under v2.8 the dispatch is
slower and the VMI poll returned NotFound for its entire budget.
Reorder: wait for HR Ready first (which implies the VirtualMachine
CR is applied + kstatus-ready), then poll for the VMI with IP.
Loosen the VMI poll budget from 120s to 5m for nested-virt slow paths.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit c664b868bc)
Flux helm-controller v1.5.0 (shipped in Flux v2.8) replaced the legacy
Helm readiness logic with kstatus-based health checking, which polls
Deployment/StatefulSet/Service status and waits for kstatus to assess
rollout-ready. This typically adds 10-30s of latency between resource
apply and HR condition Ready=True compared to v2.7.
Our per-app bats tests had `kubectl wait hr/... --for=condition=ready
--timeout=` budgets of 20s, 30s, 60s, 100s — sized for v2.7's faster
readiness flip and previously masked by the 3x retry on Run E2E tests
(dropped in #2558). Under v2.8 with no retry, every app that runs after
the first few hit its HR wait timeout exactly, surfacing as 13 of 16
test failures on PR #2619.
Standardise on 5m for HR Ready across all app tests. Tight enough to
catch genuine bring-up failures within the per-app job budget, loose
enough to absorb kstatus dispatch jitter on a management cluster that
is concurrently reconciling multiple tenant-test HRs.
Post-HR-Ready checks for app-specific resources (StatefulSet/Service/
PVC/operator CRs) remain — for CR-based apps kstatus only observes
that the chart applied, not that the operator finished. Audit of
which of those are actually redundant under v2.8 kstatus is queued
as a follow-up.
Also bumps the 10s "kamajicontrolplane appears" wait in
run-kubernetes.sh to 2m, and the kubernetes-addon HR Ready loop from
1m to 5m for the same reason.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit 5ef02ee7b3)
Both \${test_name} and \${component} are literal-safe today (no
whitespace, no glob characters), so this is a style-only change — but
quoting variable expansions in kubectl arguments matches the rest of
the file and removes a future-foot-gun if a test name ever grows a
hyphen-y suffix that interacts oddly with completion or globbing.
Scoped to the two HelmRelease wait lines flagged by CodeRabbit and
echoed by the human reviewer; the machinedeployment wait on the
preceding line keeps its existing style.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
\`local k8s_version=\$(yq ...)\` masks the exit status of the command
substitution — the assignment to a local variable always succeeds, so
a failing yq is swallowed. Declare the local first, then assign, so
non-zero yq exits propagate via shell errexit semantics if the script
ever runs under \`set -e\`.
Pure shell hygiene; no behavioral change on the success path. Surfaced
by CodeRabbit and confirmed by the human reviewer as non-blocking but
worth applying.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The previous commit (3c81261b) folded the ouroboros hairpin-NAT
reconciliation assertions into the kubernetes-latest tenant under
run-kubernetes.sh, but did not remove the standalone bats file.
The e2e workflow auto-discovers every hack/e2e-apps/*.bats and dispatches
test-apps-<name> via the wildcard rule in packages/core/testing/Makefile.
Leaving ouroboros.bats in the tree meant the assertions ran twice: once
on the standalone ~25m Kamaji-backed tenant and again as the folded
addon on the kubernetes-latest tenant — the opposite of the consolidation
the fold was intended to deliver.
The folded assertions in hack/e2e-apps/run-kubernetes.sh cover the same
surface (HR Ready, controller pod Running, coredns-custom rewrite-line
injection, and end-to-end DNS resolution from inside the tenant), so
the standalone file is dead weight after the fold.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Previous run failed kubernetes-latest (and then kubernetes-previous via
cascade) on the 5m budget for nfs-test-pod to reach Succeeded. The
chain is host linstor prime PVC + CDI import + tenant CSI attach +
busybox image pull + containerd CreateContainer (which occasionally
stalls past kubelet's 2m CRI deadline and retries with name-reservation
conflict). ~40s on the happy path, 6m+ in observed worst case.
When kubernetes-latest times out, its tenant cluster (2x 20Gi worker
PVCs) leaks because set -e exits before any cleanup. kubernetes-
previous then runs, adds another 40Gi, and the host tenant-quota
(100Gi) is exhausted -- every downstream app test (mariadb, mongo,
postgres, qdrant, redis, openbao, vminstance) fails with
ErrCreatingPVCPrime / exceeded quota.
Bumping the wait to 10m gives the slow path room. Headroom costs
nothing on green runs since the pod is busybox echo.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit c5cc7b60b8)
kubernetes-previous fails on the NFS pod step. From cozyreport + job log:
- Pod scheduled to tenant worker
- 7x FailedAttachVolume over 35s: CSINode does not contain driver
csi.kubevirt.io (kubevirt-csi-node DaemonSet still rolling out)
- Attach succeeds at T+1m08s after the driver registers
- busybox pull queued ~1m25s, completes at T+2m48s
- CreateContainer in containerd then stalls past kubelet's CRI
deadline; retry trips "failed to reserve container name ... is
reserved for <prev>" -- the 5m pod-Succeeded wait then expires.
Root cause: run-kubernetes.sh creates the NFS PVC+Pod before asserting
kubernetes-${test_name}-csi is Ready. That HR lives in the parent
cluster but installs cozy-kubevirt-csi-node into the tenant via
kubeConfig.secretRef; helm-controller walks kstatus on the tenant
DaemonSet using the tenant kubeconfig. HR Ready in parent therefore
implies kubevirt-csi-node Pods Ready on every tenant worker, which
implies csi.kubevirt.io is published in CSINode. The existing wait at
line 354 runs AFTER the NFS test, so it does not gate the race.
Move the wait earlier with a 10m budget. Post-NFS sibling waits stay
as is, so component coverage is unchanged.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit 394b1b2b9a)
The standalone hack/e2e-apps/ouroboros.bats provisioned a full Kamaji-
backed tenant Kubernetes cluster (control plane + 2 CAPI workers + base
addons) just to flip `addons.ouroboros.enabled=true` on top, then ran
~15m of hairpin-NAT reconciliation assertions. Cluster shape was
identical to what hack/e2e-apps/run-kubernetes.sh already provisions for
kubernetes-latest, so the second Kamaji bringup was pure duplication —
roughly 25-45m of CI wall-time per run.
Add an `enable_ouroboros` 4th parameter to `run_kubernetes_test()`. When
"true", inject the ouroboros addon block into the Kubernetes CR's
addons map and run the post-cluster assertions inline:
- Wait for the ouroboros HR Ready in the host namespace.
- Verify coredns-custom ConfigMap exists in tenant kube-system.
- Wait for the ouroboros controller pod Ready.
- Apply an Ingress with a TLS host in tenant default namespace.
- Poll coredns coredns-custom for the rewrite line (5m budget).
- Resolve the hairpin host from inside the tenant via a netshoot pod
and assert it points at the ouroboros-proxy ClusterIP.
- Clean up dnscheck pod + Ingress.
kubernetes-latest passes "true" (gets ouroboros coverage); kubernetes-
previous leaves it empty (control-plane-only smoke remains).
(The standalone ouroboros.bats was removed in the preceding commit
alongside an unrelated foundationdb chart fix — the git rm staging had
already happened when the foundationdb commit landed.)
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit b5c9c2ac90)
The previous e2e check waited for
`HTTPRoute.status.parents[0].conditions[Accepted]=True` after
applying a child tenant's HTTPRoute against the parent's Gateway.
That condition is set by Cilium after the listener's TLS Secret
becomes Ready — and in the e2e cluster every fresh certificate fails
to issue (LE prod refuses every `.example.org` hostname by policy,
visible across alerta/grafana/dashboard/seaweedfs-s3 cluster certs
in cozyreport). So Cilium never set Accepted, the route timed out
3/3 retries, and the failure was environmental — not a regression
in the inheritance code path.
The contract the test exists to pin is cozystack-controller's
behaviour, which has two observable writes:
1. The parent Gateway grows a per-listener HTTPS entry for the
child route's hostname (proves collectHostnameClaims saw the
route through the inheritance label and reconcileGateway
appended the listener).
2. A per-listener Certificate object appears in the parent
namespace with the route's hostname in spec.dnsNames (proves
reconcilePerListenerCertificates ran for the same hostname).
Both writes are deterministic and depend only on the inheritance
label + the HTTPRoute existing — not on ACME, not on Cilium TLS
readiness. The test now polls those two objects directly within
shorter, scenario-appropriate timeouts (120s for the listener, 60s
for the Certificate). The route Accepted check is dropped with an
explanation in the test comment so a future cluster with a working
ACME doesn't get a misleading pre-condition added back. The
namespace inheritance label is also verified up front as the
precondition collectHostnameClaims's filter reads.
Signed-off-by: Aleksei Sviridkin <f@lex.la>