Commit Graph
4913 Commits
Author SHA1 Message Date
IvanHunters affe9beca5 docs(oidc-grafana): reflect users-map replacement of the role-attribute-path design
Rewrite `docs/oidc-grafana.md` for the new authorization model:

- Selector example carries a `users:` map.
- System-mode "what the chart provisions" list drops the three
  KeycloakRealmGroups + role_attribute_path, adds the audience
  scope-only KeycloakClient defaults, and adds the users-Job step
  with `skip_org_role_sync` + `oauth_allow_insecure_email_lookup`
  wiring notes.
- Fresh "Users and RBAC" section describes the users-Job reconcile
  contract: pre-provision -> add-to-org -> PATCH role -> prune.
  Explains the "user not in list -> gets nothing" semantics
  (replaces the default-Viewer fallback).
- CustomConfig section explicitly notes that `role_attribute_path`
  in the operator's payload is now dead config: the chart forces
  `skip_org_role_sync = true` regardless of mode.
- "Failure modes" gains "user logs in but sees no dashboards"
  (missing from `users:`) and a users-Job troubleshooting entry.
- Drop the `GrafanaAdmin` deferral notes and the "Mode toggle
  destroys chart-owned KeycloakRealmGroup membership" failure
  mode — both moot now that the groups are gone.
- Removed the stale `KeycloakRealmUser` example that used
  `tenant-acme-monitoring-admin` (the chart never rendered a
  group by that name — the inner release is `monitoring-system`
  and the example was a live footgun).

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
IvanHunters 123616a1d0 test(e2e): swap KeycloakRealmGroup assertions for the users-Job in bats
Aligns the render-side e2e bats with the users-map contract:

- Feed a `spec.oidc.users:` entry to each mode's Monitoring CR so
  the reconcile Job has real input to act on.
- Assert the users-Job appears in the release namespace with the
  desired `DESIRED_USERS_JSON` env and the expected `OIDC_MODE`.
- Assert the Grafana CR renders with `skip_org_role_sync = "true"`
  and `oauth_allow_insecure_email_lookup = "true"` and NO
  `role_attribute_path`.
- Flip the three KeycloakRealmGroup existence assertions to
  negative checks: chart-owned realm groups must NOT be present
  in either System or CustomConfig mode.

Does not add a full OIDC login flow through Keycloak — that stays
deferred to a follow-up integration suite, same posture as the
tenant kube-apiserver's kubernetes-oidc-system.bats.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
IvanHunters c493f2cb96 test(monitoring): rewrite OIDC unit tests around the users-map contract
Rewrites the helm-unittest coverage for the Phase-1 OIDC selector
against the new chart contract:

- Drop assertions for the three KeycloakRealmGroups and for
  `role_attribute_path` / `allow_assign_grafana_admin` / the
  `oidc.grafanaAdmin` values lever — none of those render anymore.
- Assert the System-mode Grafana config carries
  `skip_org_role_sync = "true"` and
  `oauth_allow_insecure_email_lookup = "true"` and NO
  `role_attribute_path`.
- Cover the new users-Job template: helm-hook annotations, absence
  of a ServiceAccount, `activeDeadlineSeconds`, admin-credentials
  wiring from the chart-managed `grafana-admin-password` Secret,
  and correct serialisation of `spec.oidc.users` into the
  `DESIRED_USERS_JSON` env.
- Assert the users-Job also renders under `mode: None` so a
  System -> None flip still gets a prune pass.
- Cover the defensive `.Values.oidc | default dict` paths — omitted
  block and explicit `oidc: ~` — across the three OIDC templates.
- Tighten the client-secret regex to the actual base64-encoded
  length (`^[A-Za-z0-9+/]{43}=$`) so shortened random secrets are
  caught.

32 tests, all green under helm-unittest v1.0.3.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
IvanHunters d4f59af624 chore(monitoring): sync PackageSource comment with removed Keycloak groups
Update the oidc-variant comment on
`packages/core/platform/sources/monitoring-application.yaml` so it
no longer claims `spec.oidc.mode: System` renders three
KeycloakRealmGroups. The dependsOn edge and the fail-close guard
in `templates/grafana/oidc-keycloak.yaml` stay unchanged: the
KeycloakClient + KeycloakClientScope path still needs the
`v1.edp.epam.com` CRDs from `cozystack.keycloak-operator` to be
registered before the monitoring chart reconciles.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
IvanHunters 0a65dd04a1 feat(monitoring): expose spec.oidc.users on the Monitoring CR
Extends the tenant-facing `Monitoring` CR with the new
`spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` field
consumed by the users-Job introduced in the previous commit. Works
for both `System` and `CustomConfig` modes; ignored when
`mode: None`.

Regenerated `packages/extra/monitoring/values.schema.json`,
`README.md`, and the mirrored `openAPISchema` on the
`monitoring-rd` ApplicationDefinition through the standard
`make generate` (cozyvalues-gen + hack/update-crd.sh) pipeline.
Also refreshed the stale annotation comment on the CR that still
claimed the chart provisions three KeycloakRealmGroups.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
IvanHunters 01d68904ea feat(monitoring): drop chart-owned Keycloak groups; app-side users-map + reconcile Job
Replace the group-based authorization model on the Phase-1 Grafana
OIDC selector with an app-side users map, mirroring the tenant
kube-apiserver sibling in cozystack/cozystack#3044.

What is removed
- The three chart-owned KeycloakRealmGroup objects in the cozy realm
  (`<clientId>-{admin,editor,viewer}`). A Helm release must not own
  directory objects — toggling `spec.oidc.mode: System -> None` or
  deleting the Monitoring release would Helm-delete the groups, and
  with them any membership curated out-of-band. Per-instance groups
  in the platform realm is exactly the design question left open at
  the end of cozystack/community#24; establishing it as precedent
  from a monitoring PR is out of scope.
- Dash-separated group naming (`<namespace>-<release>-<role>`) was
  also collision-prone against tenant/subtenant paths, since both
  the namespace and release names can themselves contain dashes.
- `role_attribute_path` in the Grafana `generic_oauth` block and the
  default-Viewer fallback, which handed org-Viewer to every cozy
  realm identity that logged in — cross-tenant metrics read across
  every opted-in Grafana. Also removed the `groups` client scope
  from the KeycloakClient defaults.
- `monitoring.oidc.allowAssignGrafanaAdmin` helper and the internal
  `oidc.grafanaAdmin` values key: the feature was already deferred
  and the helper's release-name-only guard was tenant-spoofable.

What replaces it
- `spec.oidc.users: [{email, role: Admin|Editor|Viewer}]` on the
  Monitoring chart values.
- A chart-owned post-install/post-upgrade Job
  (`templates/grafana/oidc-users-job.yaml`) that reconciles that
  list into Grafana's Main Org. via the admin API: pre-provisions
  a local Grafana account per listed email so the reconcile can
  set the org role before the operator's first OIDC login, adds
  the account to the org, PATCHes the role to converge across
  re-runs, and prunes every non-admin Main-Org member whose email
  is not in the list (handles both entry removal and `mode: None`).
- `skip_org_role_sync = true` + `oauth_allow_insecure_email_lookup
  = true` in the generic_oauth block. The former stops a login from
  overwriting the Job's assignments; the latter binds the OIDC
  identity to the pre-provisioned local account.
- Job talks to Grafana over the in-cluster Service and reads
  admin creds from the chart-managed `grafana-admin-password`
  Secret via `envFrom`: no ServiceAccount / Role / RoleBinding.
  `activeDeadlineSeconds: 900` caps a stuck Job so a broken Grafana
  cannot hold the release in `pending-upgrade`.

Defensive rendering
- `.Values.oidc | default dict` in the four templates that dereference
  the `oidc` block so `oidc: ~` or an omitted block no longer nil-derefs.
- `oidc-client-secret.yaml` falls back to a fresh random when the
  existing Secret lookup succeeds but `data.client-secret` is empty.

Rationale: cozystack/cozystack#3176 review from @lllamnyp
(architectural CHANGES_REQUESTED points 1, 2, 3 + the default-Viewer
leak). The three points stack: dropping the groups incidentally
fixes the naming-collision and the default-Viewer leak, and the
users-Job gives operators the same UX story as the tenant
kube-apiserver PR across both sides of the platform.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov aa63786acd docs(monitoring): sync oidc.grafanaAdmin values docstring with deferred-lever posture
Second-iteration cozy-review flagged this as a leftover from the
platform-lever removal: `packages/system/monitoring/values.yaml`
still described `grafanaAdmin` as "Set true for the platform Grafana
release only ... the platform bundle passes this flag through
Package.spec.components", which contradicts the deferred-lever
reality already documented in the same commit's updates to
`_helpers.tpl` and `docs/oidc-grafana.md`. Rewrite the docstring to
match: chart-internal lever, defaults `false`, Phase 1 has no
end-to-end wiring; kept as a values key so a future phase can add
the wiring without touching the chart contract.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov b46543e5f2 fix(monitoring): defer platform GrafanaAdmin lever to a future phase
The MAJOR finding from the cozy-review agent: the platform-side
`oidc.grafanaAdmin: true` documented in the PR body and in
docs/oidc-grafana.md never actually reached the platform Grafana's
HelmRelease. `Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin`
was set in packages/core/platform/templates/bundles/system.yaml, but
`monitoring-system` (and `monitoring`) have no `install:` block in
packages/core/platform/sources/monitoring-application.yaml — only
`monitoring-rd` does. PackageReconciler skips components without an
`install:` block before ever reaching the assignment that copies
`pkg.Spec.Components[].Values` onto `hr.Spec.Values`
(internal/operator/package_reconciler.go around lines 197-201 and
292-293). The values-override dictionary was silently dropped, no
HR carried `oidc.grafanaAdmin: true`, and the platform Grafana
rendered with the chart default (`false`). The
`bundles_monitoring_grafana_admin_test.yaml` unit test only asserted
against the emitted Package CR spec — exactly the layer where the
value still existed but was about to be discarded — so the broken
propagation slipped past.

Trim the broken promise from Phase 1. The chart-side helper
(`monitoring.oidc.allowAssignGrafanaAdmin`) stays as-is so a future
phase that routes the override through the tenant-root chart's
monitoring HR values (or adds an `install:` block on the
`monitoring-system` component) lands on a green chart-side contract.
The helm-unittest that verifies chart-side emission stays too, but
with a docstring calling out that end-to-end wiring is deferred.
Everything else that promised the lever works — bundle line, docs,
platform bundle test — is either removed or rewritten to match
reality.

Also picks up two MINOR findings from the same review:

* Drop the misleading `secret-hash` annotation on the OIDC
  client-secret Secret. The comment claimed it "rotates the
  KeycloakClient CR and therefore the Grafana Deployment env when
  the client-secret changes", but nothing downstream watches an
  annotation on the Secret itself — no rotation is triggered. The
  `lookup`-based fallback already keeps the value stable across
  reconciles, so no rotation is even needed. Removing the dead
  annotation avoids planting a broken assumption for future readers.

* Add `release.cozystack.io/helm-install-timeout: "15m"` on the
  monitoring cozyrds ApplicationDefinition. Under `mode: System` the
  chart now emits, on top of the existing Grafana + Alerta +
  VMCluster + CNPG stack, four additional Keycloak CRs whose
  reconciliation is asynchronous against an operator in
  `cozy-keycloak` (not the tenant namespace). Flux's default 5m
  install window was already tight for the pre-OIDC stack; 15m
  matches the precedent set by tenant-rd/kubernetes-rd cozyrds.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov 5f7ed89799 fix(e2e): work around EDP schema-default strip + kubectl delete/apply race
Two follow-up bats fixes uncovered by the first live e2e pass on the
singleton-aligned monitoring OIDC tests:

1. monitoring-oidc-system, test 4 (KeycloakClient assertion). The EDP
   Keycloak operator's KeycloakClient CRD strips `spec.public: false`
   on write because false is the schema default. The chart emits
   `public: false` explicitly (confidentiality guard), but neither
   managedFields nor the resulting spec ever carries the field, so
   the assertion `[ "${public}" = "false" ]` grepped an empty string
   and tripped. Assert `spec.clientAuthenticatorType == "client-secret"`
   instead — the operator persists it, and it is the direct signal
   for "confidential client" (which is what the `public: false`
   assertion was testing for).

2. monitoring-oidc-customconfig, test 4 (secretRef variant). The
   original flow was delete-then-recreate the Monitoring CR to swap
   inline `customConfig.config` for `customConfig.secretRef.name`.
   `kubectl apply` immediately after delete raced the API server's
   grace-period cache: apply's GET-then-decide phase saw the CR
   during grace, chose the patch path, and the patch hit NotFound
   once the delete propagated. Drop the delete entirely — cozystack-
   operator reconciles the CR mode change on the same object and
   Flux re-renders the chart with the new values.

   Add an explicit wait for the target state (Grafana CR's
   `oidc-custom-ini` volumeMount present AND `spec.config.auth.generic_oauth`
   absent) before the assertions, since the CR update triggers an
   asynchronous chart re-render and the immediate get would still
   see the inline auth.generic_oauth block from @test 1.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov cb04caecee fix(e2e): align monitoring OIDC bats with chart singleton naming
The extra/monitoring chart enforces `.Release.Name == "monitoring"`
in templates/check-release-name.yaml (the tenant Monitoring is a
per-namespace singleton). Naming the Monitoring CR anything else
makes the operator materialise an outer HelmRelease with that name
and the chart hard-fails at render with

  execution error at (monitoring/templates/check-release-name.yaml:2:4):
    The name of the release MUST BE monitoring

Rename the CR to `monitoring` in both files. Rework every derived
name to match the actual reality of the chart pipeline instead of
the invented `monitoring-<TEST_NAME>` prefix the tests polled for:

  * outer HR  = ${CR_NAME}                        = monitoring
  * inner HR  = ${CR_NAME}-system                 = monitoring-system
    (rendered by extra/monitoring/templates/helmrelease.yaml as
     `metadata.name: {{ .Release.Name }}-system`)
  * clientId  = ${namespace}-${inner Release.Name}
              = tenant-test-monitoring-system
  * scope     = ${clientId}-audience
  * groups    = ${clientId}-{admin,editor,viewer}
  * client-secret Secret = ${inner Release.Name}-oidc-client
                         = monitoring-system-oidc-client

The BYO Secret name in the customconfig test is chart-independent
(the tenant provides it via customConfig.secretRef.name), so it is
kept short and no longer tied to CR_NAME.

The wait sequence in the first @test of each file is also split
into two probes: outer HR appears first (from the operator), then
inner HR (from the extra/monitoring chart's helmrelease.yaml), then
the Grafana CR. Under the previous incorrect single-probe wait
(`hr monitoring-${TEST_NAME}`) the timeout tripped after 60s
because that HR never existed under any name.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov 9ff05b2f32 test(e2e): port monitoring OIDC bats to cozytest lifecycle conventions
Mirrors the same port applied to kubernetes-oidc-*.bats in the tenant
kube-apiserver OIDC series: cozytest.sh (the e2e runner) is not real
bats, so setup()/teardown() are never invoked and any state they
established (TEST_NAME) is unset by the time an @test body runs,
tripping cozytest.sh's `set -eu` at the first "${TEST_NAME}"
expansion with `TEST_NAME: parameter not set`.

Lift TEST_NAME to file scope as a stable constant, register cleanup
via cozy_cleanup() so cozytest.sh runs it at suite exit and on the
first failing test, and call cleanup_mon() inline at the top of the
first @test for per-run isolation. Fixes the two E2E failures on
this PR: `Monitoring CR accepts spec.oidc.mode=CustomConfig with
inline config` and `Monitoring CR accepts spec.oidc.mode=System`.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov 1679c84bb0 chore(monitoring): regenerate extra/monitoring README
Column widths in the OIDC table contract by one character once the
tenant-facing `grafanaAdmin` row is gone. Regenerating the table
via `make generate` (cozyvalues-gen) so the pre-commit `make
generate` diff check in CI stays clean.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov 586e77c361 fix(monitoring): hard-fail System OIDC render on missing Keycloak CRD
`mode: System` previously wrapped every Keycloak resource in the
`Capabilities.APIVersions.Has "v1.edp.epam.com/v1"` guard, so a
cluster where the Keycloak operator CRDs were not yet registered
silently dropped the whole Keycloak side while Grafana still came
up with an `auth.generic_oauth` block pointing at a client that
never got provisioned. Login broke with an opaque OIDC error and
no chart-side signal that the platform was misconfigured.

Turn the missing CRD into an actionable render error. A new
`monitoring.oidc.assertSystemKeycloakCRD` helper fails with a
message that points at `cozystack.keycloak-operator`, called from
both `templates/grafana/oidc-keycloak.yaml` (replacing the silent
capability guard) and `templates/grafana/grafana.yaml` (symmetric
defense-in-depth so a future refactor of oidc-keycloak.yaml cannot
regress the invariant). For the platform `monitoring-system`
release the oidc variant's `dependsOn: cozystack.keycloak-operator`
already prevents the race; for tenant releases the assertion
converts the misconfiguration into an operator-facing error
instead of a broken login flow.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:45 +03:00
Ivan Okhotnikov e05ca91ff0 fix(monitoring): keep OIDC grafanaAdmin as a platform-only lever
The tenant-facing `Monitoring` CR exposed `spec.oidc.grafanaAdmin`
via `packages/extra/monitoring/values.schema.json`, which the
ApplicationDefinition mirrored into `openAPISchema`. A tenant with
create/patch on `Monitoring` in their own namespace could set the
flag on their release, promoting members of their
`<clientId>-admin` Keycloak group to server-level `GrafanaAdmin`
instead of the intended org-level Admin.

Drop the field from the tenant surface. The platform bundle still
flips it on for the `monitoring-system` component through
`Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin`
(the platform Grafana chart continues to read the value verbatim
in `packages/system/monitoring/values.yaml`); tenant Grafana
releases inherit the chart default `false` and stay at org-level
Admin.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov be52e80289 fix(monitoring): split monitoring-application PackageSource into default/oidc variants
Follow-up to the previous self-review-fix commit: the unconditional
`- cozystack.keycloak-operator` in dependsOn was itself a
CRITICAL cold-install regression on any cluster where
`authentication.oidc.enabled: false` (the default). The
keycloak-operator Package is only emitted by
`packages/core/platform/templates/bundles/system.yaml:112` when
that flag is on — a hard dep against it from the default variant
would leave `cozystack.monitoring-application` stuck on
DependenciesNotReady forever, cascading a
`cozystack-platform` HR timeout that hits every OIDC-off customer.

Fix by mirroring the cozystack-engine variant split:

- `default` variant keeps the baseline reconciliation edges
  (no keycloak-operator dep).
- `oidc` variant additionally waits for
  `cozystack.keycloak-operator` so the v1.edp.epam.com CRDs are
  registered before the monitoring chart reconciles and the
  `.Capabilities.APIVersions.Has` guard in
  templates/grafana/oidc-keycloak.yaml can safely emit
  KeycloakClient/Scope/Groups.

The bundle template swings between variants on
`.Values.authentication.oidc.enabled`, mirroring the
cozystack-engine invocation two lines above. The
`monitoring-system` component override
(oidc.grafanaAdmin: true) is preserved across both branches — the
values field is a no-op when mode: None (no auth.generic_oauth
block is emitted at all), so keeping it identical avoids two
divergent code paths.

Also widened the platform helm-unittest coverage to lock:

- default variant does NOT depend on keycloak-operator (permanent
  DependenciesNotReady guard).
- oidc variant DOES.
- Bundle emits the right variant based on
  authentication.oidc.enabled AND keeps the GrafanaAdmin uplift
  on the monitoring-system component in either branch.

83 platform tests + 23 monitoring tests still green.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov ffeb8e5e4f fix(monitoring): close self-review blockers for the OIDC PR
Address the two blocking-class findings from the cozy-review pass on
this PR:

(1) Missing PackageSource dependency on cozystack.keycloak-operator
    (MAJOR cold-install race).

    Without this edge, the monitoring chart can reconcile before the
    v1.edp.epam.com CRDs are registered on a fresh cluster — the
    `.Capabilities.APIVersions.Has` guard in
    templates/grafana/oidc-keycloak.yaml then silently drops all
    Keycloak-side objects for mode: System, and Grafana's
    auth.generic_oauth block points at a client Keycloak never
    provisions. Add the ordering edge, plus a
    sources_monitoring_application_dependson_test.yaml regression
    guard so a future edit cannot drop it silently.

(2) allow_assign_grafana_admin driven by `.Release.Name ==
    monitoring-system` (MINOR fragile coupling).

    Replace the release-name sniff with an explicit
    `oidc.grafanaAdmin: false` values field, and flip it on for the
    platform Grafana release via
    Package.spec.components["monitoring-system"].values.oidc.grafanaAdmin
    in packages/core/platform/templates/bundles/system.yaml —
    mirroring the pattern monitoring-agents already uses for
    per-component overrides. Tenant Grafana releases inherit the
    chart default false and stay at org-level Admin.

    Two new helm-unittest cases in packages/system/monitoring/tests/
    lock the values-driven behaviour on both sides
    (grafanaAdmin=true → Admin uplift, default → tenant stays at org
    Admin), plus bundles_monitoring_grafana_admin_test.yaml in
    packages/core/platform/tests/ regression-guards the Package's
    component override so the platform release keeps its uplift on
    future edits.

Also document the mode-toggle group-membership loss in
docs/oidc-grafana.md — mode: System → CustomConfig/None deletes the
three chart-owned KeycloakRealmGroups; membership is not restored on
toggle-back. Not a bug, but the operator UX cost of the toggle
deserves a bullet in Failure modes.

Regenerated artifacts (cozyvalues-gen + hack/update-crd.sh):
  packages/extra/monitoring/values.schema.json
  packages/extra/monitoring/README.md
  packages/system/monitoring-rd/cozyrds/monitoring.yaml

Test suites still green: packages/system/monitoring 23/23,
packages/core/platform 81/81.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov 4fe85d039c chore(monitoring): move OIDC schema source to packages/extra + regenerate
The Monitoring public API surface (openAPISchema in monitoring-rd
cozyrds) is generated from packages/extra/monitoring/values.yaml by
cozyvalues-gen + hack/update-crd.sh — the foundation commit
(186239044) instead wrote it by hand in
packages/system/monitoring-rd/cozyrds/monitoring.yaml, which the
pre-commit hook then reverts against every push.

Move the oidc annotations into packages/extra/monitoring/values.yaml
(matching the pattern used by the rest of the top-level fields) and
run `make generate` in that directory. That regenerates:

  - packages/extra/monitoring/values.schema.json
  - packages/extra/monitoring/README.md
  - packages/system/monitoring-rd/cozyrds/monitoring.yaml
    (openAPISchema + keysOrder, both including the new oidc keys)

No behaviour change — the values.yaml under packages/system/monitoring
still carries the same oidc defaults so `helm template` works
stand-alone, and helm-unittest keeps passing (22/22).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov ed4aa841c8 docs(oidc-grafana): operator guide for Phase-1 OIDC on the Grafana instance
New docs/oidc-grafana.md parallel to docs/oidc-tenant.md from
cozystack/cozystack#3044. Covers:

- The three modes on the Monitoring CR (None / System / CustomConfig)
  and how the selector maps to what the chart provisions.
- What System mode creates on the cozy realm: a per-instance
  confidential KeycloakClient, a KeycloakClientScope with the
  audience mapper, three KeycloakRealmGroups, and the persistent
  client-secret Secret; plus the Grafana CR side (auth.generic_oauth
  section, GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET env).
- Both CustomConfig payload paths (inline vs secretRef) and the XOR
  guard.
- User-role mapping via role_attribute_path and how an operator adds
  users to the three groups through a KeycloakRealmUser.
- Failure modes (assertSystemEnabled, XOR, wrong claims,
  emailVerified prescriptive requirement) and what is out of scope
  for Phase 1 (per-tenant realms, backend-logout-url, disabled login
  form, CEL claimValidationRules).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov fe2c606f79 test(e2e): add Phase-1 OIDC render-side bats for Monitoring
Two bats files mirroring the shape of the tenant kube-apiserver PR's
kubernetes-oidc-{system,customconfig}.bats:

- monitoring-oidc-system.bats — asserts the Monitoring CR admits the
  new spec.oidc.mode: System field; the chart renders the Grafana CR
  with an auth.generic_oauth section pointing at the cozy realm; a
  32-char client-secret Secret is persisted; the KeycloakClient (with
  public: false), KeycloakClientScope (audience mapper), and three
  KeycloakRealmGroups (-admin, -editor, -viewer) land in cozy; and
  the Grafana Deployment injects GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
  from the Secret.

- monitoring-oidc-customconfig.bats — asserts the CR admits inline
  CustomConfig; the operator's payload lands on spec.config verbatim
  with no cozy realm in the auth_url; NO KeycloakClient / Scope /
  Groups are created in cozy; and the secretRef variant mounts the
  operator Secret under /etc/grafana/oidc without emitting
  auth.generic_oauth under spec.config.

Neither drives a real browser login through Keycloak — that is
deferred to the follow-up integration suite, same posture as the
tenant kube-apiserver's Phase-1 tests.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov 1f44bc06cf test(monitoring): helm-unittest coverage for the OIDC selector
15 cases across the three modes plus the two fail-close guards:

- mode: None emits nothing OIDC-related — no cozy-realm objects, no
  client-secret Secret, no auth.generic_oauth key on the Grafana CR,
  no GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET / GF_PATHS_CUSTOM_INI env.
- mode: System renders KeycloakClient + KeycloakClientScope + three
  KeycloakRealmGroups + persistent client-secret Secret, and pins the
  Grafana spec.config to the cozy realm with a role_attribute_path
  that maps the -admin/-editor/-viewer groups to Grafana roles.
- mode: System fails the render when the platform-level OIDC feature
  is off, via the assertSystemEnabled helper.
- mode: CustomConfig inline writes the operator-supplied map verbatim
  into spec.config.auth.generic_oauth without touching cozy.
- mode: CustomConfig secretRef mounts the operator Secret under
  /etc/grafana/oidc + wires GF_PATHS_CUSTOM_INI, and does NOT emit
  auth.generic_oauth under spec.config (the ini fragment is
  authoritative).
- CustomConfig branch fails-close on both/neither of the two payloads
  via the assertCustomConfigXor helper.
- Platform release (Release.Name == monitoring-system) upgrades the
  Admin role to server-level GrafanaAdmin through the
  allow_assign_grafana_admin flag; tenant Grafana stays at
  org-level Admin.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov 8eff03b323 feat(monitoring): wire auth.generic_oauth into the Grafana CR
Patch the Grafana CR so that the oidc.mode selector drives the actual
Grafana runtime config:

- mode: System — emit a full auth.generic_oauth section under
  spec.config with client_id / client_secret / auth_url / token_url /
  api_url / scopes / role_attribute_path / allow_assign_grafana_admin
  built from the helpers, and inject
  GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET as an env from the persistent
  Secret introduced in the previous commit. role_attribute_path maps
  membership in the per-instance -admin / -editor / -viewer groups to
  Grafana's Admin / Editor / Viewer roles, defaulting to Viewer on
  authenticated identities with none of them.
  allow_assign_grafana_admin is set to true only for the platform
  release (Release.Name == 'monitoring-system'); tenant Grafana stays
  at org-level Admin.
- mode: CustomConfig (inline) — pass the operator-supplied config map
  verbatim to spec.config.auth.generic_oauth. `cozy` is not in the
  path.
- mode: CustomConfig (secretRef) — mount the operator-managed Secret
  under /etc/grafana/oidc and load it via GF_PATHS_CUSTOM_INI. The
  ini fragment is authoritative; the chart does not emit
  auth.generic_oauth under spec.config in this branch.
- mode: None (default) — no change to the rendered manifests.

CustomConfig branch fails the render at the XOR helper if both or
neither of `config` / `secretRef.name` are set. `disable_login_form`
stays false so the admin_user/admin_password Secret remains a
documented break-glass path in every mode.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov 473b112f66 feat(monitoring): persist confidential OIDC client secret for System mode
New oidc-client-secret.yaml renders a Kubernetes Secret with the
confidential client_secret used by both the EDP-Keycloak operator
(via \$<secret>:client-secret from oidc-keycloak.yaml) and the
Grafana pod (via GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET, wired by the
next commit).

Value is generated at first install (randAlphaNum 32) and preserved
across upgrades through the same \`lookup\` pattern used by
packages/system/dashboard. secret-hash annotation surfaces the value
change to whichever controller is watching, ensuring
KeycloakClient + Grafana Deployment re-reconcile if it ever rotates.

Rendered only for mode: System — CustomConfig operators bring their
own credentials (inline plaintext or a Secret they manage
out-of-band).

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov 9dd9f1d4dd feat(monitoring): render Keycloak client + scope + groups for System mode
New oidc-keycloak.yaml template mirrors the tenant kube-apiserver
Phase-1 shape (cozystack/cozystack#3044) with three Grafana-specific
adjustments:

- Client is confidential (public: false, directAccess: false);
  Grafana is server-side so a client_secret is required. The value is
  wired via the EDP-Keycloak `$<secret>:<key>` reference; the
  backing Secret is created by the next commit.
- redirectUri is https://grafana.<host>/login/generic_oauth — the
  Grafana generic_oauth callback path.
- Three KeycloakRealmGroups (`<clientId>-{admin,editor,viewer}`) are
  chart-owned. Membership is set by an operator through the Keycloak
  UI or a KeycloakRealmUser CR; grafana.yaml will map group
  membership to Admin / Editor / Viewer via role_attribute_path.

Guarded by mode == System AND _cluster.oidc-enabled == true (via the
assertSystemEnabled helper). Renders nothing for None or
CustomConfig.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
Ivan Okhotnikov e12bb1a3ba feat(monitoring): scaffold OIDC selector on the Grafana instance
Add the plumbing needed for a Phase-1 OIDC integration on Grafana,
matching the shape of cozystack/cozystack#3044 for the tenant
kube-apiserver:

- values.yaml grows a top-level oidc block with a three-value mode
  enum (None/System/CustomConfig) and a customConfig payload with the
  XOR-inline/secretRef surface.
- monitoring-rd advertises the new selector via its openAPISchema and
  keysOrder so operators see it on the Monitoring CR.
- templates/_helpers.tpl introduces the naming helpers reused by every
  downstream OIDC template: clientId, audienceScopeName,
  clientSecretName, grafanaHost, redirectUri, systemIssuerURL,
  roleAttributePath, allowAssignGrafanaAdmin (platform-only),
  assertSystemEnabled, assertCustomConfigXor.

No behaviour change on default values — mode: None short-circuits and
no OIDC objects are rendered.

Signed-off-by: Ivan Okhotnikov <ivan.okhotnikov@aenix.io>
2026-07-15 15:31:44 +03:00
myasnikovdaniil 29e5a55473 fix(kubernetes): re-pin deleted centos:stream9 digest for kubevirt-csi-driver (#3303)
## What this PR does

`packages/apps/kubernetes/images/kubevirt-csi-driver/Dockerfile` pins
its runtime base image to `quay.io/centos/centos:stream9` by digest.
`stream9` is a rolling tag, and upstream deleted the pinned digest
(`sha256:0c9db821…`) when it published a fresh stream9 build. As a
result `make image-kubevirt-csi-driver` now fails with `not found` on
every build — open PRs and `main` alike (the `Build
packages/apps/kubernetes` job goes red before E2E even starts).

Re-pin to the current live `stream9` digest (`sha256:a93e3316…`,
verified pullable and multi-arch: amd64/arm64/ppc64le/s390x). No
Dockerfile logic changes.

## Known limitation and follow-up

This is an emergency CI unblock, **not** a durable fix. `stream9` is a
rolling, garbage-collected tag: upstream deletes old digests on every
rebuild, so the same `not found` breakage will recur the next time
CentOS rolls the tag faster than the pin is bumped. This is the same
operation Renovate's weekly (non-automerged) digest PR performs.

A durable fix is out of scope for this hotfix and left as follow-up:
- mirror the base image into cozystack's own registry so old digests are
not GC'd, or
- move to an immutable-digest base such as UBI9
(`registry.access.redhat.com/ubi9`), or
- enable automerge / tighten the schedule for the digest-only Renovate
group so future rolls are absorbed automatically.

### Downstream repositories

- [x] No downstream repository is affected by this change

<!-- Runtime base-image digest of an internal component image; no API,
values, CRD, or chart-interface surface changes. -->

### Release note

```release-note
fix(kubernetes): restore the kubevirt-csi-driver image build after upstream deleted the pinned centos:stream9 base image digest
```


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

## Summary by CodeRabbit

* **Chores**
* Updated the Kubernetes CSI driver’s runtime base image to a newer
verified version.
  * Runtime behavior and application functionality remain unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 17:22:24 +05:00
Ivan Okhotnikov ce4b263ca6 fix(kubernetes): re-pin deleted centos:stream9 digest for kubevirt-csi-driver
quay.io/centos/centos:stream9 is a rolling tag; upstream deleted the pinned
digest (sha256:0c9db821...) when it published a new stream9 build, so every
build of image-kubevirt-csi-driver fails with "not found" — on open PRs and
on main. Re-pin to the current live stream9 digest (sha256:a93e3316...).

This is an emergency CI unblock, not a durable fix: stream9 is garbage-collected,
so upstream will delete this digest too on its next rebuild and the same
"not found" breakage recurs until the pin is bumped again — the same operation
Renovate's weekly, non-automerged digest PR performs. A durable fix (mirror the
base image into cozystack's own registry, or move to an immutable-digest base
such as UBI9) is out of scope for this hotfix and left as follow-up.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-15 12:54:24 +03:00
myasnikovdaniil 77389d6e99 chore(deps): update github.com/kubevirt/csi-driver digest to 27b52aa (#3066)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[github.com/kubevirt/csi-driver](https://redirect.github.com/kubevirt/csi-driver)
| replace | digest | `51c6bca` → `27b52aa` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/cozystack/cozystack).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNDIuMiIsInVwZGF0ZWRJblZlciI6IjQzLjI1OS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWF0ZWQiLCJkby1ub3QtbWVyZ2UvaG9sZCJdfQ==-->
2026-07-15 14:42:31 +05:00
myasnikovdaniil a269eb3965 chore(deps): update actions/checkout action to v6.0.3 (#3133)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://redirect.github.com/actions/checkout) |
action | patch | `v6.0.2` → `v6.0.3` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

###
[`v6.0.3`](https://redirect.github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v603)

[Compare
Source](https://redirect.github.com/actions/checkout/compare/v6.0.2...v6.0.3)

- Fix checkout init for SHA-256 repositories by
[@&#8203;yaananth](https://redirect.github.com/yaananth) in
[#&#8203;2439](https://redirect.github.com/actions/checkout/pull/2439)
- fix: expand merge commit SHA regex and add SHA-256 test cases by
[@&#8203;yaananth](https://redirect.github.com/yaananth) in
[#&#8203;2414](https://redirect.github.com/actions/checkout/pull/2414)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/cozystack/cozystack).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNDIuMiIsInVwZGF0ZWRJblZlciI6IjQzLjI0Mi4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWF0ZWQiLCJkby1ub3QtbWVyZ2UvaG9sZCJdfQ==-->
2026-07-15 14:38:36 +05:00
myasnikovdaniil 1d1c7c2c38 chore(deps): update altinity/clickhouse-backup docker tag to v2.7.4 (#3226)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[altinity/clickhouse-backup](https://redirect.github.com/Altinity/clickhouse-backup)
| final | patch | `2.7.2` → `2.7.4` |

---

### Release Notes

<details>
<summary>Altinity/clickhouse-backup
(altinity/clickhouse-backup)</summary>

###
[`v2.7.4`](https://redirect.github.com/Altinity/clickhouse-backup/blob/HEAD/ChangeLog.md#v274)

[Compare
Source](https://redirect.github.com/Altinity/clickhouse-backup/compare/v2.7.3...v2.7.4)

NEW FEATURES

- add `clickhouse.parts_columns_max_bytes_before_external_group_by` (env
`CLICKHOUSE_PARTS_COLUMNS_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY`, default
`100000000`) and `clickhouse.parts_columns_max_memory_usage` (env
`CLICKHOUSE_PARTS_COLUMNS_MAX_MEMORY_USAGE`, default `200000000`) — make
the memory caps injected into the `system.parts_columns` check query
configurable instead of hardcoded, `0` means don't inject the
corresponding setting (pre-2.7.2 behavior), fix
[#&#8203;1420](https://redirect.github.com/Altinity/clickhouse-backup/issues/1420)

BUG FIXES

- fix `--restore-database-mapping`/`--restore-table-mapping` losing
unrelated comma-separated `--tables` pattern items, or applying the
mapping to the wrong item when the same source database/table name was
repeated across items — each comma-separated pattern item is now matched
and substituted independently, fix
[#&#8203;1421](https://redirect.github.com/Altinity/clickhouse-backup/issues/1421)

###
[`v2.7.3`](https://redirect.github.com/Altinity/clickhouse-backup/blob/HEAD/ChangeLog.md#v273)

[Compare
Source](https://redirect.github.com/Altinity/clickhouse-backup/compare/v2.7.2...v2.7.3)

NEW FEATURES

- add `general.max_broken_part_ratio` (env `MAX_BROKEN_PART_RATIO`,
default `0`) — when >0, `create`/`create_remote` skips broken data parts
instead of aborting the whole backup as long as the broken/total part
ratio stays at or below the configured fraction; skipped parts are
logged as warnings and the backup is marked successful but partial; `0`
preserves the legacy any-broken-part-fails behavior, fix
[#&#8203;1418](https://redirect.github.com/Altinity/clickhouse-backup/issues/1418)
- add `clickhouse.rebind_replica_path_if_exists` (env
`CLICKHOUSE_REBIND_REPLICA_PATH_IF_EXISTS`, CLI
`--rebind-replica-path-if-exists`, also accepted by the REST API `POST
/backup/restore` endpoint) — opt-in rebind of the replicated table ZK
path to `default_replica_path` during `restore` when the resolved path
already has leftover children but no local table uses it (async-stale
state after DROP); MUST stay `false` during a concurrent multi-replica
restore to avoid split-brain, fix
[#&#8203;849](https://redirect.github.com/Altinity/clickhouse-backup/issues/849)
- add `gcs.disable_http2` (env `GCS_DISABLE_HTTP2`) — force HTTP/1.1 for
the GCS client to avoid single-TCP-connection HTTP/2 multiplexing
becoming the bottleneck for high-throughput parallel downloads, fix
[#&#8203;1437](https://redirect.github.com/Altinity/clickhouse-backup/pull/1437)

IMPROVEMENTS

- switch the S3 client retry mode from `aws.RetryModeStandard` to
`aws.RetryModeAdaptive`, so upload/download retries back off with a
client-side rate limiter under throttling (`SlowDown`/503) instead of a
fixed token bucket
- support tables with `ENGINE=Alias` during `restore` — strip the
explicit column list (Alias inherits columns from the target table) and
add `allow_experimental_alias_table_engine=1` for ClickHouse 25.11+, fix
[#&#8203;1426](https://redirect.github.com/Altinity/clickhouse-backup/issues/1426)
- skip tables from databases with engine `DataLakeCatalog` (read-only
external catalogs) during backup, in addition to
`MySQL`/`PostgreSQL`/`MaterializedPostgreSQL`, fix
[#&#8203;1417](https://redirect.github.com/Altinity/clickhouse-backup/issues/1417)
- speed up `create` on servers with many tables: batch the in-progress
`system.mutations` lookup instead of issuing one query per table (O(N^2)
-> O(N)), fix
[#&#8203;1431](https://redirect.github.com/Altinity/clickhouse-backup/pull/1431)
- speed up `restore`/`download` table list resolution: eliminate the
O(N^2) linear duplicate scan in
`getTableListByPatternLocal`/`getTableListByPatternRemote` via a shared
O(1) dedup keyed on `metadata.TableTitle`, fix
[#&#8203;1430](https://redirect.github.com/Altinity/clickhouse-backup/pull/1430)
- extend TestFlows FIPS 140-3 test coverage: dedicated test steps,
stronger `--fips-info`/version-output and TLS-handshake assertions,
updated requirements documentation, fix
[#&#8203;1443](https://redirect.github.com/Altinity/clickhouse-backup/pull/1443)

BUG FIXES

- restore the default `general.compression_use_multi_thread` to `true`:
[#&#8203;1378](https://redirect.github.com/Altinity/clickhouse-backup/issues/1378)
defaulted it to `false`, which silently dropped gzip/zstd compression
from multi-threaded (pre-1378 gzip always used `pgzip`) to
single-threaded and caused \~30% slower upload throughput for backups
dominated by one large table (where `upload_concurrency` provides no
per-stream parallelism); the option is now silently ignored instead of
failing config validation for formats other than gzip/zstd
- fix v2.7.1 regression where `restore --schema` on a second replica
rebound the ZK replica path even while the first replica was active and
healthy — a foreign/renamed local table occupying the resolved ZK path
is still detected automatically via `system.replicas` and rebound to
`default_replica_path`, but a non-empty ZK path without a local table
now defaults to the HA-safe join and requires the new opt-in
`rebind_replica_path_if_exists`, fix
[#&#8203;1428](https://redirect.github.com/Altinity/clickhouse-backup/issues/1428)
- fix inconsistent `--rbac` behavior: `restore --rbac` no longer returns
an error when the backup contains no RBAC `access` directory, matching
the `download --rbac` behavior, fix
[#&#8203;1432](https://redirect.github.com/Altinity/clickhouse-backup/issues/1432)
- inject `ON CLUSTER` for `LIVE VIEW` and `WINDOW VIEW` restored with
explicit column lists when `restore_schema_on_cluster` is set —
`enrichQueryWithOnCluster` missed these statements and the views were
created only on the local replica, fix
[#&#8203;1433](https://redirect.github.com/Altinity/clickhouse-backup/pull/1433)
- mask sensitive values (passwords, storage credentials, encryption
keys) when logging `--env` config overrides, and in the API server
action logs and `/backup/status` output, fix
[#&#8203;1441](https://redirect.github.com/Altinity/clickhouse-backup/pull/1441)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/cozystack/cozystack).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNDIuMiIsInVwZGF0ZWRJblZlciI6IjQzLjI0Mi4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJhdXRvbWF0ZWQiLCJkby1ub3QtbWVyZ2UvaG9sZCJdfQ==-->
2026-07-15 14:37:19 +05:00
Aleksei Sviridkin 249f7b5a5e feat(platform, ingress): propagate operator wildcard certificate to per-tenant termination points (#2990)
## What this PR does

The operator-provided wildcard certificate
(`publishing.certificates.wildcardSecretName`) currently serves the root
tenant only: everything is same-namespace, so the root ingress
controller and Gateway read the operator's TLS Secret directly. Child
tenants run their own ingress controller / Gateway in their own
namespace under a default-deny policy and cannot read that Secret across
namespaces, so they fall back to per-host ACME.

This propagates the operator wildcard to per-tenant termination points
by replicating the Secret into each tenant namespace that owns one,
keeping the root-MVP invariant intact: the wildcard is deliberately
distributed to tenant namespaces, but no tenant RBAC is widened — every
consumer reads only its own-namespace copy.

A new controller in `cozystack-controller` does the replication with no
extra operator input. It reads the same platform values channel the
consumers read (`cozy-system/cozystack-values`): the wildcard Secret
name from `_cluster.wildcard-secret-name` and the publishing namespace
from `_cluster.expose-ingress`. It mirrors that Secret into every tenant
namespace whose `namespace.cozystack.io/ingress` or
`namespace.cozystack.io/gateway` label equals its own name — i.e. the
namespace runs its own controller / Gateway. Because the source is
derived from the same value that makes the consumers reference it, the
replica is created whenever the consumers expect it — no manual
labelling and no upgrade hazard where a child controller references a
Secret that nothing creates.

Replicas are refreshed on rotation and pruned when a namespace stops
terminating TLS. Teardown of every replica happens on exactly one
trigger: clearing `wildcardSecretName` (explicit disable). A source that
is merely absent or mistyped, or a transiently-missing values channel,
leaves existing replicas in place, so a brief gap, a delete+recreate
rotation, or a misconfigured publishing namespace never drops tenant
TLS. The platform controller's own ServiceAccount gains Secret writes;
tenant RBAC is untouched.

The controller does not cache every Secret in the cluster: the manager's
Secret informer is scoped to managed replicas and the values channel
only, and the operator source / foreign collisions are read through the
uncached APIReader. The source is not watched (its name is dynamic), so
an in-place rotation is picked up by a bounded periodic resync while the
publishing tenant serves the source with no lag.

On the consumer side, each tenant ingress controller now sets
`--default-ssl-certificate` to the wildcard Secret in its own namespace
(`.Release.Namespace/<name>`), so a child tenant serves the replica
instead of minting per-host ACME. The Gateway path already renders
`certMode: existingSecret` with a same-namespace `wildcardSecretRef` for
every tenant, so it consumes the replica with no chart change.

### Why not clustersecret-operator

The issue proposed the shipped `clustersecret-operator`. It materializes
inline template data into selected namespaces; it cannot mirror an
existing source Secret, and it is not installed in any default bundle.
Driving it would mean embedding the certificate and private key as
literal data in a cluster-scoped object rendered from a Helm `lookup` —
that broadcasts key material the design keeps off any shared channel,
and a `lookup` is invisible to the Flux helm-controller digest so it
would not re-render on rotation. Its namespace selector also cannot
express "the namespace whose owner label equals its own name", so it
would over-replicate the key into every tenant namespace. A small
dedicated reconciler targets exactly the termination namespaces and
keeps the key off the values channel.

### Coverage caveat

Replication delivers the certificate bytes, not SAN coverage: a single
`*.<root-host>` does not match `*.<child-apex>`. Operators serving
child-tenant subdomains supply a certificate whose SANs cover those
apexes; the docs spell this out. The replica carries the wildcard
private key into each terminating tenant namespace — the same exposure
as the per-host ACME Secret it replaces, now shared across tenants.

### Tests

- Go fake-client tests for the reconciler: termination-only replication
(ingress-owning and gateway-owning namespaces only, never inheriting
ones, never over the source), copy markers, rotation, prune on namespace
demotion, the non-destructive source-deletion contract, replica
self-heal after out-of-band deletion, the publishing-namespace
self-replication guard, disable-via-config teardown, source rename,
foreign-Secret collision safety (no overwrite, no requeue, does not
block siblings), transient-error requeue, non-TLS source ignored,
absent-channel keeps replicas, publishing-namespace mismatch keeps and
requeues, the cache-scope pin, the enqueue-wiring map, an
`ownsTerminationPoint` table, and the active-only requeue contract.
- helm-unittest for the ingress `default-ssl-certificate` wiring
(including a child-tenant controller pointing at its own-namespace
replica) and for the cozystack-controller ClusterRole's new Secret-write
rule.

### Screenshots

N/A — no UI changes.

### Release note

```release-note
feat(platform): the operator-provided wildcard TLS certificate (`publishing.certificates.wildcardSecretName`) is now served by per-tenant ingress controllers and Gateways automatically — the platform controller replicates it into each tenant namespace that terminates TLS, with no extra operator input and no cross-namespace Secret read
```

Closes #2820

Part of #2811


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

## Release Notes

* **New Features**
* Wildcard TLS Secret now replicates from the platform to tenant
namespaces that terminate TLS via their own ingress controller or
Gateway, including child-tenant scenarios.

* **Documentation**
* Clarified configuration, timing, and SAN matching requirements for
wildcard replication.
* Updated gateway and ingress-nginx default certificate documentation
for replica mirroring behavior.

* **Bug Fixes**
* Improved handling for missing/invalid source secrets (no unnecessary
pruning) and safe collision behavior.

* **Tests**
  * Added comprehensive reconciliation/replication test coverage.
  * Added Helm unittest target for the controller chart.

* **Chores**
  * Updated controller RBAC to support wildcard TLS Secret replication.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 11:32:08 +03:00
Aleksei Sviridkin 6b1ad96093 fix(e2e): gate LINSTOR node-list probe on a ready controller endpoint (#3250)
## What this PR does

The E2E `Install Cozystack` step runs `hack/e2e-post-install-prep.sh` in
the background, which waits for 3 LINSTOR satellites to come Online by
dialing the `linstor-controller` Service
(`linstor+ssl://linstor-controller:3371`) from the controller pod. That
probe was gated only by a one-shot `kubectl wait
deployment/linstor-controller --for=condition=available`, which goes
stale. The controller carries `reloader.stakater.com/auto=true` and
mounts the cert-manager-issued API-TLS Secret
(`packages/system/linstor/templates/cluster.yaml`), so when cert-manager
(re)writes that Secret during bring-up, reloader rolls the
single-replica controller. The roll drops the Service to zero ready
endpoints *after* `Available` already passed, and a dial into that
window fails with `[Errno 113] No route to host` (EHOSTUNREACH — the
state of a ClusterIP whose Service has no ready backend). The blind
`until` loop then churned the CLI on that error, tolerating a real
un-Ready-controller race instead of gating on it.

This gates every probe on the `linstor-controller` Service actually
having a ready endpoint (a non-empty `Endpoints .subsets[].addresses`),
so the CLI only dials a routable controller and a mid-loop reload
re-waits for the endpoint instead of erroring. The `Online == 3`
satellite-convergence check stays the real readiness assertion, and the
dedicated 300s budget is preserved from the previous `timeout 300`; on
timeout the loop now dumps the controller endpoints and pods for
diagnostics before failing, per the E2E diagnostics convention.

Evidence for the mechanism: the same single `Error: Unable to connect to
linstor+ssl://linstor-controller:3371: [Errno 113] No route to host`
line appears exactly once per install across multiple runs and is
immediately recovered by the retry — consistent with one reloader-driven
controller roll during initial bring-up, not a persistent outage.

Scoped to the LINSTOR readiness path in `hack/e2e-post-install-prep.sh`
only; no change to install ordering, dependencies, or timeouts.

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved the LINSTOR startup readiness check to wait more reliably for
the service to become reachable before verifying node status.
* Made the node-online verification more resilient by handling transient
connectivity errors and enforcing a clearer timeout.
* Added extra diagnostic output when readiness does not complete in
time, helping with troubleshooting failed installs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 11:32:05 +03:00
Aleksei Sviridkin 3763c528da fix(seaweedfs): soften master anti-affinity for small clusters (#3197)
## What this PR does

Overrides the seaweedfs master anti-affinity in the package values from
a hard `requiredDuringSchedulingIgnoredDuringExecution` hostname term to
a strong preference (`preferredDuringSchedulingIgnoredDuringExecution`,
weight 100). The selector and topologyKey are unchanged.

The vendored subchart pins the master to a hard hostname anti-affinity —
its own comment notes that emptying it is what allows single-node
deployment. With the master StatefulSet at three replicas, the surplus
master Pods stay Pending forever on clusters with fewer than three
nodes, so `helm install --wait` never returns and the S3 stack never
comes up.

Softening to a strong preference keeps the masters spread across
distinct nodes whenever the cluster has them (the scheduler only
co-locates when there is no alternative — exactly the small-cluster
case), while letting single- and dual-node clusters bring the master up.
This mirrors the posture already applied to the s3 component in the same
values file.

A helm-unittest pins the softened term (asserts the hard term is gone
and the preferred term carries the right weight, topologyKey, and
component selector) so a future subchart bump cannot silently re-harden
it.

### Screenshots

N/A — no UI changes.

### Release note

```release-note
fix(seaweedfs): soften the master anti-affinity to a preference so the S3 stack installs on clusters with fewer than three nodes
```


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

* **New Features**
* SeaweedFS master pods now use a softer scheduling preference for
hostname separation, improving deployment success on smaller clusters.
* **Tests**
* Added a Helm chart validation to confirm the anti-affinity behavior is
now expressed as a weighted preference (not a hard requirement),
including the expected selector and topology key.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 11:32:01 +03:00
myasnikovdaniil 6c9edf5038 fix(platform): route OIDC backend calls through the in-cluster Keycloak by default (#3289)
## What this PR does

Defaults `authentication.oidc.keycloakInternalUrl` to
`http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy` instead of an
empty string.

With the empty default, oauth2-proxy performed OIDC discovery against
the external Keycloak hostname. That request resolves through public DNS
and lands on the root ingress controller, which does not serve yet on a
fresh install: the root tenant's IngressClass only exists once
`tenant-root` is ready, and `tenant-root` is still reconciling while the
dashboard starts. Discovery has nothing to talk to, the gatekeeper
fails, and the dashboard never comes up — with no obvious diagnosis for
a first-time installer.

The bundled Keycloak is the only supported provider (the external
hostname is templated as `keycloak.<host>` and the realm is fixed), so
backend-to-backend calls have no reason to leave the cluster. The new
default is the same Service address `keycloak-configure` already uses
for its own backend calls. The dashboard and linstor-gui gatekeepers
then run with `--skip-oidc-discovery` and send token, jwks, userinfo and
logout requests straight to the Service, while browser-facing redirects
stay on the external host.

Both gatekeeper templates already branch on the value being non-empty,
so neither needed a change. The value stays overridable for a Keycloak
that does not live at the standard in-cluster address, and setting it
back to `""` restores the previous external-discovery behaviour.

Token validation is unaffected: Keycloak pins `KC_HOSTNAME` to the
external URL, so tokens redeemed over the internal Service still carry
the external `iss` and match the unchanged `--oidc-issuer-url`.

### Upgrade behaviour

Existing OIDC clusters that never set `keycloakInternalUrl` pick up the
new default on upgrade: their gatekeepers roll once and move to the
internal path. Clusters that set the value explicitly — including to
`""` — keep exactly what they configured.

### Security posture

The backend leg (token redemption, which carries the gatekeeper's
Keycloak client secret, plus jwks/userinfo/logout) now travels as
plaintext HTTP between pods rather than HTTPS to the ingress. This is
stated in the `values.yaml` comment rather than left implicit. The
`keycloak-http` Service has no TLS listener, so in-cluster HTTPS is not
an available alternative, and `keycloak-configure` already sends
Keycloak *admin* credentials over that identical address — anyone
positioned to read this traffic can already reach a strictly more
powerful secret. The externally reachable front door (browser to
oauth2-proxy) is unchanged.

### Testing

helm-unittest coverage across all three affected charts: the default
landing on the `_cluster` channel, both gatekeepers rendering the
in-cluster backend URLs while keeping browser URLs external, an explicit
override winning, the empty-string escape hatch still rendering the
external-discovery shape, and OIDC-disabled clusters (now the common
case of "OIDC off, internal URL set") ignoring the value entirely.

The E2E OIDC case previously applied this exact URL as a manual
workaround. It now sets only `oidc.enabled` and asserts the consequence:
it waits for the `auth-proxy` container to appear in the gatekeeper
Deployment, then gates on `kubectl rollout status`. Readiness alone
would prove nothing there — the HelmRelease is still `Ready` from the
token-proxy install, and with `replicas: 1` and `maxUnavailable: 25%`
(which rounds down to 0) the Deployment keeps the old ReplicaSet
`Available` while a new pod crashloops. Only the rollout catches a
regression back to external discovery.

Follow-up: the OIDC documentation in the website repo still frames
`keycloakInternalUrl` as an optional tweak "for self-signed certificates
or restricted external access". It is now the default path and needs a
separate docs PR.

Closes #3288

### Screenshots

No UI changes.

### Release note

```release-note
fix(platform): `authentication.oidc.keycloakInternalUrl` now defaults to the in-cluster Keycloak Service (`http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy`). OIDC backend calls from the dashboard and linstor-gui gatekeepers no longer leave the cluster, so enabling OIDC on a fresh install no longer leaves the dashboard down while the root ingress is not yet serving. Existing clusters that never set the value pick up the new default and roll their gatekeepers once. Set the value explicitly to point at a non-standard Keycloak placement, or to an empty string to restore external OIDC discovery.
```


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

## Summary by CodeRabbit

- **New Features**
- OIDC-enabled applications now use the internal Keycloak service for
backend authentication by default, improving reliability and avoiding
unnecessary external discovery.
- Browser redirects and login flows continue using the external Keycloak
address.
- Operators can provide a custom internal Keycloak URL or set it to an
empty value to restore external OIDC discovery.

- **Bug Fixes**
- Dashboard and Linstor GUI authentication now correctly apply internal
Keycloak settings only when OIDC is enabled.
- Dashboard upgrades now wait for authentication components to become
ready before completing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 13:08:56 +05:00
myasnikovdaniil 5826d5fd7d docs(harbor): fix SeaweedFS discovery key described as annotation (#3293)
## What this PR does

Corrects a factual error in the Harbor app's prerequisites
documentation.

The prerequisites section described the resolved SeaweedFS class as
being surfaced on the tenant namespace as the
`namespace.cozystack.io/seaweedfs` **annotation**. It is a **label**.
`packages/apps/tenant/templates/namespace.yaml` renders that key under
`metadata.labels` — in the block commented "Labels for network
policies", alongside the sibling `namespace.cozystack.io/etcd`,
`/ingress`, `/gateway`, `/monitoring` and `/host` keys. No annotation of
that name exists anywhere in the tree.

Why the wrong noun matters: it sends an operator to the wrong field at
exactly the moment they are already debugging. The README documents that
a Harbor release which cannot provision its registry bucket stays
unreconciled waiting on `BucketInfo`, and the natural check is to
confirm the tenant chain actually resolved a SeaweedFS class. Following
the text and inspecting `.metadata.annotations` returns nothing even on
a perfectly healthy tenant, which invites the conclusion that the tenant
chain is misconfigured and sends the investigation away from the real
cause.

The change is one word. I checked the rest of the tree for the same
error class — grepping for `namespace.cozystack.io/` together with
"annotation" across all of `packages/` and then across the whole
repository — and this README was the only occurrence.
`packages/extra/gateway/README.md` already describes the sibling keys
correctly as labels.

Note on the file being partially generated:
`packages/apps/harbor/README.md` has a `generate:` target in its
Makefile that runs `cozyvalues-gen ... -r README.md`, but that generator
only rewrites the `## Parameters` tables from the `@param` comments in
`values.yaml`. The corrected sentence lives in the hand-maintained prose
section, which the generator preserves verbatim — confirmed by running
cozyvalues-gen v1.6.0 (the version CI pins) against a copy of the
package: the corrected wording survives regeneration byte-for-byte, so
this introduces no codegen drift. Editing the README directly is the
correct fix here; there is no separate upstream source to change.

Downstream consequence worth flagging for reviewers: the cozystack
website generates the Harbor app page from this README, so the published
v1.5 Harbor page currently carries the same error. It will be corrected
when the site re-syncs from this file.

### Screenshots

Not applicable — documentation-only change, no UI surface.

### Release note

```release-note
docs(harbor): fix SeaweedFS discovery key in prerequisites — it is the `namespace.cozystack.io/seaweedfs` namespace label, previously documented as an annotation
```


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

## Summary by CodeRabbit

* **Documentation**
* Updated Harbor prerequisites to clarify that the resolved SeaweedFS
class is exposed through a namespace label.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 13:08:47 +05:00
myasnikovdaniil ce1b74da46 docs(agents): add downstream repository checklist to the PR template (#3298)
## What this PR does

Cozystack is upstream for repositories that nothing keeps in sync with
it, and no CI job compares the two sides, so a change here can break
them in silence. It has already happened: `packages/apps/opensearch`
ships but has no reference page on the website, the Terraform provider
still offers a Kubernetes version this repo removed, and the plugin repo
still sends operators to a `hack/` script that was deleted.

This adds a **Downstream Repositories** trigger map to
`docs/agents/contributing.md` — per repository, which change here forces
a change over there and which file to touch — and a checklist in the PR
template that links to it. The template addresses AI agents directly,
since they author a large share of PRs, and tells them to open the
follow-up rather than tick a box that claims work nobody did.

A unit test (`hack/downstream-trigger-map.bats`) keeps the map from
rotting: it pins every in-repo path the map cites to a file that exists,
pins the map and the checklist to the same repository list, and pins the
CI carve-out that keeps the test running when the map is edited alone.
Its header is explicit about what it cannot check — whether a coupling
is real, and whether it still holds in the other repo, stays a human's
job.

### Downstream repositories

- [x] No downstream repository is affected by this change

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

* **Documentation**
* Expanded contributor guidance for downstream repository changes and
follow-up links.
* Clarified how to preserve PR template checklists when creating pull
requests.
  * Added detailed downstream repository trigger-map instructions.

* **CI**
* Documentation changes affecting the trigger map now run unit and
controller checks.

* **Tests**
* Added validation to ensure trigger-map paths, workflow wiring, and
repository lists remain consistent.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 13:08:01 +05:00
Aleksei Sviridkin 44507d2cd0 docs(agents): add downstream repository checklist to the PR template
Cozystack is upstream for repositories that nothing keeps in sync with
it, and no CI job compares the two sides, so a change here breaks them
in silence. Only two narrow paths are automated, and both fire on a
release rather than on a PR.

It has already happened. The website docs generator works from an app
list hardcoded in its own Makefile, and packages/apps/opensearch was
never added to it, so an application we ship has no reference page at
all. The Terraform provider still offers a Kubernetes version this repo
removed. The plugin repo still sends operators to hack/ scripts that
were deleted, one of them rewritten as a Go command.

Add a Downstream Repositories trigger map to the contributing guide,
naming per repository which change here forces a change over there and
which file to touch, and a checklist in the PR template that links to
it. The template addresses AI agents directly: they author a large share
of PRs and will otherwise tick a box without opening the follow-up it
claims.

The coupling is wider than the obvious ones, and mostly invisible. talm
restates the node contract, overlapping ours but already diverged, so it
must be diffed rather than overwritten. cozyhr reads an annotation the
operator writes and silently merges the wrong values if it is renamed.
cozy-proxy is vendored in from its own repo, yet consumes labels
produced here and quietly stops handling external VM services if they
move. The telemetry server queries metric names emitted here, and a
rename just empties its dashboards. None of these fail loudly.

Guard the map with a unit test, because a map about silent drift must
not drift silently itself. It pins every in-repo path the map cites to a
file that exists, pins the map and the checklist to the same repository
list, and pins the workflow carve-out below to the map's location, to
the job that reads the flag, and to the output that carries it: cutting
any one of those three leaves the other two looking wired up. The header
states plainly what the suite cannot check, so a green tick is not
mistaken for a correct map: whether a coupling is real, and whether it
still holds over there, stays a human's job.

Editing the map alone is a docs-only PR, which skips the unit tests and
would exempt the map from its own guard. Rather than widen the docs-only
rule, which also gates the Talos image build, the plan step exports a
separate flag that only the unit-test job reads: a docs PR now runs the
tests and still builds nothing. It detects the map by its old path too,
since a rename is reported as the new path alone.

Document that gh pr create --body and --body-file replace the body
wholesale, which would otherwise drop the checklist from every
CLI-authored PR.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 17:07:47 +03:00
Aleksei Sviridkin 6f5889816b feat(ci): guard migrations.targetVersion against silent skip (#2771)
## Summary

- Add `hack/check-migrations-target.sh` that fails when the latest
numbered file under
`packages/core/platform/images/migrations/migrations/` is not matched by
a bumped `migrations.targetVersion` in
`packages/core/platform/values.yaml`.
- Wire it into `unit-tests` via a new `migrations-target-check` Makefile
target so PR CI catches the regression mechanically.

`run-migrations.sh:34` iterates `seq $CURRENT_VERSION $((TARGET_VERSION
- 1))`, so a migration whose number equals the configured
`targetVersion` is silently skipped. The cost is state corruption on
upgrade — a class of bug worth eliminating with a 10-line check rather
than relying on reviewer vigilance.

## Test plan

- [x] `make migrations-target-check` passes on `main` HEAD
(`targetVersion=43`, latest migration `42`, `43 >= 43`).
- [x] Synthetic failure: `touch
packages/core/platform/images/migrations/migrations/99` makes the script
exit 1 with a clear error pointing at
`packages/core/platform/values.yaml` and the required bump.
- [x] Tested on macOS (BSD `ls` + mikefarah `yq`).

```release-note
NONE
```

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

* **Chores**
* Added a CI validation step to unit tests to ensure
`migrations.targetVersion` is kept in sync with newly added database
migrations, preventing upgrade scripts from skipping new migration
steps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 14:55:01 +03:00
Aleksei Sviridkin 9223f2ed0e feat(agents): block hardwrapped prose in markdown and GitHub bodies (#3297)
## What this PR does

Markdown renderers collapse a soft line break into a space, so a
paragraph hardwrapped at ~80 columns renders exactly like the same
paragraph on one line — while breaking narrow viewports, mangling nested
list and table rendering, and turning a one-word edit into a diff that
reflows the entire block. `AGENTS.md` now states the rule for markdown
files and for every body an agent publishes to GitHub, and names what
keeps its line breaks: code blocks, lists, tables, headings,
blockquotes, front matter, badge stacks, and explicit `<br>` breaks.
Commit message bodies keep their ~72-column wrap.

A documented rule alone does not survive the reflex it fights: an
~80-column wrap is *correct* in Go comments and commit bodies, where
coding agents spend most of their time, and it leaks from there into
documentation and GitHub. So the rule is also checked mechanically at
the moment of writing. `.claude/hooks/md-no-hardwrap.py` is a PreToolUse
hook that refuses a `Write`/`Edit`/`MultiEdit` adding a hardwrapped
paragraph, list item or blockquote to a markdown file, and a `gh`
command publishing one via `--body`, `--body-file`, a heredoc, or an
`api` body field.

Judging what an edit *adds* is the whole difficulty. Comparing the text
of each wrapped block refuses a typo fix inside a paragraph that was
already wrapped; counting the blocks lets a file be rewritten as
entirely fresh hardwrapped prose so long as the total does not rise. The
hook therefore asks about the break rather than the block: a soft break
is the edit's doing only when both lines it joins are lines the edit
wrote.

Whatever it cannot read with certainty, it allows — a body the shell
assembles at runtime, an unsplittable command line, a `--body-file` the
same command has yet to write. A guard that blocks legitimate work gets
switched off, and then it guards nothing. Vendored charts, published
changelogs and Go templates are out of scope, so
`hack/upload-releasenotes.sh` keeps publishing its historical release
notes.

The rule is scoped honestly: it governs prose this repository owns, and
new or changed paragraphs. Much of the tree predates it and is wrapped;
that is not a backlog to go and fix.

`hack/md-no-hardwrap.bats` covers both surfaces with 62 cases and is
picked up by `make unit-tests` automatically; it requires `python3`.

Note for anyone with a personal `.claude/settings.json`: `.gitignore`
previously ignored `.claude/` wholesale, and this PR starts tracking the
hook and its registration by name. Git overwrites a previously-ignored
file on pull without warning, so move personal overrides to
`.claude/settings.local.json`.

Named in honour of IvanHunters 🙂

### Screenshots

Not applicable — no UI changes.

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

* **New Features**
* Added automated checks to prevent newly introduced hardwrapped
Markdown prose during content edits and publishing commands.
* Reports detected formatting issues with affected line ranges and
guidance.
* Supports Markdown edits and relevant command-line publication
workflows while excluding code and other valid formatting patterns.

* **Documentation**
  * Added Markdown prose-formatting guidance for coding assistants.

* **Tests**
* Added comprehensive coverage for valid, invalid, excluded, and
malformed-input scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 14:47:15 +03:00
Aleksei Sviridkin a4bbc74b61 feat(agents): add md-no-hardwrap hook enforcing the prose rule
An ~80-column wrap is correct in Go comments and commit bodies, where
coding agents spend most of their time, and the reflex leaks from there
into markdown files and GitHub bodies. A documented rule alone does not
survive that reflex, so check it mechanically at the moment of writing.

The hook refuses a Write/Edit/MultiEdit that would add a hardwrapped
paragraph, list item or blockquote to a markdown file, and a gh command
that would publish one to GitHub via --body, --body-file, a heredoc, or
an api body field.

Judging what an edit ADDS is the whole difficulty. Comparing the text of
each wrapped block refuses a typo fix inside a paragraph that was
already wrapped; counting the blocks lets a file be rewritten as
entirely fresh hardwrapped prose so long as the total does not rise. So
the hook asks about the break rather than the block: a soft break is the
edit's doing only when both lines it joins are lines the edit wrote.

Whatever it cannot read with certainty, it allows -- a body the shell
assembles at runtime, an unsplittable command line, a path that is not a
regular file, a --body-file the same command has yet to write. A guard
that blocks legitimate work gets switched off, and then it guards
nothing. Vendored charts, published changelogs and Go templates are out
of scope entirely, so hack/upload-releasenotes.sh keeps publishing its
historical release notes.

Line breaks are only rejected inside prose. Code blocks, tables, HTML,
front matter, link reference definitions, footnotes, badge stacks and
explicit hard breaks -- two trailing spaces or a trailing backslash,
which render as <br> -- keep their breaks, because reflowing those would
change the rendered output. A heredoc is attributed to the command that
opened it, so a YAML manifest piped to kubectl is not read as a PR body.

The hook and its registration are tracked by name so contributors get
them on clone, while personal agent state stays ignored; personal
overrides belong in .claude/settings.local.json. hack/md-no-hardwrap.bats
covers both surfaces with 60 cases and make unit-tests picks it up
automatically.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 14:37:52 +03:00
Aleksei Sviridkin 395af115b5 docs(agents): require one continuous line per paragraph
Markdown renderers collapse a soft line break into a space, so a
paragraph hardwrapped at ~80 columns renders exactly like the same
paragraph on one line -- while breaking narrow viewports, mangling
nested list and table rendering, and turning a one-word edit into a
diff that reflows the entire block.

The rule covers prose this repository owns, in markdown files and in
every body published to GitHub: PR bodies, issue bodies, review
comments, release notes. It governs new and changed paragraphs; the
existing tree is wrapped in many places and is not a backlog of defects
to go and fix. Vendored upstream chart docs stay verbatim, published
changelogs stay as published, and commit message bodies keep the
~72-column wrap that git log and its tooling expect.

Structure that needs its line breaks is unaffected: code blocks, lists,
tables, headings, blockquotes, front matter, badge stacks, and explicit
hard breaks, which render as <br> and cannot be reflowed without
changing the output.

AGENTS.md opened with a wrapped paragraph of its own; reflow it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 14:34:22 +03:00
Aleksei Sviridkin f24b56327b fix(platform): route OIDC backend calls through the in-cluster Keycloak by default
authentication.oidc.keycloakInternalUrl defaulted to an empty string, which
left oauth2-proxy performing OIDC discovery against the external Keycloak
hostname. That request resolves through public DNS and lands on the root
ingress controller, which does not serve yet on a fresh install: the root
tenant's IngressClass only exists once tenant-root is ready, and tenant-root
is still reconciling while the dashboard starts. Discovery has nothing to talk
to, the gatekeeper fails, and the dashboard never comes up.

The bundled Keycloak is the only supported provider — the external hostname is
templated as keycloak.<host> and the realm is fixed — so backend-to-backend
calls have no reason to leave the cluster. Default the value to
http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy, the same Service
address keycloak-configure already uses. The dashboard and linstor-gui
gatekeepers then run with --skip-oidc-discovery and send token, jwks, userinfo
and logout requests to the Service, while browser redirects stay on the
external host. Those calls travel as plaintext HTTP between pods, as
keycloak-configure's admin calls to the same address already do.

The value remains overridable for a Keycloak that does not live at the standard
in-cluster address, and setting it back to an empty string restores external
discovery. Existing OIDC clusters that never set it get a gatekeeper rollout
onto the internal path on upgrade.

Enabling OIDC swaps the dashboard's token-proxy container for oauth2-proxy, so
the E2E install now drives that swap to completion: it waits for the auth-proxy
container to appear, then gates on the gatekeeper rollout. Readiness alone
proves nothing there — the HelmRelease is still Ready from the token-proxy
install, and the Deployment keeps the old ReplicaSet Available while a new pod
crashloops, so only the rollout catches a regression back to external discovery.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 14:22:46 +03:00
Aleksei Sviridkin caf4645cf2 docs(harbor): fix SeaweedFS discovery key described as annotation
The prerequisites section described the resolved SeaweedFS class as being
surfaced on the tenant namespace as the `namespace.cozystack.io/seaweedfs`
annotation. The tenant chart renders that key under `metadata.labels`,
alongside the sibling `/etcd`, `/ingress`, `/gateway`, `/monitoring` and
`/host` keys; no annotation of that name exists.

The wrong noun sends an operator to the wrong field. When a Harbor release
stays unreconciled waiting on `BucketInfo`, the documented check is to
confirm the tenant chain resolved a SeaweedFS class. Looking for it in
`.metadata.annotations` returns nothing even on a correctly configured
tenant, which invites the wrong conclusion that the tenant chain is broken
and sends the investigation away from the actual cause.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 13:45:19 +03:00
Aleksei Sviridkin 5d881f111a fix(fluxcd): omit empty distribution.artifact in FluxInstance for guest clusters (#3284)
## What this PR does

Guest Kubernetes clusters (`apps/kubernetes`) with the `fluxcd` addon
enabled never became
`Ready`. The `flux-instance` template renders
`spec.distribution.artifact` unconditionally,
unlike the sibling optional fields (`artifactPullSecret`,
`imagePullSecret`, `variant`, which
are all `{{- if }}`-guarded). The umbrella default is empty (`artifact:
""`) so the operator
uses its embedded manifests, which is the intended air-gapped default
(#964, and the upstream
recommendation). But an empty value renders as YAML `null`, and the
flux-operator v0.50.0 CRD
types `distribution.artifact` as a non-nullable `string` (pattern
`^oci://.*$`), so the API
rejects the `FluxInstance`.

Result: the addon's HelmRelease looped install/uninstall
(`remediation.retries: -1`), the
parent `kubernetes` release stayed `InProgress`, and the cluster CR was
stuck `Ready=Unknown`.

This guards the field so an empty value is omitted entirely. Behaviour:

- empty (default) -> field omitted -> operator falls back to embedded
manifests: **air-gapped
default preserved**, and the CRD accepts it (artifact is not in
`distribution.required`);
- non-empty -> renders as before; `valuesOverride` still applies.

The template is a vendored subchart (`make update` runs `helm pull`), so
the change is carried
as `patches/guard-distribution-artifact.diff` and applied by the
`update` target, matching the
convention of the sibling `fluxcd-operator` package (`patch -p1 <
patches/*.diff`).

Verified with `helm template packages/system/fluxcd`:

- default values -> no `artifact:` line (before this PR: `artifact:
null`);
- `--set
flux-instance.instance.distribution.artifact=oci://example.com/x:1` ->
renders.

The affected path is off by default (`addons.fluxcd.enabled: false`) and
not exercised by the
in-repo CI values, which is why default e2e stayed green.

Closes #3283

### Release note

```release-note
fix(fluxcd): guest Kubernetes clusters with the fluxcd addon enabled no longer fail to install; an empty distribution artifact is now omitted so the operator uses its embedded manifests (air-gapped default preserved) instead of rendering an invalid null
```


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

* **Bug Fixes**
* Prevented Flux instance configuration from including an empty
distribution artifact value, which could cause validation failures.
* Improved the generated FluxInstance manifest so the artifact field is
omitted entirely when no distribution artifact is provided.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 13:18:29 +03:00
Ivan Okhotnikov da47b70e2c fix(fluxcd): omit empty distribution.artifact in FluxInstance
The flux-instance template rendered spec.distribution.artifact
unconditionally, unlike the sibling optional fields (artifactPullSecret,
imagePullSecret, variant). The umbrella default is empty to use the
operator's embedded manifests (the air-gapped default from #964), but an
empty value renders as YAML null. In the flux-operator v0.50.0 CRD the
field is optional yet typed 'string' with pattern ^oci://.*$ and is not
nullable, so the rendered null (like an empty string) fails schema
validation and the API rejects the FluxInstance.

Guest Kubernetes clusters with the fluxcd addon enabled could therefore
never install it: the sub-release looped install/uninstall, the parent
kubernetes release stayed InProgress, and the cluster never reached Ready.

Guard the field so an empty value is omitted (the operator falls back to
its embedded manifests, preserving the air-gapped default) while a
non-empty artifact still renders. The template lives in the vendored
subchart, so the change is carried as a patch applied by 'make update'.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-14 13:10:59 +03:00
IvanHunters 3380bb4ab6 refactor(hack): replace ls|grep with pure-bash glob (shellcheck SC2010)
Filenames in packages/core/platform/images/migrations/migrations/ are
numeric-only by convention so the original `ls -1 | grep -E` filter was
safe in practice, but the cleaner pure-bash form is equivalent and
silences SC2010 without an annotation.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-14 13:08:54 +03:00
IvanHunters c3d3e2596e feat(ci): guard migrations.targetVersion against silent skip
Adds hack/check-migrations-target.sh and wires it into unit-tests via the
migrations-target-check Makefile target. The check fails when the highest
numbered file under packages/core/platform/images/migrations/migrations/
is not matched by a bumped migrations.targetVersion in
packages/core/platform/values.yaml.

run-migrations.sh:34 loops `seq $CURRENT_VERSION $((TARGET_VERSION - 1))`,
so a migration whose number equals targetVersion never runs and the
intended state change is silently skipped on cluster upgrade.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-07-14 13:08:54 +03:00
Aleksei Sviridkin f478915935 fix(dashboard): unbreak CORS on expired session for k8s API calls (#2788)
## What this PR does

Fix two related issues that cause the dashboard SPA to break with CORS
errors once the `kc-access` cookie expires.

1. `KeycloakClient` for dashboard had no `webOrigins` set, so the
operator did not touch the field in Keycloak after initial creation.
After any `rootUrl` change the existing `webOrigins` value got stuck at
an old hostname and blocked CORS from the SPA. Setting it to `+` makes
Keycloak derive the allowed origins from `redirectUris`.

2. `oauth2-proxy` answered `302` (redirect to Keycloak `/auth`) for
unauthenticated XHR/fetch on `/api` and `/apis`. The login page does not
emit `Access-Control-Allow-Origin`, so the browser blocked the fetch
with a CORS error and the SPA had no way to recover. Adding
`--api-route` makes `oauth2-proxy` answer `401` without a redirect for
these paths, so the SPA can handle re-auth itself via a top-level
navigation.

### Release note

```release-note
fix(dashboard): dashboard SPA no longer fails with CORS errors after the kc-access cookie expires; oauth2-proxy returns 401 (not 302) for /api and /apis, and the KeycloakClient declares webOrigins: ["+"].
```

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

## Summary by CodeRabbit

## Chores

* Updated API routing configuration to properly handle requests to API
endpoints
* Enhanced web origins configuration for improved authentication system
compatibility

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 13:07:13 +03:00
IvanHunters 0044221d31 fix(apps/vpn): remove invalid foo field from urls Secret (#3281)
## What this PR does

Removes a leftover debug field `foo` from the `<release>-urls` Secret
template in the VPN app chart.

The `foo` field is not part of the `core/v1` Secret schema (which only
allows `data`, `stringData`, `type`, `immutable`, and `metadata`). It
went unnoticed under client-side apply, but under server-side apply the
stricter schema validation rejects it with:

```text
.foo: field not declared in schema
```

As a result, any VPN application upgrade fails and the release is left
in `UpgradeFailed` state.

### Release note

```release-note
fix(apps/vpn): remove invalid `foo` field from the urls Secret that broke Helm upgrades under server-side apply
```


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

## Summary by CodeRabbit

* **New Features**
  * Added per-user Shadowsocks connection links to the VPN secret.
* Links include the configured encryption method, host, port, and
connection name for easier client setup.

* **Bug Fixes**
  * Removed obsolete serialized password data from the VPN secret.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 12:50:17 +03:00
Andrey Kolkov bf69eff7a5 feat(keycloak): add S3 backup for the CNPG database (#3174)
## What this PR does

Adds optional scheduled S3 backups for the `keycloak-db` CloudNativePG
cluster.

When `backup.enabled=true` with a `backup.destinationPath`, the Cluster
gets
`spec.backup.barmanObjectStore` (gzip-compressed data + WAL streams, a
configurable `retentionPolicy`) and a `ScheduledBackup` drives recurring
base
backups. S3 credentials are either inlined into a rendered
`keycloak-db-backup-s3-creds` Secret, or referenced via
`backup.existingSecretName` (a Secret carrying `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY`).

Disabled by default — no change to existing deployments; the object
store
coordinates and credentials are supplied per-environment by the
operator.

This is the Keycloak/Postgres half of backing up a Cozystack portal
control
plane. The portal-side etcd backups (system Talos etcd + api-server
etcd) live
in the companion downstream PR aenix-org/cozyportal#1008.

### Release note

```release-note
feat(keycloak): add optional S3 backups (CNPG Barman) for the Keycloak database, configurable via `backup.*` values and disabled by default.
```


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

* **New Features**
* Added configurable scheduled backups for the Keycloak CNPG database to
an S3-compatible object store, including destination prefix, optional
endpoint URL, retention policy, and cron schedule.
* Backups and scheduled backup resources are only created when all
required backup settings are provided, avoiding incomplete
configuration.
* Supports backup credentials via inline values (creates the needed
Secret automatically) or via an existing Secret reference.
* **Documentation**
* Added guidance on the cron schedule format required for the scheduler.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 11:49:30 +04:00
Ivan Okhotnikov 70a2284551 fix(apps/vpn): remove invalid foo field from urls Secret
A leftover debug field "foo" in the <release>-urls Secret template
violates the core/v1 Secret schema. It went unnoticed under client-side
apply but breaks Helm upgrades under server-side apply with
".foo: field not declared in schema", leaving the release in
UpgradeFailed state. Drop the debug block.

Signed-off-by: Ivan Okhotnikov <xorokhotnikov@gmail.com>
2026-07-13 23:32:24 +03:00