Lock in the SA-object token toggle (off by default, on only for kubernetes
auth), complementing the pod-level assertions.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Address review: the proxy pod ran under the namespace default ServiceAccount,
which the keycloak StatefulSet also uses. Binding a Vault Kubernetes-auth role
to default would hand the keycloak pod itself Transit access, defeating the
isolation the proxy exists to provide. Add a dedicated keycloak-kms-proxy
ServiceAccount, run the proxy pod under it, and mount its token only in the
kubernetes auth mode (off otherwise, to minimise the credential surface).
Docs name the SA to bind the Vault role to.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
Extend encryption.kms.vault.auth with "kubernetes" (recommended): the
proxy logs in with its own ServiceAccount token, so no credential is
stored in a Secret. "approle" and "token" remain. Render-time
validation requires kubernetes.role and rejects unknown methods;
helm-unittest covers the new path and the mutual absence of the other
modes' env vars.
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
The notContains checks matched whole array elements, so a name-only
content never equaled the valueFrom entry and the assertion could never
fail. Add any:true so "AppRole stores no static token" and, symmetrically,
"token mode renders no AppRole vars" are actually verified (confirmed by
mutation-testing the template).
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
The keycloak-kms-proxy Vault Transit backend only supported a static
token. Add an encryption.kms.vault.auth switch — "token" (default,
unchanged) or "approle" — wiring KKP_VAULT_ROLE_ID / KKP_VAULT_SECRET_ID
/ KKP_VAULT_APPROLE_MOUNT from a Secret. With AppRole the proxy logs in
and re-authenticates on demand, so no long-lived token is stored.
Requires a keycloak-kms-proxy image with AppRole support (> v0.1.0).
Assisted-By: Claude AI
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
## What this PR does
Bumps the LINSTOR storage stack to current upstream, picking up the
published security fixes the issue enumerates:
- `linstor-server` (piraeus-server) `1.33.2 → 1.33.3` (latest 1.33
patch; v1.34.0 is rc-only). Pulls in the 1.33.3 DRBD fixes (clone-status
check before connection states arrive; the secondary-after-mkfs path is
now guarded upstream too) plus refreshed Debian packages. Note: 1.33.3
also disables DRBD volume shrink upstream (it never worked correctly) —
no effect on cozystack, which does not expose volume shrink.
- `linstor-csi` `v1.10.6 → v1.11.2`. Its go.mod requires Go ≥ 1.26, so
the CSI builder moves `golang:1.25 → golang:1.26` — the right lever for
the Go stdlib / `moby/spdystream` CVEs.
Both cozystack images are rebuilt multi-arch (linux/amd64 + linux/arm64)
and pinned by digest in `values.yaml`. All five piraeus-server patches
re-apply cleanly against v1.33.3 in the Dockerfile glob order, and both
linstor-csi patches re-apply against v1.11.2.
Adds helm-unittest coverage (the package shipped none) asserting the
controller / CSI / satellite images stay digest-pinned
(`<image>:<tag>@sha256:<64-hex>`), version-agnostic so it survives the
release-prepare re-tag.
**Verified on a dev cluster:** a rolling restart of all satellites,
controller, and CSI to 1.33.3 / v1.11.2 kept every node Online with zero
faulty resources; a replicated PVC provisioned, mkfs'd, and reached
UpToDate across 3 nodes with no DRBD "device held open" abort (the
cozystack secondary-after-mkfs patch handled the path). The csi v1.11.2
"no RWX without DRBD layer" change is a no-op for cozystack, which
always uses the DRBD layer.
Closes#2881
### Release note
```release-note
chore(linstor): bump linstor-server to 1.33.3 and linstor-csi to v1.11.2
Upgrade impact: an empty tenant Kubernetes worker `nodeGroups[*].storageClass` now defaults to the application-level (DRBD `replicated`) storageClass instead of the management-cluster default, because linstor-csi v1.11.2 rejects ReadWriteMany volumes on a non-DRBD storage class. This changes the rendered worker template, so existing tenant clusters that relied on the empty-storageClass default roll their worker MachineDeployments once on the first reconcile after upgrade — workers are reprovisioned on the application storageClass and container images are re-pulled. The per-nodeGroup override and the both-empty cluster-default escape hatch are preserved.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Chores**
* Updated LINSTOR Server to **v1.33.3** and LINSTOR CSI to **v1.11.2**
(image tags/digest pins refreshed)
* Updated the LINSTOR CSI build toolchain to **Go 1.26**
* Added a **test** make target to run Helm unit tests
* **Documentation**
* Updated the LINSTOR Server patches README to reference **v1.33.3**
* **Tests**
* Added an image-pinning validation suite to confirm controller and
satellite images use **sha256** digests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Worker-disk storageClass default (companion to the bump)
linstor-csi v1.11.2 rejects ReadWriteMany volumes that are not on a
DRBD-layer storage class. Worker VMs live-migrate, so their disks need
RWX — the access mode comes from the chosen StorageClass's CDI
StorageProfile (a DRBD class yields RWX), it is not set on the worker
DataVolume by the chart. With the bump, a worker nodeGroup left on a
non-DRBD cluster-default StorageClass would fail provisioning, so an
empty per-nodeGroup storageClass now defaults to the application
storageClass (replicated/DRBD) instead of the cluster default. The
per-nodeGroup override and the both-empty cluster-default escape-hatch
are preserved.
Upgrade impact: the empty-storageClass default ships on `md0`, and the
KubevirtMachineTemplate name is content-addressed over the rendered
worker body, so this flip renames the template and triggers a one-time
CAPI rolling replacement of worker pools on the first reconcile after
upgrade. Two hash-pin tests guard the rename so it stays an intentional,
reviewed change. linstor-csi v1.11.2 also defaults DRBD two-primaries to
kubevirt-only — a no-op for cozystack, where kubevirt is the target.
## What this PR does
Implements the immutable-tag + rc-promotion flow from #2677. A **stable
release becomes a renamed release-candidate**: the bytes shipped as
`vX.Y.Z` are bit-for-bit the bytes built and e2e-tested as
`vX.Y.Z-rc.N`. No tag is ever force-moved, and stable is never rebuilt —
it is *promoted* by retagging the rc's existing images.
Layered onto the build matrix from #2937/#2983 (this PR is **stacked on
`refactor/build-matrix-2937`** and must merge after it).
## The five force-retag sites, removed
| Site | Before | After |
|---|---|---|
| `tags.yaml` api/apps/v1alpha1 tag | `git tag -f` / `push -f` |
write-once (create-if-absent, fail if it would move) |
| `tags.yaml` release-X.Y.Z branch | `git branch -f` / `push -f` |
compare-before-force (no-op if unchanged; staging branch only) |
| `pull-requests-release.yaml` stable tag | `git tag -f` / `push -f` |
write-once at the PR merge commit (force impossible by construction) |
| `pull-requests-release.yaml` maintenance branch | `updateRef
force:true` | fast-forward-only |
| `auto-release.yaml` patch tags | delete-recreate (cron) | **workflow
deleted** — stable only via explicit promote |
## Version decoupling (the enabler)
The operator baked its version into the image at build time, so an rc
image self-reported the rc string — blocking retag-promotion. It now
reads `COZYSTACK_VERSION` from the environment (threaded via
`cozystackOperator.platformVersion` → Deployment env, stamped by `make
manifests`), falling back to the build-time value when unset. The same
image bits can report any release name. The only runtime reader is the
telemetry metric `cozy_cluster_info{cozystack_version=...}`.
## Promotion flow
`promote-rc.yaml` (`workflow_dispatch`, `rc_tag=vX.Y.Z-rc.N`):
1. Validate the rc release exists and the stable tag does not.
2. `hack/promote-retag.sh` reads the rc's digest-pinned image refs from
`packages/*/*/values.yaml` and `skopeo copy`s each — **by digest** — to
`:vX.Y.Z` and `:latest`, verifying with `skopeo inspect`.
3. Rewrite the cosmetic `-rc.N` substring in vendored tags to the stable
version (the `@sha256` wins regardless), restamp the version-stamped
assets (operator manifests, cozypkg, openapi; the heavy Talos assets are
copied verbatim from the rc draft), open a `release-X.Y.Z` PR.
4. Merging the PR reuses the existing release-PR e2e and
`pull-requests-release.yaml` finalize to cut the **write-once** stable
tag at the merge commit and publish the release. Squash is disallowed
(the tag needs a real merge commit).
`nightly.yaml` cuts write-once `*-nightly.<date>` tags (gated by
`NIGHTLY_ENABLED`); `retention.yaml` keeps the newest 14 per line
(dry-run by default).
## Validation
**Local end-to-end — the core claim is proven, not asserted.** The whole
design rests on "copy-by-digest to a new tag preserves the digest, so
stable == the e2e-tested rc, bit-for-bit." This was exercised against a
real registry (`ttl.sh`) with real `skopeo`:
- Pushed a multi-arch image under a `:v1.4.0-rc.2` tag, built a temp
tree exercising all three `values.yaml` digest shapes (single `image:`
string, split `repository`/`tag`/`digest` map, and the
`platformSourceRef` OCI artifact), then ran the actual
`hack/promote-retag.sh v1.4.0`.
- **Result: `:v1.4.0-rc.2`, `:v1.4.0`, and `:latest` all resolve to the
identical digest** (`sha256:fd8d9aa6…`), across all 17 platform
manifests. The script's own `skopeo inspect` post-check passed, and an
independent re-inspection confirmed it.
Local validation caught (and this PR fixes) two bugs that only surface
against a real registry — never in static lint:
1. `skopeo copy --multi-arch all --all` — mutually exclusive flags,
fatal error; every retag would have failed on CI. Now `--multi-arch
all`.
2. The same digest was retagged twice when an image appeared in two
value shapes; the ref set is now deduped on the canonical `repo@digest`.
**Other local checks:** `go test ./pkg/version` + `go build`/`vet`;
`helm template` renders `COZYSTACK_VERSION` in all 3 operator variants
(omitted when unset); `make manifests` stamps the version into the
install assets; `shellcheck` clean on `promote-retag.sh`; `actionlint`
clean and `act -l` resolves the job graph on all workflows; the rc-tag
parser and nightly version-math unit-tested (accept/reject +
minor-vs-patch bumps).
**Still CI-only (cannot be exercised offline):** the full workflow
runtime — github-script API calls, OCIR auth, the rc-release /
staging-branch preconditions, the nightly `base_ref` push mechanics, and
self-hosted runners. The *logic* inside the steps is unit-tested; the
*orchestration* is not. Treat it as unproven until the workflows run.
## Out of scope / required follow-up (needs repo admin — not doable from
a PR)
- Tag-protection rules: `v*` and `api/apps/v1alpha1/*` create-only by
the CI app, no delete/update; "limit branches/tags updated in a single
push" = 1.
- Enable `NIGHTLY_ENABLED` (and `RETENTION_APPLY` when ready) repo
variables.
- Registry-side pruning of `*-nightly.*` image tags (no OCIR delete
parity yet — tracked TODO in `retention.yaml`).
## Release note
```release-note
NONE
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added nightly publishing (mirror-by-digest, disk build, e2e
validation) and nightly retention pruning.
* Added RC-to-stable promotion via digest retagging (no rebuild).
* Operator/installer and console now expose version/platform metadata,
including runtime override via `COZYSTACK_VERSION`.
* **Bug Fixes**
* Enforced write-once tag behavior and fast-forward-only maintenance
updates across release workflows.
* Made nightly mirroring/selection and retention pruning more selective
and safer.
* **Tests**
* Added Bats coverage for nightly mirroring and RC retagging, plus new
Helm/unit checks.
* **Documentation**
* Updated the release model to center RC promotion and tag immutability.
<!-- 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
### 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. -->
### 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
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for the newer etcd snapshot workflow
(BackupClass/BackupJob/RestoreJob) with in-place restore.
* Updated etcd chart/operator packaging and migration flow for the
v1alpha2 operator release.
* **Bug Fixes**
* Switched readiness/condition handling to **Available**.
* Improved waits to cover all etcd member pods during restore and
scenario steps.
* **Documentation**
* Refreshed etcd backup/restore docs and example scenarios to match the
v1alpha2 APIs and parameters.
* **Tests**
* Updated e2e coverage to validate v1alpha2 operator runtime contracts
and replaced legacy backup-block tests with a snapshot round-trip test.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Address non-blocking review feedback from lexfrei on
packages/system/dashboard/Makefile:34.
When COZYSTACK_VERSION is empty (a local build with no matching git tag) the
build-arg baked a bare `APP_VERSION=v` into the console bundle. Match the guard
the root Makefile already uses for platformVersion so an empty version yields an
empty APP_VERSION instead of "v". Dev-only cosmetics — the config.json path
overrides the displayed version at deploy time — but it removes the mismatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
- nightly.yaml: pass the registry tokens to `skopeo login` over stdin
(--password-stdin) instead of `-p` on argv, matching promote-rc/finalize and
keeping the token out of the process list.
- dashboard configmap: require digits-then-dot (^v?[0-9]+\.) when deriving the
displayed platform version from the console image tag. The old ^v?[0-9] let a
tagless, port-carrying ref (ghcr.io:443/.../console, whose last ":"-segment is
"443/.../console") pass as a version. Verified: v1.5.0 / 1.5.0 still surface;
the port ref and :latest no longer do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The console config ConfigMap stripped the leading v (trimPrefix "v") while
the operator's COZYSTACK_VERSION and the vX.Y.Z release tags keep it, so the
dashboard displayed 1.4.0 where the operator reported v1.4.0. Keep the tag
verbatim, and build the baked VITE_APP_VERSION fallback as v$(COZYSTACK_
VERSION) too so the deploy-time and build-time version strings stay
consistent with each other and with the operator.
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>
The COZYSTACK_VERSION env test pinned talos and hosted but not generic,
leaving the generic branch of the endpoint-env interleave (KUBERNETES_
SERVICE_HOST/PORT from cozystack.apiServerHost/Port) unguarded. Add a
generic + platformVersion case so a refactor can't silently drop
COZYSTACK_VERSION or mis-render the generic endpoint block.
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>
Address review feedback from gemini-code-assist on packages/system/etcd-operator/templates/deployment.yaml: add an explicit seccompProfile (RuntimeDefault) to the controller-manager pod securityContext.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
The claim mapping switched to email in de0299f33, but two header
comments still described the earlier design:
oidc-authn-config.yaml:15 — "Username claim is preferred_username"
oidc-rbac-job.yaml:28 — "matched against preferred_username"
The rendered config (oidc-authn-config.yaml:49) maps
username.claim: email, the CRB subject is rendered from .email
(oidc-rbac-job.yaml:264), and the email-claim contract is pinned by
tests/oidc_test.yaml:260-261. Only the comments were stale; no code
change.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Gemini flagged that the `values.yaml` @field docstring on
`oidc.users[].email` still described the claim mapping as
`preferred_username`, misleading operators after the claim was
switched to `email` for real (see the earlier fix and its
end-to-end verification on dev3). Rewrite the description to
match the actual mechanic: `email` claim, requested via
`--oidc-extra-scope=email` in the chart-generated kubeconfig,
emitted by every conformant OIDC provider.
`make generate` re-propagates the update through README.md,
types.go and the cozyrds embedded schema.
Note on the other finding in the same review — Gemini also
flagged `audiences` as being incorrectly nested under `issuer`
in the AuthenticationConfiguration Secret. That is a
false positive: per the kube-apiserver v1beta1 spec
(apiserver.config.k8s.io/v1beta1, `JWTAuthenticator.Issuer`),
`audiences` IS a child of `issuer`, not a sibling of `jwt[i]`.
The current shape is correct and verified end-to-end on dev3
(alice/bob tokens carry `aud: [tenant-test-kubernetes-test3,
kubernetes]` and apiserver validates them successfully with
CRB-driven RBAC).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Four findings from the gemini re-review:
- `.Values.oidc.customConfig.secretRef.name` and
`.Values.oidc.customConfig.config` — replaced direct chained
access with `dig "customConfig" "…" "" .Values.oidc` so a CR that
explicitly nulls `customConfig` doesn't nil-panic. `.Values.oidc`
itself has a kubebuilder default of `{}` so it stays reachable.
- `.Values.controlPlane.apiServer.extraArgs` in the collision guard
— wrapped with `| default list` so an explicit null renders as an
empty range rather than a nil deref. (`dig` on `.Values` itself
doesn't work because it's `chartutil.Values`, not
`map[string]interface{}`.)
- `base64 -w 0` in the OIDC kubeconfig patch — replaced with
`base64 | tr -d '\n'`. Both work on the current alpine/k8s image
(BusyBox 1.37 supports `-w`), but the pipe form is portable back
to older BusyBox releases and to macOS/BSD base64 (which does not
know `-w`).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Same class of upgrade regression: a customer who wired their own
structured AuthenticationConfiguration by hand via
`controlPlane.apiServer.extraArgs: [--authentication-config=...]`
would hit a duplicate-flag scenario the moment they toggle
`spec.oidc.mode` — kube-apiserver would receive two conflicting
--authentication-config= arguments and the last-wins behaviour is
undefined between apiserver versions.
Add a second render-time guard that fails when a legacy
--authentication-config= is present in the user-supplied extraArgs
AND spec.oidc.mode is not None, pointing at the CustomConfig
migration path (customConfig.config or customConfig.secretRef.name).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Reviewer flagged a real customer-upgrade regression: the previous
values.yaml docstring on `controlPlane.apiServer.extraArgs` documented
`--oidc-issuer-url`/`--oidc-client-id` as a valid usage. An existing
customer who wired OIDC by hand through that passthrough (following
the doc) would see their tenant kube-apiserver BOOT-FAIL the moment
they toggle `spec.oidc.mode` to `System` or `CustomConfig`:
kube-apiserver refuses to boot with both `--authentication-config`
(injected by the new spec.oidc) and any legacy `--oidc-*` flag —
`--authentication-config cannot be used with any of --oidc-*`.
Add a render-time guard: when `spec.oidc.mode != None`, scan the
user-supplied `controlPlane.apiServer.extraArgs` for any flag with
prefix `--oidc-` and fail with a clear message pointing at the
`spec.oidc.mode: CustomConfig` migration path (they can carry the
same issuer/audience inside `customConfig.config`).
When `spec.oidc.mode: None` the passthrough still works — customers
who manage OIDC entirely by hand keep working until they opt into
the new selector explicitly.
Also update the values.yaml docstring on `extraArgs` to steer OIDC
users to `spec.oidc.mode` and warn against the collision.
Two new helm-unittest cases: the collision fails, and mode=None with
legacy flags renders normally (backward compat).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Two follow-ups from the final adversarial review:
- The chart-generated OIDC kubeconfig exec block requested only
`--oidc-extra-scope=groups`. On a Keycloak realm where `email` is
not a default client scope, kubectl-oidc-login would fetch a token
that lacks the `email` claim — apiserver would then fail to
identify the user, causing a silent auth failure. Add
`--oidc-extra-scope=email` explicitly so the flow does not depend
on realm-level default-scope hygiene.
- `docs/oidc-tenant.md` still said the `User:` subject "must match
the `preferred_username` claim emitted by the issuer" — obsolete
after the claim switch. Rewrite to say `email`, and add a note
that the chart requests the `email` scope explicitly.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Real bug caught by an end-to-end OIDC login test on dev3: apiserver
accepts the JWT (auth succeeds) but the RBAC check fails with 403
because the CRB `User:` subject was `alice-oidc@test3.example` (from
`users[].email`) but the apiserver-visible identity was `alice-oidc`
(from `preferred_username`, which in the cozy realm is the Keycloak
*username*, not the email).
The design proposal example used `claim: preferred_username`, but
the operator-facing `users[].email` field explicitly asks for the
email address. In the cozy realm — verified — `preferred_username`
is the Keycloak username (`kvaps`, `i.okhotnikov`), so the two do
NOT coincide. Every user whose Keycloak username differs from their
email would silently 403 after a successful OIDC handshake.
Switch the AuthenticationConfiguration `claimMappings.username.claim`
to `email`, aligning the apiserver-visible identity with the
operator-declared `users[].email`. Every conformant OIDC provider
emits `email` — cozy realm does (verified), and any BYO
`CustomConfig` issuer that follows OIDC does too.
The switch is invisible to installers where `preferred_username`
already equals the email (a common Keycloak setup); for installers
where it doesn't, this fix is required for RBAC to work at all.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Caught by dev3 Job runtime: `kubectl patch --type=merge` fetches the
current object before computing the merged state, so the Role needs
`get` in addition to `patch` on the specific Secret name. Without it
the Job fails with `secrets "..." is forbidden: ... cannot get
resource "secrets"` and the OIDC kubeconfig stays on its placeholder.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Tighten the release Role — the `delete` verb on `keycloakclients` and
`keycloakclientscopes` now carries `resourceNames` naming exactly the
per-cluster client and its audience scope. Without the scope, the SA
could delete any Keycloak object in the tenant namespace.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Follow-up to the KCP cleanup extension: Kamaji's SSA field manager
takes ownership of `spec.apiServer.extraArgs` and
`spec.apiServer.extraVolumeMounts` too, not just
`spec.deployment.extraVolumes`. The previous normalize patch only
replaced extraVolumes; the two remaining fields still carried the
`--authentication-config=` flag and the corresponding mount even
after mode was toggled to None.
Extend the merge patch to REPLACE all three fields at once, sourced
from Helm values so user-supplied entries in
`controlPlane.apiServer.extra{Args,VolumeMounts,Volumes}` remain
intact — only the chart-owned OIDC-only entries drop off.
Verified on dev3: with the extended patch, mode=System → mode=None
leaves the tenant KCP with `extraArgs: []`,
`extraVolumeMounts: []`, and `deployment.extraVolumes` back to just
`talos-ca`, `talos-tls-cert` (or those + operator-supplied extras).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Follow-up to the KCP normalize commit: Helm-controller's SSA-based
apply also does not delete namespaced Secrets and cozy-realm
Keycloak resources when the new manifest omits them — the resources
stay as ghost owner-labeled-by-Helm objects. The dashboard picks
them up (via the cozyrds `secrets.include` list) and shows the
placeholder kubeconfig even for clusters where OIDC was explicitly
turned off.
Extend the `mode=None` normalize path to also `kubectl delete`:
- `<release>-oidc-kubeconfig` Secret
- `<release>-oidc-authn-config` Secret
- `<namespace>-<release>` KeycloakClient in the cozy realm
- `<namespace>-<release>-audience` KeycloakClientScope
The delete verbs are added to the release-owned Role scoped to the
specific resourceNames (Secrets) and the API group (Keycloak — the
EDP operator does not support resourceNames on cluster-scoped-ish
CRDs). No new privileges beyond what the chart itself would need to
render these objects.
Together with the previous KCP-normalize commit, `mode=System →
mode=None` now leaves ZERO chart-owned OIDC leftovers.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Kamaji's controller takes SSA field-ownership on
`KamajiControlPlane.spec.deployment.extraVolumes` when it syncs the
resource back from the TenantControlPlane, and Helm's subsequent
apply (with the same field-manager but a manifest that omits the
OIDC volume) then no longer removes it — the tenant apiserver Pod
keeps an unmounted-but-declared `authentication-config` volume
pointing at a Secret the chart already reaped. Harmless at runtime
(the volume is not mounted after the extraVolumeMounts fix), but the
CRD gets dirty and the operator wants zero leftovers.
Fix: the OIDC bootstrap Job is now rendered for ALL modes (not only
`System`/`CustomConfig`). When `mode=None` the Job takes the
normalize path — a single `kubectl patch --type=merge` that replaces
`spec.deployment.extraVolumes` with the chart baseline (talos-ca +
talos-tls-cert). We verified end-to-end that `kubectl patch
--type=merge` on this array REPLACES it deterministically (the
earlier Helm patch didn't).
For `mode=System`/`CustomConfig` the Job continues to apply
per-user ClusterRoleBindings inside the tenant cluster, prune stale
ones labelled by this release, and patch the pre-rendered
`<release>-oidc-kubeconfig` Secret with a real kubeconfig.
The Role gains `patch` on the specific `kamajicontrolplanes/<release>`
so the normalize path has just enough privilege; nothing else new.
The pre-delete cleanup Job stays conditional on `mode != None` — no
CRBs to clean up when OIDC was never enabled.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Two real bugs caught by the dev3 e2e test — both would have shipped
without live-cluster validation:
- `rm --force` in the bootstrap and cleanup Job scripts is rejected
by BusyBox `rm` (the alpine/k8s image uses BusyBox coreutils, not
GNU). The bootstrap Job on dev3 died at the prune step with
`rm: unrecognized option: force`. Switch to `rm -f` (works on
both GNU and BusyBox). Also swap
`grep --line-regexp --quiet --fixed-strings` for `grep -x -q -F`
for the same reason.
- Kamaji's `KamajiControlPlane` is a CRD, and Helm applies
JSON-merge-patch to CRDs. That patch does not remove array-typed
fields the new manifest simply omits — it leaves the live array
untouched. Toggling `oidc.mode: System` → `None` therefore left
the tenant kube-apiserver with a dangling `--authentication-config`
flag pointing at a Secret the chart had already reaped, and the
apiserver would refuse to start on the next kubelet restart.
Fix: always render `extraArgs` and `extraVolumeMounts` on the
KamajiControlPlane, emitting `[]` on the off-path so the patch
REPLACES the stale array. Verified end-to-end on dev3.
Adds two helm-unittest cases: the mode=None regression, and an
adjustment of the baseline default-values test.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Three items from the gemini-code-assist and coderabbitai passes that
survived the Phase-1 redesign:
- `oidc-rbac-job.yaml` (gemini HIGH): pre-delete cleanup uses
`kubectl --request-timeout=30s` in addition to `--timeout=30s`
on the delete verb, so an unreachable tenant apiserver cannot
stall the pre-delete hook on the HTTP round-trip itself. The
`|| echo WARNING` fallback still keeps a truly unreachable
cluster from blocking `helm uninstall`.
- `oidc-keycloak.yaml` + `_helpers.tpl` (gemini LOW): switch
`_cluster.{oidc-enabled,root-host}` reads to `dig` with an empty
map default so a missing `_cluster` key nil-safely emits an
empty string and hits the platform-OIDC guard instead of a
template panic on `.Values._cluster`.
- `hack/e2e-apps/kubernetes-oidc-{system,customconfig}.bats`
(coderabbit MAJOR): add an existence-poll `timeout ... until
kubectl get secret ... ; do sleep ...; done` before the
`kubectl wait secret --for=jsonpath` line. Without it a runner
that reaches the wait before the HR has rendered the Secret
gets an immediate `NotFound` and the test flakes.
Adds one helm-unittest case covering the nil-safe `_cluster` path.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Self-review fix caught by deploying to dev3: the EDP Keycloak operator
CRD v1 does not declare `standardFlow` (the canonical field is
`standardFlowEnabled`, default true), and `webUrl` is meaningful only
for clients with a real web UI (not for the kubectl oidc-login flow
which is keyed only by `redirectUris`).
Both lines violated the CRD's openAPIV3Schema and Helm refused the
install with `field not declared in schema`. Dropping the empty
`webUrl` and relying on `standardFlowEnabled`'s default (true) keeps
the manifest minimal and CRD-clean. helm-unittest did not catch this
because it does not validate templates against live CRD schemas.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Self-review fix: the Role granted `["get", "patch"]` on the OIDC
kubeconfig Secret, but the bootstrap Job script never issues `kubectl
get` against the management apiserver — it only `kubectl patch`es the
pre-rendered Secret. The tenant-side `kubectl get clusterrolebindings`
uses the mounted admin kubeconfig, which is a separate trust path.
Tightens the SA's permission surface to exactly what the script uses.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Self-review fix: the KeycloakClientScope rendered for the per-cluster
audience used `included.custom.audience` (raw string), but the
project convention — set in
`packages/system/keycloak-configure/templates/configure-kk.yaml` for
the legacy global `kubernetes-client` scope — is
`included.client.audience` (refers to a clientId in the same realm).
Both keys make Keycloak emit the audience claim with the same value
in our setup, but the `client.audience` form is the idiomatic one
when the audience IS a registered client in the realm.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Self-review fix: the design proposal specifies
`name: authentication-config` and
`mountPath: /etc/kubernetes/authentication-config`, but the previous
revision had renamed both to `cozy-oidc-authn-config` and
`/etc/kubernetes/cozy-oidc` to dodge a collision with the volume
name used in `apiserver_authentication_test.yaml`. That test was
demonstrating the GENERIC user-supplied `extra*` passthrough — not
the OIDC use case — so rename the test fixture instead and restore
the design-spec names on the chart side.
The reservation guard against user-supplied volumes reusing
`authentication-config` only fires when `spec.oidc.mode` is not
None, so clusters with OIDC off retain the previous freedom to name
their own volumes anything.
Renames in `apiserver_authentication_test.yaml`: the user-supplied
volume name `authentication-config` becomes `tenant-byo-mount`; the
Secret name `tenant-authentication-config` and the apiserver flag
`--authentication-config=` are untouched.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Self-review fixes for four linked lifecycle issues:
- The post-install Job created `<release>-oidc-kubeconfig` via
`kubectl apply` from inside the script; the resulting Secret had
no Helm release labels and was orphaned on `helm uninstall`. Now
a release-owned placeholder Secret is rendered up front in
`oidc-kubeconfig-secret.yaml` and the Job patches its `data`
field at runtime (the tenant CA still has to come from the
Kamaji-issued admin kubeconfig, which appears asynchronously).
- The ServiceAccount/Role/RoleBinding for the bootstrap Job were
hook resources with `helm.sh/hook-delete-policy: before-hook-creation`,
so they leaked on `helm uninstall`. They are now regular release
resources (no hook annotations) and are reaped by `helm uninstall`
alongside everything else. Both the post-install bootstrap Job
and the pre-delete cleanup Job find a working SA.
- The previous design tried to add `hook-succeeded` to the SA, but
that would delete the SA right after the bootstrap hook
succeeded — leaving the pre-delete cleanup Job with no SA to
schedule. Release-owned resources sidestep this race entirely.
- The Role had `verbs: ["create"]` on `secrets` without
`resourceNames` (Kubernetes RBAC cannot scope `create` by name).
With the placeholder Secret pre-rendered the Job only needs
`get`/`patch` on the specific name — the broad `create` rule is
removed.
Also tightens the script: `set -euo pipefail` (was `set -eu`, which
silently swallowed `kubectl get` failures inside command
substitutions) and uses `kubectl patch --type=merge` against the
existing Secret instead of `kubectl create --dry-run | kubectl apply`.
Adds three helm-unittest cases for the placeholder Secret across all
three modes.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Add the operator-facing guide for the new selector — what the three
modes mean, what the chart provisions, how a user logs in, what the
failure modes are, and what is intentionally out of scope (per-tenant
realms, federation, cross-cluster SSO). The architectural rationale
points back to cozystack/community#24.
Update the chart NOTES.txt to print the OIDC kubeconfig retrieval
snippet, the per-cluster client/issuer values, and the listed
`users[]` roster — only when OIDC is on, otherwise the previous
admin-kubeconfig hint is unchanged.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Cover the three branches of the new selector API:
- `mode: None` (default) — every OIDC template is empty, the
KamajiControlPlane has no extra args and the standard two talos
volumes, the bootstrap Job is not rendered.
- `mode: System` — per-cluster `KeycloakClient` + audience scope
in the `cozy` realm, namespace-prefixed clientId, PKCE attribute
on the client, soft-render when the EDP Keycloak operator CRDs
are absent, the AuthenticationConfiguration Secret pinned to the
cozy issuer, the chart-owned `--authentication-config` flag and
`cozy-oidc-authn-config` mount injected into the KCP, the
bootstrap Job carrying `policy.cozystack.io/allow-to-apiserver`,
and one ClusterRoleBinding per `users[]` entry with the right
ClusterRole (`cluster-admin` vs `view`).
- `mode: CustomConfig` — inline config written verbatim; secretRef
path mounted on the KCP without a chart-owned Secret; no
Keycloak objects emitted; mutual-exclusion and missing-source
guards both hard-fail.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Add `kubernetes-<name>-oidc-kubeconfig` to the Kubernetes
ApplicationDefinition's `spec.secrets.include[].resourceNames` so
the dashboard surfaces the OIDC kubeconfig to tenant viewers
alongside the existing admin kubeconfig.
The Secret is produced by the post-install,post-upgrade bootstrap
Job in `apps/kubernetes` when `spec.oidc.mode: System` is set.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Add a post-install,post-upgrade Helm-hook Job that reconciles the
operator-supplied `users[]` list into the tenant cluster:
- For every entry, apply a ClusterRoleBinding labelled by the
release (managed-by=cozystack-oidc, instance=<release>).
`role: admin` maps to `cluster-admin`; `role: view` maps to
`view`. The subject is the literal `email` value, matched
against the issuer's `preferred_username` claim.
- Prune any orphan CRBs with the same labels — removing a user
from `users[]` revokes their kubectl access on the next loop.
For `mode: System` the same Job also writes
`<release>-oidc-kubeconfig` on the management cluster: a ready
kubeconfig with the per-cluster apiserver endpoint, the tenant CA
extracted from the Kamaji-issued admin kubeconfig, and a
`kubectl oidc-login` exec block keyed to the per-cluster client.
A pre-delete cleanup Job removes the labelled CRBs before the
control plane is torn down (best-effort: the CRBs disappear with
the tenant cluster anyway).
Pod is labelled `policy.cozystack.io/allow-to-apiserver: "true"`
so Cilium lets it reach the konnectivity tunnel.
ServiceAccount/Role/RoleBinding are minimum-privilege — only the
specific admin-kubeconfig and OIDC kubeconfig Secrets are listed.
Renders nothing for `mode: None`.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Render a per-release Secret carrying the structured kube-apiserver
`AuthenticationConfiguration` (apiserver.config.k8s.io/v1beta1) and
mount it into the KamajiControlPlane.
For `mode: System` the Secret is chart-owned: one JWT entry pointing
at the `cozy` realm issuer with the per-cluster audience, plus the
standard `preferred_username`/`groups` claim mappings. For `mode:
CustomConfig` the chart either writes the operator-supplied inline
config into a Secret, or — when `secretRef.name` is set — mounts an
existing operator-owned Secret directly (mutually exclusive; the
chart fails the render if neither or both are set).
The `--authentication-config=` flag, mount, and volume on the
KamajiControlPlane are kept orthogonal to the user-supplied
`controlPlane.apiServer.extra*` knobs: the OIDC additions are
appended, and the chart-managed volume name `cozy-oidc-authn-config`
is reserved (only when `mode` is not None) so user-supplied volumes
cannot collide.
Switches from the legacy `--oidc-*` flag form to the file form so
multiple issuers, inline private-CA PEM, and future Phase-2 issuers
extend the same Secret without rewiring the chart.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
For `oidc.mode: System`, emit a per-cluster public `KeycloakClient`
plus a per-cluster `KeycloakClientScope` inside the platform `cozy`
realm. The scope carries an `oidc-audience-mapper` that pins
`id_token.aud` to the per-cluster clientId, so a token minted for
cluster A is rejected by cluster B's apiserver.
The clientId is namespace-prefixed (`<ns>-<release>`) to stay unique
across the realm — two clusters with the same name in different
tenants would otherwise collide.
The client is `public: true`, redirects locked to localhost:8000 /
localhost:18000 (the kubelogin/oidc-login defaults), with PKCE
required via the standard Keycloak attribute. `defaultClientScopes`
includes both `groups` and the per-cluster audience scope.
A render-time guard fails fast when `oidc.mode: System` is set but
`_cluster.oidc-enabled` is not `"true"` — the Keycloak operator CRDs
are otherwise absent and the chart would silently no-op.
Nothing is rendered for `mode: CustomConfig` or `mode: None`.
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
Introduce a flat selector — `mode: System | CustomConfig | None` —
plus a `users[]` list and a `customConfig` block for tenant-supplied
AuthenticationConfiguration. Default `None` preserves today's
behavior.
The `System` mode trusts the platform `cozy` realm via a per-cluster
public client; `CustomConfig` accepts an inline `AuthenticationConfiguration`
or a Secret reference (mutually exclusive). The `users[]` list drives
per-user ClusterRoleBindings inside the tenant cluster, with two
roles: `admin` and `view`.
Regenerates deepcopy, JSON schema, README, and the kubernetes-rd
cozyrds embedded schema.
Implements Phase 1 API of the per-tenant OIDC design proposal
(cozystack/community#24).
Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
The console bakes VITE_APP_VERSION into its bundle at build time, so a
promote-by-retag image (which is retagged, never rebuilt) would keep
reporting the rc version in the UI after promotion to stable.
Inject the version at deploy time instead: the chart derives it from the
console image tag (build-stamped vX.Y.Z, rewritten rc->stable on promotion
by promote-rc.yaml) and writes it into the existing console config.json
ConfigMap, which the SPA already fetches. App.tsx now prefers
config.version and falls back to the baked VITE_APP_VERSION when absent
(dev / non-version tags like :latest).
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>
The COZYSTACK_VERSION env is what lets a release-candidate image be promoted to
stable by retagging alone, and it is interleaved with the per-variant endpoint
env under one conditional. Add a helm-unittest (and the package test target so
`make unit-tests` runs it) pinning both axes: talos/hosted x platformVersion
set/unset, asserting COZYSTACK_VERSION presence and the variant endpoint block.
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The operator's platform version was baked into the image at build time
(`-ldflags -X pkg/version.Version` from `git describe`), so a
`vX.Y.Z-rc.N` image literally carried the rc string. That blocks promoting
a release-candidate to stable by retag: the bits would still self-report
the rc version.
Resolve the version from the COZYSTACK_VERSION env var at runtime, falling
back to the build-time value when unset (local builds still work). The
installer chart threads `cozystackOperator.platformVersion` into the
operator Deployment as that env var, and `make manifests` stamps it into
the kubectl-apply release artifacts. The same image bits can now report any
release name, so promotion needs no rebuild.
The only behavioral reader is the telemetry metric
`cozy_cluster_info{cozystack_version=...}`; cozypkg/check-readiness keep
their own build-time Version and are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Reverts #3006. Dropping persistent EFI/TPM was motivated by a live-migration
concern that rested on an outdated assumption — that an RWO Filesystem
backend-storage PVC pins the VM to its node. KubeVirt has migrated
RWO-Filesystem backend storage since v1.4 (kubevirt/kubevirt#12629): on
migration it creates a fresh target state PVC and copies the small state blob,
so persistent-EFI/TPM VMs on the default replicated storage live-migrate fine
(verified: the VM reports LiveMigratable=True and the copy-on-target PVC is
created on migration). Restore upstream's persistent firmware state for the
windows.* preferences.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
## What this PR does
The Gateway (`cozystack-gateway-hostname-policy`) and Route
(`cozystack-route-hostname-policy`) admission policies constrain tenant
hostnames to the tenant apex on the Gateway API path. Gateway API is
opt-in and off by default, so in the default configuration tenant
applications publish through a legacy `networking.k8s.io/Ingress` on the
shared ingress-nginx, which had no equivalent policy.
This adds `cozystack-ingress-hostname-policy`, a fail-closed
`ValidatingAdmissionPolicy` + binding for `Ingress` in `tenant-*`
namespaces — the legacy-Ingress counterpart of the existing
Gateway/Route hostname policies, and a new Layer 8 in the gateway
security model. A hostname on `spec.rules[].host` or
`spec.tls[].hosts[]` is allowed when it is within the namespace's own
apex (`namespace.cozystack.io/host`), or when it is a concrete
(non-wildcard) name entirely outside the platform root apex
(`_cluster.root-host`). The second case keeps the `kubernetes` app's
Proxied `exposeMethod` working, since it routes a user-supplied external
domain to a nested cluster. A hostname under the platform root apex but
outside the namespace's own apex, a wildcard taken through the
outside-root path (which could otherwise match the platform apex
itself), a rule with no host (absent or empty — an unbounded catch-all),
and `spec.defaultBackend` are rejected.
The policy is fail-closed: a `tenant-*` namespace missing its own apex
label is denied, and the policy renders only when `_cluster.root-host`
is set, so it is never shipped in a state that would admit every
hostname. `cozy-*` namespaces are cluster-admin-managed and not gated.
This is defense-in-depth in the same family as the Gateway/Route
policies — by design tenants hold only read access to `Ingress`.
The change ships helm-unittest coverage (render, plus the root-host,
empty-host, and wildcard guards) and behavioral admission tests against
the live apiserver in `hack/e2e-apps/gateway.bats` (in-apex allow,
external-domain allow, under-root-outside-apex deny,
wildcard-matching-apex deny, hostless deny, empty-string-host deny,
defaultBackend deny), and documents the new layer in
`packages/extra/gateway/README.md`.
### Screenshots
N/A — no UI change.
### Release note
```release-note
fix(cozystack-basics): constrain legacy Ingress hostnames in tenant namespaces to the tenant apex
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added **fail-closed** defense-in-depth Layer 8
(`cozystack-ingress-hostname-policy`) to enforce allowed legacy Ingress
rule and TLS hostnames for tenant namespaces, while blocking unsafe
catch-all configurations (including missing/empty hosts and
`spec.defaultBackend`) when the platform root host is configured.
* **Tests**
* Added end-to-end and render/deny validation for permitted vs denied
hostname patterns, wildcard behavior, required namespace host labeling,
and correct deny/fail-closed rendering.
* **Documentation**
* Updated the gateway security model to document Layer 8 behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
The VPA admission-controller webhook TLS Secret is currently rendered by
an in-chart `genCA` + `genSignedCert` template. Those functions are not
idempotent across `helm template` invocations — every Flux HelmRelease
reconcile produces a fresh CA + cert. A `checksum/tls-secret`
podAnnotation on the admission-controller Deployment then flips its
sha, materialising a new ReplicaSet on every reconcile.
Under load — the E2E install phase where cert-manager / CDI / LINSTOR
cascade races repeatedly refire the VPA HelmRelease — this produces
10+ admission-controller ReplicaSets in minutes and blocks the whole
cozystack-platform HR: the admission-controller pod livelocks in
startup (waits on its own election lease which its previous
ReplicaSet is still holding, hits livenessProbe timeout,
CrashLoopBackOffs), VPA never becomes Ready, PackageSources stay
InProgress, and cozystack-platform aborts at its 15-minute install
timeout.
Fix: replace the source of the TLS Secret with a namespace-scoped
cert-manager `Issuer` + `Certificate`. cert-manager only rewrites the
Secret when the certificate is close to expiry (`renewBefore`), so
reconcile-driven cert churn stops. Pod rotation is delegated to a
runtime-lookup-based `checksum/tls-cert` podAnnotation that only
changes when the Secret's `tls.crt` content actually changes — not on
every render, not on every reconcile.
## What
-
`charts/vertical-pod-autoscaler/templates/admission-controller/certificate.yaml`
(new): namespace-scoped selfSigned Issuer + Certificate. The
Certificate mirrors the legacy `genSignedCert` shape (single DNS SAN
`vpa-webhook.<ns>.svc`, 365d validity, matching Secret name), so
cert-manager on the first upgrade adopts the same Secret name the
legacy path wrote — no rename cascade in the Deployment volume mount.
Rendered only when `admissionController.tls.certManagerEnabled=true`
AND no `existingSecret` is supplied.
-
`charts/vertical-pod-autoscaler/templates/admission-controller/tls-secret.yaml`
(modified): wrapped in an extra `certManagerEnabled=false` guard so
the legacy genCA path remains available as an explicit opt-out.
-
`charts/vertical-pod-autoscaler/templates/admission-controller/deployment.yaml`
(modified): the legacy `checksum/tls-secret` annotation is gated
behind `certManagerEnabled=false`. Under the cert-manager path the
Deployment carries a `checksum/tls-cert` annotation computed from
`lookup`-ing the runtime Secret's `tls.crt` — see the template
comment for the full timing analysis and the day-350 renewal
trade-off it documents.
- `charts/vertical-pod-autoscaler/values.yaml` (modified): new
`admissionController.tls.certManagerEnabled` value defaulting to
`true`. Values-block header documents the switch as effectively
one-way (cert-manager writes `type: kubernetes.io/tls`;
`Secret.type` is immutable, so falling back requires `kubectl
delete` first).
- `patches/cert-manager-admission-tls.diff` (new) +
`Makefile update` extended: the four subchart edits above live as a
single reviewable diff that Makefile re-applies over the freshly
vendored chart on every `make update`, matching the fluxcd-operator
package convention. Prevents a bump from silently reverting the
fix.
- `packages/core/platform/sources/vertical-pod-autoscaler.yaml`
(modified): VPA PackageSource now depends on `cozystack.cert-manager`.
Non-circular: cert-manager depends on `cozystack.networking` +
`cozystack.gateway-api-crds`, neither reaches VPA.
- `tests/vpa_test.yaml` (modified): +11 helm-unittest cases (15 total,
4 pre-existing) covering `certManagerEnabled={true,false}`,
`existingSecret` bypass, disabled admission-controller, and
the offline-render behaviour of the `checksum/tls-cert` lookup.
## Why (evidence)
Snapshot from CI run 28587656430 (this branch, pre-workaround-review):
```
admission-controller ReplicaSets after first-install churn window:
vertical-pod-autoscaler-admission-controller-55d888d6d4
vertical-pod-autoscaler-admission-controller-56f8d4c747
vertical-pod-autoscaler-admission-controller-5775dbc9b7
vertical-pod-autoscaler-admission-controller-585b7c56c9
vertical-pod-autoscaler-admission-controller-6fc44944fb
vertical-pod-autoscaler-admission-controller-74bccf4849
vertical-pod-autoscaler-admission-controller-84df98f98b
vertical-pod-autoscaler-admission-controller-8666dffb85
vertical-pod-autoscaler-admission-controller-86b469457c
vertical-pod-autoscaler-admission-controller-9b979f9d6
→ 10 ReplicaSets, one per Flux reconcile, each with a different
genCA-rooted TLS Secret sha in checksum/tls-secret.
```
## Migration for existing clusters
On upgrade the legacy Opaque Secret disappears from the release
manifest, Helm garbage-collects it, and cert-manager immediately
writes a fresh `kubernetes.io/tls` Secret of the same name via the
new Certificate. `Secret.type` is immutable, so in-place adoption is
impossible — the delete/create pass is the correct path, and the
seconds-long window it produces is safe: the `vpa-webhook-config`
MutatingWebhookConfiguration ships with `failurePolicy: Ignore`
(verified on cozy-dev3), so Pod admissions in that window skip VPA
mutation but never get rejected — no cluster-wide disruption.
The Deployment rolls in the same upgrade because the new
`checksum/tls-cert` annotation differs from anything the pod had
before, so the admission-controller re-registers `vpa-webhook-config`
with the fresh caBundle on the same pass.
## Test plan
- [x] `helm unittest packages/system/vertical-pod-autoscaler` — 15/15
pass.
- [x] `helm template` diff between main and this branch is scoped
exactly to the cert-manager path (new Certificate + Issuer,
new `checksum/tls-cert` annotation, guarded legacy Secret, new
values key, patch file + Makefile step, PackageSource
dependsOn). No unrelated lines change.
- [x] `make update`-style simulation (rm -rf charts && helm pull &&
patch) produces a byte-identical subchart to what git tracks —
the patch stays clean across future upstream bumps until it
genuinely conflicts.
- [ ] Live migration on cozy-dev3: `helm upgrade` VPA release from
pre-fix version → observe cert-manager write the new tls Secret
→ verify `checksum/tls-cert` on the new pod's podTemplate →
verify MutatingWebhookConfiguration `vpa-webhook-config`
caBundle updated in place → confirm no ReplicaSet churn on
subsequent Flux reconciles. Blocking the draft's removal.
- [ ] Full E2E on this branch — verify only 1 admission-controller
ReplicaSet after install, VPA HR reaches Ready inside
`cozystack-platform`'s 15m install timeout, and
`kubernetes-latest.bats` / `kubernetes-previous.bats` no longer
time out on VPA-attributable resource churn.
## Review pass — kvaps' CHANGES_REQUESTED addressed
- ✅ Vendored chart mods now carried as `patches/*.diff` re-applied by
`Makefile update` (matches fluxcd-operator convention).
- ✅ Retired the tls-migrator hook — kvaps correctly showed it was
a no-op: Helm's delete decision is driven by the stored release
manifest not by live labels (`ResourcePolicyAnno` is the only opt-
out), and `Secret.type` is immutable so cert-manager could not have
adopted the legacy Opaque Secret in place anyway. ~200 lines
removed.
- ✅ PR description rewritten to match the actual diff (no more
reference to the removed `tls-rollout.yaml` post-upgrade hook, real
test count, dev3 verification honestly listed as pending).
- ✅ `checksum/tls-cert` timing comment corrected: `lookup` runs on
Helm actions only, day-350 renewal trade-off spelled out.
- ✅ `certManagerEnabled=false` documented as effectively one-way
(Secret.type immutable).
- ✅ Dropped the `secretTemplate` labels — `app.kubernetes.io/managed-
by: Helm` on a cert-manager-owned Secret was misleading.
- ✅ Banner comment indentation restored in `vpa_test.yaml`.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* VPA admission-controller webhook TLS now uses cert-manager by default,
with options to disable cert-manager or supply an existing TLS secret.
* Webhook pod restarts are now tied to cert-manager Secret contents via
a content-based checksum.
* **Bug Fixes**
* Improved webhook TLS provisioning reliability when TLS readiness is
delayed.
* Corrected conditional rendering/rollout behavior to avoid mismatched
TLS ownership.
* **Tests**
* Added rendering-invariant tests covering cert-manager, legacy TLS,
existing-secret bypass, and admission-controller disabled behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->