mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 18:47:04 +09:00
ci/parallel-container-disk
335
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7cc5df5139 |
Prepare release v1.5.2
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
22d9f224f9 |
Prepare release v1.5.1
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
e1aaa90ed3 |
Prepare release v1.5.0
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
aeaedca4a4 |
ci: consolidated platform & e2e stabilization batch (#2948)
## What Consolidated landing branch for a set of interdependent platform/CI stabilization fixes. It began as the source PRs in the table below (in dependency order) and has since grown with review-driven fixes and a few production-behavior changes — the full current inventory is under **Scope** below. Landing them as one unit lets CI run against the real combined state instead of a fragile bottom-up merge train. ## Source PRs (dependency order) | # | Commit | Source PR | Addresses | |---|--------|-----------|-----------| | 1 | fix(capi): startupProbe on capi-controller-manager | #2946 | capi crashloop during cert provisioning | | 2 | test(e2e): bucket.bats port-forward + S3 client reliability | #2944 | flaky bucket test | | 3 | fix(seaweedfs): restore -lock BucketClass, s3 svc name, drop volumeSizeLimitMB | #2943 | missing bucket/access classes | | 4 | test(e2e): in-cluster Cilium endpoint-leak healer (install + apps) | #2874 | cilium "IP already in use" leak | | 5 | refactor(build): mode=max registry cache + main-only warmer | #2938 | build cache | | 6 | ci(build): isolate each PR build on its own ephemeral runner VM | #2939 | shared-buildkit contention | | 7 | fix(e2e): LINSTOR post-install waits on a single 15m deadline | #2928 | LINSTOR provisioning timeouts | | 8 | test(metallb): assert digest-pinned image form, not version literal | #2873 | brittle metallb assertion | ## Why consolidated These form a dependency DAG (verified from CI logs): e.g. 2943 needs 2946, 2938 needs 2874, 2928 needs 2938/2939, 2873 needs 2928. Tested bottom-up, each lower PR runs with none of the fixes above it and can't go green alone. This branch carries all of them, so CI runs against the real combined state. ## Verification status CI is green on the latest head (`5653c30`): full-suite E2E passes end-to-end and is reproducible (2 of 3 runs on this SHA green). The one red run was a LINSTOR tie-breaker / DRBD-metadata infra flake on the sandbox, unrelated to the diff — a different environmental subsystem failed each run (details in the comments below). The earlier 3-hour crust-gather snapshot hang is fixed in `5653c30`. ## Scope beyond the original 8 The branch has grown past the 8 source PRs above with review-driven fixes and a few production-behavior changes. For reviewer transparency, the full set: **Review fixes (@lexfrei review):** - **B1 (blocker)** — `8f41910` converts the SeaweedFS `-lock`/`-readonly` BucketClasses and the s3 service-name override into `patches/`, wired into `make update`, so a re-vendor no longer drops them. - **FU1** — `cc99230` grants the cilium leak-healer `delete` on pods (both delete remedies were RBAC-forbidden). - **FU3** — `014de3f` adds a unit test for the HelmRelease update conflict-retry path. - **FU4** — `6deddc6` makes the leak-healer refuse a disconnect for any non-terminal owner phase, not just `Running`. - **FU7** — `f3914d2` sets `persist-credentials: false` on the build-cache checkout (SHA-pinning is handled repo-wide in #2849). **Production-behavior changes (not in the table above):** - VPA `updateMode: Auto → Initial` for etcd (`6e9ff90`) and monitoring (`0997105`), plus `vmselect`/`vmstorage` `minAllowed` floors — stops install-time eviction churn. - cozystack-api: `retry.RetryOnConflict` on the Application→HelmRelease update path (`728f676`). **E2E robustness follow-ups:** per-test crust-gather snapshots (`50e5f94`), EtcdBackupSchedule wait (`763b85e`), tenant-node single-deadline wait (`9e43d45`), harbor BucketClaim 10m budget (`d7f244f`), tenant API via LoadBalancer (`9beda4e`), crust-gather pin (`95f42aa`). **Not addressed (by decision):** FU2 — the s3 Service keeps `seaweedfs.name`-based naming via patch (rendered output unchanged; cozystack renders `fullname == seaweedfs`, so it matches the `componentName`/`fullname` siblings in practice). FU6 — fork PR build push is handled by the existing mirror-to-same-repo-branch workflow. ## Relates to #2946 #2944 #2943 #2874 #2938 #2939 #2928 #2873 — if this lands, those can be closed; otherwise they remain the granular per-PR review path. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added CI build cache warming for main branch builds. * Added a best-effort in-cluster Cilium endpoint self-heal watchdog for e2e installs. * Extended SeaweedFS COSI with object-lock support plus separate readonly access. * **Improvements** * Reduced e2e install flakiness with a shared readiness deadline, safer waits, and improved cleanup/diagnostics capture. * Prevented install-time churn by switching VPA update mode to **Initial**. * Added container startup probes and updated SeaweedFS volume sizing. * **Tests** * Improved e2e robustness (etcd backup schedule waiting, digest-pinned image checks, and corrected S3 port-forwarding). <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
6e9ff908ed |
fix(etcd): set VPA updateMode to Initial to stop member-evicting churn
Auto mode evicts running etcd members to apply VPA recommendations, churning the StatefulSet (disruptive to quorum and a Cilium IP-reuse-leak trigger under load). Initial sizes at pod creation without evicting; the existing 250m/256Mi minAllowed floor covers the no-runtime-resize tradeoff. Same eviction-churn class as the monitoring VPA fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
d3958c5bcb |
refactor(build): mode=max registry cache with a main-only warmer (#2938)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
9e3ae7447b |
fix(ingress): keep tls section in operator wildcard mode
Dropping the whole tls block in wildcard mode disabled the forced HTTP-to-HTTPS redirect: ingress-nginx only issues the 308 redirect when TLS is enabled for the Ingress, while a host without a tls section is still served on 443 with the default certificate but stays reachable over plain HTTP. Keep the tls section with the host list and omit only the per-host secretName, so ingress-nginx falls back to the operator wildcard configured as the default SSL certificate and the redirect stays enforced, matching the non-wildcard behaviour. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
b1f5d9579c |
feat(ingress): serve operator wildcard via default SSL certificate
When publishing.certificates.wildcardSecretName is set, the root tenant's ingress-nginx controller now serves the operator-supplied wildcard Secret as its --default-ssl-certificate, and the platform service Ingresses stop requesting their own per-host ACME certificates. The flag is set only on the publishing controller (Release.Namespace == expose-ingress) because ingress-nginx loads the Secret from its own namespace; child-tenant controllers cannot reach a Secret in another namespace, so they keep the existing ACME path. Each service Ingress (dashboard, keycloak, grafana, alerta, bucket, linstor-gui, harbor, seaweedfs S3, bootbox) drops its cert-manager cluster-issuer annotation, the http01 challenge-class annotation, and its tls block in wildcard mode; nginx then falls back to the default certificate for those hosts. Other annotations and routing are unchanged. Each file keeps its existing render guard intact, and the annotations block collapses cleanly where the cert annotations were its only content. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
6e219c241b |
feat(platform,gateway): distribute operator wildcard secret name
Add publishing.certificates.wildcardSecretName to the platform chart and propagate it through the cozystack-values channel as _cluster.wildcard-secret-name. Only the Secret NAME travels — the cert/key material is never broadcast, unlike the public kube-root-ca that already rides this channel. When the key is set, the gateway chart switches the rendered TenantGateway to certMode=existingSecret with a wildcardSecretRef pointing at the operator-supplied Secret, and skips the ACME solver/provider/issuer validations that no longer apply. Wildcard mode takes precedence over solver=dns01. TLS-passthrough services and attached namespaces continue to render unchanged. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
8df4b0b0f1 |
feat(ingress): add proxyProtocol knob to ingress-nginx
Expose a proxyProtocol value on the Ingress application that turns on ingress-nginx use-proxy-protocol with real-ip-header=proxy_protocol and enable-real-ip. The host control plane also enables it through the _cluster.proxy-protocol switch, applied only to the external-facing ingress (the namespace named by expose-ingress). use-forwarded-headers / compute-full-forwarded-for are deliberately not set: without a paired proxy-real-ip-cidr they let clients spoof X-Forwarded-For, and the PROXY-protocol frame is already the source of truth for the client address. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
9868c43e53 |
fix(build): read --cache-from from ghcr :latest so PR builds warm-start
PR #2711 switched PR builds to push only :pr-<N>-<sha> tags (PUBLISH_FLOATING=0) to stop concurrent-push 409 conflicts, but left --cache-from pointing at $(REGISTRY)/<img>:latest. PR builds set REGISTRY to the per-CI registry (iad.ocir.io) and never publish :latest there, so every PR build's registry cache lookup 404s and rebuilds cold. Add a CACHE_REGISTRY knob (default ghcr.io/cozystack/cozystack, where release builds do publish :latest) and point every --cache-from at it. Push targets ($(REGISTRY)) and the unique per-PR tags are unchanged, so the #2711 anti-conflict behaviour is preserved while PR builds regain a warm cache from the last release. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
7857282d66 |
chore(seaweedfs): bump seaweedfs-cosi-driver to v0.3.1
v0.3.1 ships the stale-socket self-heal: the driver removes a leftover COSI unix socket before binding, so the objectstorage provisioner recovers from CrashLoopBackOff after a non-graceful exit (SIGKILL / OOM / panic) instead of wedging on "bind: address already in use". Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
3fe74ba68e |
fix(seaweedfs): drop deprecated upgrade.force from seaweedfs-system HR
The flux-bump-v048 base branch removed upgrade.force from every other
HelmRelease in
|
||
|
|
29c6afc84d |
refactor(seaweedfs): split seaweedfs-system into seaweedfs-db + seaweedfs-system
Splits the seaweedfs-system HelmRelease in two so the CNPG Cluster/seaweedfs-db lives in its own HR (seaweedfs-db), and the application HR (seaweedfs-system) dependsOn it. The new HR uses Flux v2 HelmRelease.spec.healthCheckExprs with a CEL expression on Cluster.status.conditions[type=Ready] plus waitStrategy.name: poller, so its Ready=True only flips after the postgres primary is actually serving connections — not just after helm install applied the Cluster CR. This eliminates the seaweedfs-filer CrashLoopBackOff race on a fresh tenant install. With Cilium kubeProxyReplacement: true, socket-LB returns EPERM from connect(2) to ClusterIPs with no Ready endpoints. Pre-split, the filer StatefulSet scheduled concurrently with the CNPG bootstrap (~55–70s of unavailable postgres), each connect() failed EPERM, kubelet exponential restart backoff pushed past the e2e bats 'kubectl wait hr/seaweedfs-system --timeout=2m' window, and the 'Configure Tenant and wait for applications' test failed. healthCheckExprs uses Flux's three-predicate form: route ClusterIsNotReady (the CNPG bootstrap condition) to `inProgress` so the HR keeps polling, keep `failed` for other Ready=False reasons, and rely on the HR `timeout: 10m` as the real backstop for a genuinely stuck cluster. A plain Ready=False predicate would flag the HR failed within seconds of creating the Cluster CR. Migration 42 adopts existing Cluster/seaweedfs-db resources into the new release on upgrade by rewriting meta.helm.sh/release-name and stamping helm.sh/resource-policy: keep so the seaweedfs-system upgrade (which no longer renders the Cluster) does not delete it during the transition. The application HR's dependsOn collapses the db dependency (unconditional) with the existing ingress dependency (guarded by `if eq $ingress .Release.Namespace` so sub-tenants that inherit ingress from a parent namespace don't deadlock on a non-existent local HR) into one list. Requires Flux v2.8.x (helm-controller v1.5.0+) for healthCheckExprs. Provided by the parent PR #2602 in this chain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
7f94182a87 |
fix(platform): drop deprecated upgrade.force from HelmReleases; fix kafka WorkloadMonitor replicas paths
Flux v2.8 / helm-controller v1.5.0 embeds Helm v4, which renamed --force
to --force-replace and rejects combining it with SSA's --force-conflicts.
Existing HelmReleases with spec.upgrade.force: true fail every upgrade with
"cannot use force conflicts and force replace together", looping
upgrade -> rollback -> upgrade and blocking dependants. Observed on PR #2619
CI for tenant-root/{ingress-nginx-system,seaweedfs-db}, tenant-test/info,
and others.
Per Flux docs, upgrade.force has been a no-op since v2.8 anyway, so removing
it is safe on green clusters and unblocks broken ones.
Also fix packages/apps/kafka/templates/workloadmonitor.yaml: it referenced
.Values.replicas (no such field; values.yaml has kafka.replicas and
zookeeper.replicas). Flux v2.7 silently dropped the null; v2.8 SSA forwards
it, and the WorkloadMonitor CRD rejects "spec.replicas: null" as not
type:integer, which blocks every kafka-test install.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
(cherry picked from commit
|
||
|
|
edb3eeb011 |
fix(info): use root-host for Keycloak OIDC issuer URL in tenant kubeconfig (#2704)
## What this PR does The kubeconfig generated by the `info` package for non-root tenants pointed `--oidc-issuer-url` at `keycloak.<TENANT>.<ROOT-DOMAIN>`, but Keycloak's ingress and cert live at `keycloak.<ROOT-DOMAIN>` (built from `_cluster.root-host`). `kubectl oidc-login` then failed TLS verification against the nginx-ingress default fake cert, so the auto-generated kubeconfig was unusable for any non-root tenant. `$host` was being sourced from `_namespace.host` (tenant-specific subdomain) with a lookup-based override that tried to read `tenant-root`'s `spec.values.host`. That override has been dead since #1787 migrated `tenant-root` to `valuesFrom` (Secret-backed) — `spec.values` is now always empty, so the override never fires. Source `$host` from `_cluster.root-host` directly, matching what `packages/system/dashboard/templates/gatekeeper.yaml` already does for the same Keycloak realm. Verified with `helm template tenant1 packages/extra/info -n tenant-tenant1 --set-string _namespace.host=tenant1.example.org --set-string _cluster.root-host=example.org ...` — issuer URL is now `https://keycloak.example.org/realms/cozy` regardless of the per-namespace host. ### Release note ```release-note fix(info): use root-host for the Keycloak OIDC issuer URL in tenant kubeconfigs so `kubectl oidc-login` succeeds against the real Keycloak cert on non-root tenants ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated kubeconfig generation to consolidate host configuration handling, now using a single authoritative source instead of multiple fallback mechanisms. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2704?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
3756f5c459 |
feat(networking): Gateway API support via Cilium (supersedes #2213) (#2470)
## What this PR does
Replaces ingress-nginx as the default ingress layer for cozystack-native
services with **Cilium Gateway API**, materialised per-tenant via a new
`TenantGateway` CRD reconciled by `cozystack-controller`. Supersedes
#2213 / #2208. Stacked on top of #2468.
## Architecture
The chart renders one `gateway.cozystack.io/v1alpha1 TenantGateway` CR
per tenant that owns its own Gateway. The cozystack-controller
reconciles the actual `Gateway`, per-tenant `Issuer`, `Certificate`
(wildcard or per-listener), and HTTP→HTTPS redirect `HTTPRoute` from
there. Helm does not render `Gateway` or `Certificate` directly — that
prevents the Helm-vs-controller race on `Gateway.spec.listeners` that
route-driven listener additions would otherwise cause.
```
┌── TenantGateway CR (rendered by extra/gateway chart for owning tenants)
│
└──▶ cozystack-controller reconciles:
├─ Gateway (with dynamic listeners, label-based allowedRoutes)
├─ Issuer (per-tenant ACME account)
├─ Certificate(s) — wildcard + per-child SANs in DNS-01, per-listener in HTTP-01
├─ HTTPRoute (controller-owned http→https redirect)
├─ Namespace label patching (cozy-* system namespaces from spec.AttachedNamespaces)
└─ Watches HTTPRoute / TLSRoute attachments to materialise listeners
```
## Inheritance: tenants attach by default, opt into their own Gateway
A tenant gets a dedicated Gateway (own LB Service, own LB IP, own
Certificate) only when it explicitly asks via
`tenant.spec.gateway=true`. Every other tenant in the tree publishes
through the Gateway of the nearest ancestor that owns one — same shape
as the existing `_namespace.ingress` inheritance.
The `apps/tenant` chart writes a `namespace.cozystack.io/gateway` label
on each tenant namespace, carrying either the tenant's own name (when
owning a Gateway) or the inherited ancestor name (when inheriting). The
Gateway's listener `allowedRoutes.namespaces.selector` is keyed on that
label, so the same selector admits routes from every namespace pointing
at the owner — descendants and cozy-* system namespaces alike.
cozystack-controller patches the same label onto every namespace in
`tgw.Spec.AttachedNamespaces` and garbage-collects labels it wrote when
an entry is removed from the attach list (annotation-gated GC so labels
written by the apps/tenant chart are never stripped).
In DNS-01 mode, the controller extends the parent's wildcard Certificate
with `<child-apex>` + `*.<child-apex>` SANs per inheriting child, and
adds a `*.<child-apex>` HTTPS listener per child apex referencing the
same cert. Without this expansion the parent's single-level wildcard
cannot match a child route's hostname (`harbor.alice.example.org` is two
labels past the parent's `*.example.org`).
Opt-out into own Gateway is the right choice when: a tenant needs its
own LB IP (DNS pinned, firewall rule), the apex is not derived from the
parent (custom `host` value the ancestor's cert can't cover), or the
tenant wants its own ACME account / cert authority. Otherwise leave
`gateway` unset and inherit.
## End-to-end flow
1. Platform: `gateway.enabled=true` toggles cert-manager solver,
switches per-system-app templates from Ingress to HTTPRoute / TLSRoute.
2. Per tenant: explicit `tenant.spec.gateway=true` renders a
`TenantGateway` CR and triggers the controller to materialise the owner
Gateway. Tenants with the field unset inherit through
`_namespace.gateway` propagation in the tenant chart.
3. Adding a published app under any tenant (owner or inheriting) is just
deploying its HTTPRoute. The controller picks up the hostname, adds a
per-listener Certificate (HTTP-01) or extends the wildcard cert's SANs
(DNS-01), and updates the owner Gateway's listeners.
Defaults stay legacy: `gateway.enabled=false`,
`tenant.spec.gateway=false`. Existing clusters upgrade unchanged.
## External IP allocation — mechanism-agnostic
The per-tenant Gateway's auto-created `LoadBalancer` Service draws its
IP from whatever LB allocator the cluster admin has configured at the
platform layer — same shape as ingress-nginx today. The tenant API stays
mechanism-agnostic — no `gatewayIP` field, no allocator-specific
manifest in the tenant chart. Cozystack itself ships MetalLB installed
but does not render any `IPAddressPool` / `L2Advertisement` /
`BGPAdvertisement` from this chart; admins set up the allocator that
suits their environment (MetalLB pool with L2 / BGP, Cilium LB-IPAM with
announcer, robotlb against a cloud provider, or
`Service.spec.externalIPs` pinning).
If a tenant needs a specific address (DNS already pinned, firewall rule,
etc.), the operator pre-allocates it on the admin side: either
pre-create the Service with `loadBalancerIP` set, or hand the tenant a
reference to a named admin-managed pool. Per-Service IP uniqueness is
the allocator's responsibility — same as for any other LoadBalancer
Service.
## Cert mode: HTTP-01 (default) vs DNS-01 (opt-in)
`publishing.certificates.solver` selects how the controller sources TLS
certs.
**HTTP-01 (default)** — per-app HTTPS listener + per-app Certificate.
New apps require zero platform-side config: deploy the HTTPRoute,
controller does the rest. Inheriting children's hostnames pick up
per-listener certs the same way the owner's own apps do.
**DNS-01 (opt-in)** — single wildcard Certificate (`<apex>` +
`*.<apex>`) covering every published app under the apex, plus per-child
SANs (`<child-apex>` + `*.<child-apex>`) for every inheriting tenant.
Pick this for clusters where many apps share the apex and Let's Encrypt
rate limits matter. The DNS provider account must be able to write TXT
records under every apex level the parent serves — for deeply-nested
inherited children that requires zone delegation or a wide-scope
provider credential.
| `publishing.certificates.dns01.provider` | Required
`publishing.certificates.dns01.<provider>` keys |
| --- | --- |
| `cloudflare` (default) | `cloudflare.secretName`,
`cloudflare.secretKey` |
| `route53` | `route53.region`, `route53.secretName` (and `accessKeyID`
if not on IRSA) |
| `digitalocean` | `digitalocean.secretName` |
| `rfc2136` | `rfc2136.nameserver`, `rfc2136.tsigKeyName`,
`rfc2136.secretName` |
The platform chart wires every provider's keys into `_cluster.dns01-*`;
the per-tenant gateway chart and the cluster-wide `cluster-issuers.yaml`
both read them.
## Strengthens admission coverage on apps.cozystack.io/*
The `cozystack-tenant-host-policy` probe in `gateway.bats` initially
looked like a flake — the apply that was supposed to be VAP-rejected
just succeeded. Investigation traced it to the custom REST handler at
`pkg/registry/apps/application/rest.go`. genericapiserver hands every
storage method a callback into the validating admission chain:
`createValidation` for Create, `updateValidation` for Update,
`deleteValidation` for Delete. `Update` was correctly invoking its
callback since
|
||
|
|
2cb16d5ae6 |
refactor(build): standardize image tagging to fix concurrent PR push conflicts (#2711)
## What this PR does Concurrent PR builds were racing in the OCIR registry. Every package Makefile tagged its images via `settag`, which on any non-release commit collapsed to `:latest` — so every image across every PR was pushed to the same tag, and parallel builds got 409s on the manifest PUT. Two outliers (`metallb`, `redis-operator`) used hardcoded version tags and collided across PRs unconditionally. This PR replaces `settag` and the `TAG=latest` fallback in `hack/common-envs.mk` with a single `image-tags` macro driven by three env vars: | Env | Meaning | |---|---| | `IMAGE_TAG` | Build-unique tag, always pushed | | `PUBLISH_VERSIONED` | Also push `:<component-version>` (when provided) | | `PUBLISH_FLOATING` | Also push `:latest` | Wired into the two workflows: - **`pull-requests.yaml`** — `IMAGE_TAG=pr-<N>-<sha>`, both `PUBLISH_*` off. Every PR build pushes a unique tag set; no collisions, no `:latest` move. - **`tags.yaml`** — `IMAGE_TAG=<ref_name>`, both `PUBLISH_*` on. Release output preserved (component versions + `:latest`) plus a uniform `:<git-tag>` handle on every image for traceability. - **Local** — defaults to `IMAGE_TAG=dev`, no flags, `PUSH=0`. All 30 package Makefiles converted to `$(call image-tags,<repo>,<versioned-tag>)`. Image consumers reference digests pinned into `values.yaml` by each Makefile, so the tag is decorative for cluster-side pulls — the build-unique tag is sufficient for E2E. Also drops the redundant `:<component-version>-<git-tag>` form (cilium, linstor, matchbox, ubuntu-container-disk, kubevirt-*, cluster-autoscaler). Nothing in the repo or downstream values.yaml consumed it; the same information is preserved via the new `:<git-tag>` plus the existing `:<component-version>` tags on release. `ubuntu-container-disk` builds a separate image per Kubernetes version in a loop, so its primary tag became `:<k8s-ver>-<IMAGE_TAG>` to prevent loop iterations within a single build from pushing to the same tag. Verified by dry-running `make -n image` across representative packages (simple, dual-tag, skopeo, flux-push, loop) under all four contexts (PR / release / main / dev) — tag sets are unique and behave as expected. ### Release note ```release-note refactor(build): PR CI now pushes images under a unique `pr-<N>-<sha>` tag per build instead of `:latest`. Concurrent PR builds no longer race in the registry. Release builds keep the existing `:<component-version>` and `:latest` tags and additionally publish a uniform `:<git-tag>` handle across every image. The redundant `:<component-version>-<git-tag>` tag (cilium, linstor, matchbox, ubuntu-container-disk, kubevirt-*, cluster-autoscaler) is no longer published — image references in values.yaml are digest-pinned and were the only legitimate consumers. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Centralized and simplified container image tagging across build workflows. * PR builds now use unique image tags derived from PR metadata for better isolation. * Added configurable flags to control publishing of versioned and floating tags, making release tagging behavior more predictable. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2711?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
6e0c2d609d |
docs(gateway): describe inheritance model and DNS-01 SAN expansion
Replace the 'Inheritance from parent Gateway' known-limitation entry (which described inheritance as deferred) with: * A top-of-README 'Inheritance: when to opt in for a separate Gateway' section explaining when a tenant owns its Gateway vs inherits, and how the namespace.cozystack.io/gateway label is written by apps/tenant chart and patched onto cozy-* by the controller. * DNS-01 cert-mode docs spell out SAN expansion for child apexes: the wildcard Certificate now carries <child-apex> + *.<child-apex> per inheriting tenant, with a per-child *.<child-apex> listener on the parent Gateway. Documents the DNS-provider-permission requirement for deeply-nested children (ACME challenge writes TXT under each apex zone, so zone delegation or wide-scope provider creds are needed for grandchild apexes). * New known-limitation entries: 64-listener cap on the parent Gateway under inheritance (with the high-fanout escape via tenant.spec.gateway=true), and Cilium sharing-key port-collision (multiple per-tenant Gateways still each take their own LB IP until ListenerSet ships in Cilium). Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
18fd155d2b |
feat(external-dns): enable gateway-tlsroute alongside gateway-httproute
The Gateway API source-flag toggle (gatewayAPI=true) already enabled gateway-httproute discovery. cozystack ships TLSRoute attachments for SSL-passthrough services (kubernetes control plane API, kubevirt CDI uploadproxy, kubevirt VM exportproxy) — without gateway-tlsroute in the external-dns sources list those hostnames are never picked up for DNS record management. Adds gateway-tlsroute under the same conditional and updates the value description / schema / README to reflect that the toggle now covers both route kinds. Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
7c893bc653 |
docs(gateway): security model as three groups, mechanism-agnostic IP allocation
Security model section split into three groups by who they defend against, anchoring the framing in the apps.cozystack.io/* tenant API surface (tenants do not write Gateway API resources directly, so most of the seven layers are not protecting against tenant-user input): - tenant-user-input gates: Layer 4 (cozystack-tenant-host-policy) via cozystack-api admission. Tenant.spec.host is the user-supplied field that surfaces as a security boundary. - defense-in-depth: Layers 1, 2, 5, 6, 7. Cover bugs in cozystack-controller / Flux, supply-chain compromise of an app chart, confused-deputy admin mistakes. Fail-closed. - admin-against-themselves: Layer 3 (cozystack-gateway-attached- namespaces-policy). Catches kubectl edit on the platform Package. Layer 7 wording fix: drop the implication that a tenant user with HTTPRoute RBAC could exploit the cross-apex hostname surface — that RBAC is not granted in Cozystack. Reframed as defense-in-depth against an app chart bug or supply-chain compromise. External IP allocation section reframed as admin-side concern: allocator (MetalLB / Cilium LB-IPAM / robotlb / externalIPs) is configured at the platform layer, tenant API stays mechanism-agnostic. Per-Service IP uniqueness is the allocator's responsibility — same shape as for any LoadBalancer Service. Inheritance Known limitations entry: spell out both upstream gaps (Gateway API 64-listener cap, Cilium sharing-key inactive on 443/TCP via cilium#42756) so readers understand why per-tenant Gateway is the only scalable shape today. publishing.gateway.attachedNamespaces -> gateway.attachedNamespaces fix: the values block is at top-level, not under publishing. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
d2e851f63b |
feat: Gateway API support via Cilium
Replace ingress-nginx as the default ingress layer for cozystack-native
services with Cilium Gateway API, materialised per-tenant via a new
TenantGateway CRD reconciled by cozystack-controller.
The extra/gateway chart renders one gateway.cozystack.io/v1alpha1
TenantGateway per tenant; the controller owns the resulting Gateway,
per-tenant Issuer, Certificate(s), and the http->https redirect
HTTPRoute. This avoids the helm-vs-controller race on
Gateway.spec.listeners when route-driven listeners are added.
publishing.certificates.solver selects HTTP-01 (default; per-app HTTPS
listener + per-app Certificate) or DNS-01 (opt-in; single wildcard
Certificate per apex; providers: cloudflare, route53, digitalocean,
rfc2136). HTTP-01 keeps zero-config per-app onboarding; DNS-01 saves
Let's Encrypt budget on apex-shared clusters.
Security model -- seven independent layers, all admission-time gates
fail-closed:
1. Listener allowedRoutes namespace whitelist via the
kube-apiserver-written kubernetes.io/metadata.name label;
plain-HTTP listener is strictly narrower than HTTPS, and HTTPS /
passthrough listeners restrict allowedRoutes.kinds to
HTTPRoute / TLSRoute respectively.
2. cozystack-gateway-hostname-policy VAP on Gateway.
3. cozystack-gateway-attached-namespaces-policy VAP on Package --
blocks tenant-* in gateway.attachedNamespaces.
4. cozystack-tenant-host-policy VAP on Tenant -- blocks spec.host
writes from non-trusted callers.
5. cozystack-namespace-host-label-policy VAP on Namespace -- blocks
writes to namespace.cozystack.io/host from non-trusted callers.
6. Render-time fail in cozystack-basics for tenant-* in
gateway.attachedNamespaces.
7. cozystack-route-hostname-policy VAP on HTTPRoute / TLSRoute,
scoped to tenant-* namespaces.
Within-apex cross-namespace hostname conflict is resolved at reconcile
time by a HostnameConflict condition (cozy-* namespace wins).
Five reconcile paths refuse to rewrite pre-existing objects with the
controller-derived name but no OwnerReference back to the
TenantGateway: Gateway, redirect HTTPRoute, per-tenant Issuer, wildcard
Certificate, per-listener Certificate.
Derived-apex tenants (tenants whose host is a subdomain of a parent
tenant's) auto-enable Gateway when the platform-level gateway.enabled
is on.
Bonus fix: pkg/registry/apps/application/rest.go was ignoring the
createValidation and deleteValidation callbacks that genericapiserver
hands to every storage method, silently bypassing admission for Create
and Delete on every apps.cozystack.io resource (Tenant, MariaDB,
Postgres, Kubernetes, ...). Aligned with the existing updateValidation
pattern.
Always-on changes: cilium.envoy.enabled + cilium.gatewayAPI.enabled
flip to true (extra cilium-envoy DaemonSet, ~100 MB RAM/node idle),
and the cozystack-api admission-chain fix. Everything else is gated
behind gateway.enabled=false and tenant.spec.gateway=false; existing
clusters see no behavioural change until an operator opts in.
Tests: 60+ Go cases in internal/controller/tenantgateway/, admission-
chain regression tests in pkg/registry/apps/application/, helm-unittest
across 7 charts (extra/gateway, core/platform, apps/tenant,
system/cozystack-basics, system/cert-manager-issuers,
system/cozystack-api, system/dashboard), and 14 e2e bats scenarios in
hack/e2e-apps/gateway.bats. New make test-controllers target wired
into pull-requests.yaml.
Deps: sigs.k8s.io/gateway-api v1.4.1, cert-manager v1.17.4.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
|
||
|
|
d80f67e799 |
feat(platform): add implementation of backup-strategy controller for etcd
Signed-off-by: Andrey Kolkov <androndo@gmail.com> |
||
|
|
ed0297eb87 |
refactor(build): standardize image tagging to fix concurrent PR push conflicts
Every package Makefile tagged images via `settag` which, on a non-release commit, collapsed to `:latest`. Concurrent PR builds therefore raced on every image's `:latest` manifest in the OCIR registry. Two outliers (metallb, redis-operator) used hardcoded version tags and collided across PRs even when `settag` would not have. Replace `settag` and the `TAG=latest` fallback with an `image-tags` macro driven by three env vars: - IMAGE_TAG — build-unique tag, always pushed - PUBLISH_VERSIONED — also push :<component-version> - PUBLISH_FLOATING — also push :latest PR CI sets IMAGE_TAG=pr-<N>-<sha> with both PUBLISH_* off, so every push is unique across runs. Release CI sets IMAGE_TAG=<git-tag> with both PUBLISH_* on, preserving the previous release output and adding a uniform :<git-tag> tag across every image. Local builds default to IMAGE_TAG=dev with PUSH=0 already off. Image consumers reference digests pinned into values.yaml by each Makefile, so the tag is decorative for cluster-side pulls; the build-unique tag is sufficient for E2E. Drops the redundant `:<component-version>-<git-tag>` form (cilium, linstor, matchbox, ubuntu-container-disk, kubevirt-*, cluster-autoscaler) — nothing in the repo or downstream values.yaml consumed it. The same information is preserved via the new uniform `:<git-tag>` plus the existing `:<component-version>` on release. ubuntu-container-disk builds a separate image per Kubernetes version in a loop. Switched its primary tag to `:<k8s-ver>-<IMAGE_TAG>` so loop iterations within a single build do not push to the same tag. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
65ce407cbc |
fix(info): use root-host for Keycloak OIDC issuer URL in tenant kubeconfig
The kubeconfig generated for non-root tenants pointed `--oidc-issuer-url` at `keycloak.<TENANT>.<ROOT-DOMAIN>` because `$host` was sourced from `_namespace.host` (tenant-specific subdomain). Keycloak's ingress and cert live at `keycloak.<ROOT-DOMAIN>` (built from `_cluster.root-host`), so `kubectl oidc-login` failed TLS verification against the nginx-ingress default fake cert. The existing lookup-based override that tried to read `tenant-root`'s `spec.values.host` no longer triggers since PR #1787 migrated tenant-root to `valuesFrom` (Secret-backed), leaving `spec.values` empty. Use `_cluster.root-host` directly, matching what the dashboard gatekeeper template already does for the same Keycloak realm. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
e84677782c |
revert(ingress)!: remove broken publishing.exposure=loadBalancer mode (#2603)
## What this PR does Reverts the `publishing.exposure=loadBalancer` mode for ingress-nginx added in #2468. The mode rendered the ingress-nginx Service as `type: LoadBalancer` and a `CiliumLoadBalancerIPPool` over `publishing.externalIPs`, as a migration path off `Service.spec.externalIPs` (deprecated upstream in Kubernetes v1.36 per KEP-5707). In default Cozystack the implementation produces two failure modes: - `publishing.externalIPs` is the operator-supplied list of node public IPs (addresses the cluster nodes already hold and listen on, see [docs section "4b. Node Public IP Setup"](https://cozystack.io/docs/v1.3/install/cozystack/platform/#4b-node-public-ip-setup)), not a virtual-VIP pool. Treating it as one and DNATing those IPs via Cilium LB-IPAM collides with the existing ingress-nginx DNAT on the same IPs (`Service.spec.externalIPs` path), so traffic is forwarded non-deterministically. - Cilium in default Cozystack runs with `ipam.mode: kubernetes` and no L2/BGP announcer, so a pool over addresses outside the node range is allocated but never advertised — the Service stays unreachable. The KEP-5707 deprecation is real, but the right migration is a mechanism-agnostic tenant API knob translated by a Cozystack-side controller into whichever backend the cluster admin has configured (MetalLB pool, Cilium LB-IPAM, robotlb, externalIPs pin) — not a chart-side `IPPool` baked into the tenant chart. That follow-up belongs in its own PR. ### Removed - `publishing.exposure` value (and the inline comment) from `core/platform/values.yaml`. - `expose-mode` plumbing line in `core/platform/templates/apps.yaml`. - `CiliumLoadBalancerIPPool` template at `apps/tenant/templates/cilium-lb-pool.yaml`. - `type: LoadBalancer + externalTrafficPolicy: Local` branch in `extra/ingress/templates/nginx-ingress.yaml`. The Service now renders as `ClusterIP + spec.externalIPs` when externalIPs are set, falling through to `type: LoadBalancer` when they are not — pre-#2468 shape. - Exposure mode section from `extra/ingress/README.md`. - `exposure_test.yaml` fixtures in both charts. ### Compatibility Operators who explicitly set `publishing.exposure=loadBalancer` will see their ingress Service revert to `ClusterIP + spec.externalIPs` on upgrade. Anyone on the default value (`externalIPs`) is unaffected. ### Release note ```release-note revert(ingress): remove the publishing.exposure=loadBalancer mode added in #2468. The default externalIPs path is unchanged; operators who explicitly opted into loadBalancer will have their ingress Service revert to ClusterIP + spec.externalIPs on upgrade. Migration off Service.spec.externalIPs (deprecated in KEP-5707) will be reintroduced via a mechanism-agnostic API knob in a follow-up. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Refactor** * Removed conditional generation of the Cilium load‑balancer IP pool and simplified service exposure to rely on explicit external IP values. * Streamlined configuration for service exposure keys. * **Documentation** * Removed the exposure-mode guidance from ingress documentation. * **Tests** * Deleted the exposure-mode-related test suites covering various external IP and loadBalancer scenarios. [](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2603) <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
9f655e29ff |
Prepare release v1.4.0-rc.2
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
252c18f3e8 |
Prepare release v1.4.0-rc.1
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
fdffedbd4b |
feat(apps): instance-type resourcesPreset across apps and extras
Rewrite resourcesPreset values in every apps/* and extras/* chart from the legacy flat names to the new instance-type form, using the 1:1 mapping: nano -> t1.nano micro -> t1.micro small -> t1.small medium -> c1.small large -> c1.medium xlarge -> c1.large 2xlarge -> c1.xlarge Each affected values.yaml carries the canonical 47-value enum (40 instance-type names plus 7 legacy aliases) so the schema accepts both forms during the deprecation window. cozyvalues-gen regenerates the matching api/apps/v1alpha1/*/types.go kubebuilder enum and defaults, the chart values.schema.json, the README parameter table, and the ApplicationDefinition openAPISchema in packages/system/*-rd/cozyrds. Three hardcoded legacy literals in chart templates migrated: - packages/apps/harbor/templates/harbor.yaml:121 (portal) - packages/apps/opensearch/templates/opensearch.yaml:43 (dashboards) - packages/system/keycloak/templates/sts.yaml:115 (heap fallback) packages/apps/foundationdb/Makefile gains the missing hack/update-crd.sh call. Without it, every regen left foundationdb-rd cozyrds stale and new FoundationDB app CRs through cozystack-api were rejected at validation for any non-legacy resourcesPreset value. Every default-rendered chart produces identical CPU and memory to the value applied before the rename, courtesy of the legacy alias table in cozy-lib and the 1:1 default mapping. Co-authored-by: Matthieu ROBIN <matthieu@hidora.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
f919afe15f |
revert(ingress)!: remove broken publishing.exposure=loadBalancer mode
Reverts the ingress loadBalancer exposure mode added in #2468. The mode rendered the ingress-nginx Service as type: LoadBalancer and a CiliumLoadBalancerIPPool over publishing.externalIPs, as a migration path off Service.spec.externalIPs (deprecated upstream in Kubernetes v1.36 per KEP-5707). In default Cozystack the implementation produces two failure modes: - publishing.externalIPs is the operator-supplied list of node public IPs (addresses the cluster nodes already hold and listen on), not a virtual-VIP pool. Treating it as one and DNATing those IPs via Cilium LB-IPAM collides with the existing ingress-nginx DNAT on the same IPs (Service.spec.externalIPs path), so traffic is forwarded non-deterministically. - Cilium in default Cozystack runs with ipam.mode: kubernetes and no L2/BGP announcer, so a pool over addresses outside the node range is allocated but never advertised — the Service stays unreachable. The KEP-5707 deprecation is real, but the right migration is a mechanism-agnostic tenant API knob translated by a Cozystack-side controller into whichever backend the cluster admin has configured (MetalLB pool, Cilium LB-IPAM, robotlb, externalIPs pin) — not a chart-side IPPool baked into the tenant chart. That follow-up belongs in its own PR. Removed: - publishing.exposure value (and the inline comment) from core/platform/values.yaml - expose-mode plumbing line in core/platform/templates/apps.yaml - CiliumLoadBalancerIPPool template at apps/tenant/templates/cilium-lb-pool.yaml - type: LoadBalancer + externalTrafficPolicy: Local branch in extra/ingress/templates/nginx-ingress.yaml (Service renders back as ClusterIP + spec.externalIPs when externalIPs are set, falling through to type: LoadBalancer when they are not — pre-#2468 shape) - Exposure mode section from extra/ingress/README.md - exposure_test.yaml fixtures in both charts Operators who explicitly set publishing.exposure=loadBalancer will see their ingress Service revert to ClusterIP + spec.externalIPs on upgrade. Anyone on the default value (externalIPs) is unaffected. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> |
||
|
|
99e7070e4e |
fix(seaweedfs): wait for ingress-nginx-system before creating Ingress (#2406)
## What this PR does Stacks on top of #2258. Adds `dependsOn: ingress-nginx-system` to the inner `seaweedfs-system` HelmRelease so flux blocks its install until the ingress chart is Ready. ## Why In #2258's E2E run (and the `cozyreport-2258` bundle from this morning), `seaweedfs-system` failed to install with: ``` Helm install failed for release tenant-root/seaweedfs-system: 1 error occurred: * Internal error occurred: failed calling webhook "validate.nginx.ingress.kubernetes.io": failed to call webhook: Post "https://root-ingress-controller-admission.tenant-root.svc:443/networking/v1/ingresses?timeout=10s": dial tcp 10.96.33.42:443: connect: operation not permitted ``` ### Root cause class The outer tenant HelmReleases (`apps/tenant/templates/*.yaml`) render one inner HR per subsystem and let flux reconcile them. Both `seaweedfs` and `ingress` outer HRs get applied in parallel, and each of them spawns its own inner HR — which flux then installs in parallel too. When `seaweedfs-system` gets to the `creating 39 resource(s)` step, it tries to create the tenant Ingress. The apiserver calls the ingress-nginx `ValidatingWebhookConfiguration` (which uses `failurePolicy: Fail` by default). If the nginx controller pod/Service/endpoint state hasn't finished reconciling on the CNI yet, the connect to the ClusterIP fails with `EPERM`, the webhook errors, Helm aborts, and flux rolls back. E2E recovers via the `flux reconcile --force` retry loop in `hack/e2e-install-cozystack.bats`, but it's flaky and adds minutes. ### Fix Introduce an explicit ordering dependency at the inner HR layer: ```yaml {{- if $ingress }} dependsOn: - name: ingress-nginx-system {{- end }} ``` Flux waits for `ingress-nginx-system` to reach `Ready=true` before installing seaweedfs — which, under Flux's wait semantics, means the ingress-nginx Deployment is Available and its webhook endpoint is routable. The race disappears. Guarded by `{{- if $ingress }}` so this only kicks in for tenants that actually have ingress enabled. ## Test plan - [ ] Rerun E2E on #2258 — `seaweedfs-system` should install on the first try without a `Retry install` iteration. - [ ] Verify rendered template still matches expectations when `$ingress` is unset (no `dependsOn` block). ### Release note ```release-note [seaweedfs] Make tenant seaweedfs wait for ingress-nginx to be Ready before creating its Ingress resource, avoiding a webhook race that intermittently failed install on fresh clusters. ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved deployment sequencing so the system now waits for the Ingress controller to be ready before creating or updating resources that include Ingress. This prevents premature deployments and related failures, reduces rollout errors, and provides more reliable, predictable deployments for applications relying on Ingress resources. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
026581e0e9 |
fix(seaweedfs): only depend on ingress-nginx-system when tenant owns its ingress
Address review blocker on #2406: gate the dependsOn on `eq $ingress .Release.Namespace` so sub-tenants inheriting ingress from a parent namespace don't deadlock waiting for an HR that doesn't exist locally. Also add explicit `namespace:` to match repo convention. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
43c76fd8f3 |
fix(seaweedfs): wait for ingress-nginx-system before creating Ingress
Without this, the outer tenant HelmReleases apply both seaweedfs and
ingress in parallel, which in turn creates their inner HelmReleases
(seaweedfs-system and ingress-nginx-system) in parallel too. When
seaweedfs-system tries to create its Ingress before the nginx admission
webhook's Service/endpoint state has finished reconciling on the CNI,
the apiserver's webhook call fails with:
failed calling webhook "validate.nginx.ingress.kubernetes.io":
dial tcp ...:443: connect: operation not permitted
and flux rolls back the install. E2E recovers via the "Retry install"
loop but it's flaky and adds minutes to every run.
Adding dependsOn: ingress-nginx-system on the inner seaweedfs HR makes
flux block the install until the ingress chart is Ready, which means
the controller Deployment is Available and its webhook endpoint is
routable before the first Ingress is created.
Guarded by {{- if \$ingress }} so this only kicks in for tenants that
have ingress enabled (i.e. tenant-root in the default topology).
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
|
||
|
|
b5a1ae57de |
feat(etcd): update operator to v0.4.3 and expose S3 backup schedules (#2428)
## What this PR does
Bumps the vendored `etcd-operator` chart from `v0.4.2` to `v0.4.3` and
wires the new backup machinery through the cozystack `Etcd` app API.
Upstream v0.4.3 ships two new CRDs — `EtcdBackup` (one-shot snapshot)
and `EtcdBackupSchedule` (periodic snapshots) — along with the
`EtcdCluster.spec.bootstrap` restore field, a new `OPERATOR_IMAGE` env
on the operator deployment, and a replacement of the deprecated
`gcr.io/kubebuilder/kube-rbac-proxy` image. The chart portion of this PR
is a clean re-vendor via `make update` — no local patches.
On top of the bump, `packages/extra/etcd` exposes a new optional
`backup.*` values block. When `backup.enabled=true`, the chart renders
an `EtcdBackupSchedule` referencing the platform `EtcdCluster/etcd` and
a `Secret/etcd-s3-creds` with the S3 credentials expected by the
backup-agent. The values field names (`destinationPath`, `endpointURL`,
`s3AccessKey`, `s3SecretKey`, `schedule`) mirror the existing `postgres`
backup pattern for consistency. The chart `fail`s at template render if
any of the four required S3 fields are empty or if `destinationPath` is
malformed (missing `s3://` prefix, `//` segments, no bucket).
Test coverage: 15 `helm-unittest` cases across
`tests/backup-secret_test.yaml` and
`tests/etcd-backup-schedule_test.yaml` (wired into `make test`, picked
up by `hack/helm-unit-tests.sh`), plus three bats cases in
`hack/e2e-apps/etcd.bats` covering the baseline `Etcd` app, the `backup:
{}` default-off path, and the full `backup.enabled=true` case that
verifies the `EtcdBackupSchedule` materializes and the operator
generates the downstream `CronJob`.
`packages/core/platform/sources/etcd-operator.yaml` now sets
`upgradeCRDs: CreateReplace` on the etcd-operator component, so Flux
applies the new CRDs on upgrade of the chart instead of silently
skipping them.
Depends on #2427.
### Scope deferred to follow-ups
- The one-shot `EtcdBackup` CRD is vendored but not exposed through this
chart — users can still hand-apply the CRD directly.
- `EtcdCluster.spec.bootstrap` (restore from an existing snapshot) is
likewise vendored but not exposed. Both gaps are called out in
`packages/extra/etcd/README.md`.
### Release note
```release-note
feat(etcd): update etcd-operator to v0.4.3 and expose S3 backup schedules on the Etcd app. Set `backup.enabled=true` along with `backup.s3AccessKey`, `backup.s3SecretKey`, `backup.destinationPath`, and `backup.endpointURL` to schedule periodic snapshots.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Scheduled S3 backups for etcd clusters (configurable cron, generated
S3 credentials) and restore/init from S3 or PVC via new backup
resources.
* **Tests**
* New end-to-end tests for etcd lifecycle and backup scenarios; added
Helm unit tests for backup templates and validations.
* **Documentation**
* Backup configuration guide and parameter reference added.
* **Chores**
* Operator chart version bump; RBAC, webhooks, and deployment wiring
updated; added chart test target.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
5ef1500baf |
fix(seaweedfs): pass filer.replicas and s3.replicas to HelmRelease (#2533)
## What this PR does The `filer` and `s3` blocks in the seaweedfs HelmRelease values omitted the `replicas:` field, so user-supplied `.Values.filer.replicas` and `.Values.s3.replicas` were silently ignored and the upstream chart defaults took over. As a second-order effect, the `WorkloadMonitor` resources for `filer` and `s3` (which read the same values) diverged from the actual replica count and could trigger spurious alerts when users set non-default values. This PR mirrors the existing `master` / `volume` / `db` pattern and interpolates `replicas:` into both blocks. Fixes #2531 ### Release note ```release-note fix(seaweedfs): pass filer.replicas and s3.replicas to HelmRelease so user-supplied values take effect ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Chores** * SeaweedFS deployment configuration now supports configurable replica counts for the filer and s3 components, providing flexible cluster scaling through deployment parameters. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
0aed318b3e |
feat(seaweedfs): limit single PUT request size to 5GB (#2414)
## Summary Limit single PUT request body size to 5GB for SeaweedFS S3 endpoints to enforce multipart upload usage for large files. ## Motivation Large monolithic file uploads via single PUT requests (files >5GB) frequently cause issues in production environments: - Connection timeouts during long uploads - Memory pressure on proxy/gateway components - Incomplete uploads resulting in corrupted data - High support load from users experiencing these failures AWS S3 and other object storage providers recommend using multipart uploads for files larger than 100MB, with multipart being mandatory for files >5GB. ## Changes - Set `nginx.ingress.kubernetes.io/proxy-body-size: "5g"` in system SeaweedFS configuration - Set `nginx.ingress.kubernetes.io/proxy-body-size: "5g"` in tenant SeaweedFS template (hardcoded, non-overridable) - Add explanatory comments documenting the limitation and rationale ## Impact **For users:** - Single PUT requests are limited to 5GB maximum - Files >5GB must be uploaded using S3 multipart upload API - All standard S3 clients (aws-cli, boto3, MinIO client, etc.) automatically use multipart for large files - No breaking changes for properly configured S3 clients **Platform benefits:** - Reduced support load from large file upload failures - Better resource utilization (memory, connections) - More reliable upload experience for end users - Aligns with industry best practices (AWS, MinIO, etc.) ## Testing - Verify small files (<5GB) upload successfully via PUT - Verify large files (>5GB) via PUT return HTTP 413 - Verify multipart upload works for any file size - Test with common S3 clients (aws-cli, s3cmd, etc.) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated SeaweedFS S3 ingress configuration to enforce a 5GB maximum for single requests; files larger than 5GB should use multipart upload. * Disabled response/request buffering and extended NGINX timeouts, and enabled upstream service handling to improve reliability of large uploads and long-lived connections. <!-- end of auto-generated comment: release notes by coderabbit.ai --> |
||
|
|
400da5ee2b |
fix(seaweedfs): pass filer.replicas and s3.replicas to HelmRelease
The filer and s3 blocks in the seaweedfs HelmRelease values omitted the replicas: field, so user-supplied .Values.filer.replicas and .Values.s3.replicas were silently ignored and the upstream chart defaults took over. This also caused the WorkloadMonitor resources for filer and s3 (which read the same values) to diverge from the actual replica count and trigger spurious alerts when users set non-default values. Mirror the master/volume/db pattern and interpolate replicas: into both blocks. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
3364290761 |
feat(ingress): add loadBalancer exposure mode via CiliumLoadBalancerIPPool (#2468)
## What this PR does Adds an opt-in `publishing.exposure=loadBalancer` mode for the ingress-nginx Service as a migration path away from `Service.spec.externalIPs`, which is deprecated upstream in Kubernetes v1.36 ([KEP-5707](https://github.com/kubernetes/enhancements/issues/5707), [kubernetes#137293](https://github.com/kubernetes/kubernetes/pull/137293)). The `AllowServiceExternalIPs` feature gate is expected to default to off around v1.40 and the implementation to be removed around v1.43. Stacked on top of #2464 (cilium v1.19.3 bump) — depends on it for the `CiliumLoadBalancerIPPool` at `cilium.io/v2`. ### Behavior - New platform value `publishing.exposure` — enum `externalIPs | loadBalancer`, default `externalIPs` (current behavior unchanged on upgrade). - Plumbed through `cozystack-values` into each tenant's ingress HelmRelease via the new `expose-mode` key. - When `exposure=loadBalancer` and the current namespace matches `publishing.ingressName`, the Service becomes `type: LoadBalancer` with `externalTrafficPolicy: Local` and a `CiliumLoadBalancerIPPool` announces the addresses from `publishing.externalIPs` via Cilium LB IPAM. - The pool uses a namespace-only `serviceSelector` (`io.kubernetes.service.namespace: <ns>`) — any LoadBalancer Service in the tenant namespace draws from it. See "Pool ownership" below. - IPv4 addresses get `/32` CIDRs, IPv6 addresses get `/128`. Mixed families supported. Pre-CIDR entries (`192.0.2.10/32`) are accepted without double-suffixing. - Unknown values, `loadBalancer` with empty externalIPs, and stray empty entries from `publishing.externalIPs` are rejected at render time with explicit error messages. ### Pool ownership The `CiliumLoadBalancerIPPool` is rendered from `packages/apps/tenant/templates/cilium-lb-pool.yaml`, not from the ingress chart. The tenant chart is the per-tenant owner of cross-cutting resources (Namespace, `cozystack-values` Secret, HelmReleases for ingress and gateway), so one pool per tenant lives there. Cilium LB IPAM rejects overlapping CIDRs across pools regardless of `serviceSelector` — the last-added pool gets `cilium.io/PoolConflict` and stops allocating. Keeping the pool in the ingress chart would collide with the Gateway-API PR (#2470), which materialises its own LoadBalancer Service from the same `publishing.externalIPs` range when `tenant.spec.gateway=true`. Moving the pool to the tenant chart with a namespace-only selector lets a single pool back both services (ingress-nginx today, a Cilium Gateway Service once #2470 lands). Only the ingress-loadBalancer signal is wired here (`_cluster.expose-mode=loadBalancer` + `.Values.ingress=true` + publishing tenant). #2470 rebases on top of this PR, drops its own `packages/extra/gateway/templates/cilium-lb-pool.yaml`, and adds an OR branch for `.Values.gateway` in the tenant template. ### Scope Only the ingress-nginx Service is migrated by this setting. Other cozystack components that still write `Service.spec.externalIPs` directly (notably the `vpn` app at `packages/apps/vpn/templates/service.yaml`) need separate follow-up before Kubernetes v1.40. ### Tests - `packages/extra/ingress/tests/exposure_test.yaml` — 10 helm-unittest cases on the ingress-nginx Service: type / externalTrafficPolicy / externalIPs assertion for both modes, unknown-mode rejection (case-sensitive enum), empty-IPs failure, empty-entry filtering, non-publishing-tenant fallback. - `packages/apps/tenant/tests/exposure_test.yaml` — 9 cases on the pool itself: IPv4, IPv6, mixed, pre-CIDR input, empty-entry filtering, `ingress=false` in publishing tenant (no pool), non-publishing tenant (no pool), empty externalIPs (no pool). - Both suites are auto-discovered by `hack/helm-unit-tests.sh` via the `test:` target in each package's Makefile. ### Caveats (copied from the inline `values.yaml` comment) - `loadBalancer` mode uses `externalTrafficPolicy: Local`. The external IP must already be routed to a node that hosts an ingress pod (floating IP / upstream router / podAntiAffinity). - Cilium does not announce the IP on its own unless L2 announcements or BGP are enabled in the Cilium values (disabled by default in cozystack). - Switching the value on a running cluster causes the ingress-nginx Service to be recreated (`upgrade.force: true` on the HelmRelease + Service kind change); expect a brief interruption of ingress traffic. ### Release note ```release-note feat(ingress): add opt-in publishing.exposure=loadBalancer mode that uses type: LoadBalancer + CiliumLoadBalancerIPPool instead of the deprecated Service.spec.externalIPs. Default (externalIPs) preserves existing behavior. ``` |
||
|
|
36e326d768 |
fix(etcd): remove destructive post-upgrade cert-regeneration hook (#2462)
## What this PR does
Removes the `post-upgrade` Helm hook in `packages/extra/etcd` that was
`kubectl delete`ing the etcd TLS chain (`etcd-ca-tls`,
`etcd-peer-ca-tls`,
`etcd-client-tls`, `etcd-peer-tls`, `etcd-server-tls`) and then deleting
etcd pods on every chart upgrade.
The hook was gated by a semver compare of a stored
`etcd-deployed-version`
ConfigMap against `2.6.1`. That gate was written when chart versions
looked
like `2.6.0`, `2.6.1`, etc. Since commit `f871fbdb` ("Remove
versions_map
logic") all chart versions are stamped as `0.0.0+<git-hash>`, which per
semver is always `< 2.6.1`, so the gate always evaluates to "regenerate
certs" and the destructive hook fires on every upgrade.
On clusters running Kamaji-managed tenant control planes the consequence
is
severe: wiping the etcd CA triggers cert-manager to re-issue it with a
brand-new CA, but Kamaji's `datastore-certificate` Secrets still carry
the
old CA bundle mounted into tenant `kube-apiserver` pods. Those
apiservers
hit `x509: certificate signed by unknown authority` against
`etcd.<ns>.svc:2379` and go into CrashLoopBackOff until each tenant
`DataStore` is individually force-reconciled and the Deployments rolled.
Commit `47d81f70` ("Disabled private key rotation in CA certs") already
fixed the underlying `rotationPolicy: Always` issue the hook was written
to
paper over in March 2025, so the migration has no remaining use.
Changes:
- Delete `templates/hook/{job,role,rolebinding,serviceaccount}.yaml`
- Delete `templates/version.yaml` (only the hook read
`etcd-deployed-version`)
- Add `tests/no-post-upgrade-hook_test.yaml` as a regression guard — if
`templates/hook/job.yaml` or `templates/version.yaml` is ever brought
back,
`make unit-tests` fails
### Release note
```release-note
fix(etcd): remove destructive post-upgrade cert-regeneration hook. Previously the etcd chart ran a `post-upgrade` hook on every Helm upgrade that deleted etcd TLS Secrets and pods, causing cert-manager to re-issue the etcd CA. On clusters with Kamaji-managed tenant control planes this put every tenant `kube-apiserver` into CrashLoopBackOff until each DataStore was manually re-reconciled. The hook was a one-shot `2.6.0 -> 2.6.1` migration that became a permanent footgun once chart versioning moved to `0.0.0+<git-hash>` and the underlying `rotationPolicy: Always` issue was fixed in commit `47d81f70`. This is behavior removal, not cosmetic cleanup.
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Tests**
* Added Helm chart test suite verifying absence of post-upgrade hook Job
and version ConfigMap.
* **Chores**
* Removed post-upgrade hook Job and associated RBAC resources (Role,
RoleBinding, ServiceAccount).
* Removed version ConfigMap.
* Added `test` target to Makefile for running Helm chart unit tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
||
|
|
500816b71b |
refactor(ingress): move CiliumLoadBalancerIPPool to tenant chart
The pool is now rendered from packages/apps/tenant/templates/cilium-lb-pool.yaml instead of packages/extra/ingress/templates/cilium-lb-pool.yaml. Cilium LB IPAM forbids overlapping CIDRs across pools regardless of serviceSelector, so both the ingress-loadBalancer path and the upcoming per-tenant Gateway in #2470 cannot each own their own pool on the same publishing.externalIPs range. The tenant chart is the natural per-tenant owner — it already creates the Namespace, the cozystack-values Secret, and the HelmReleases for both ingress and gateway. The new pool uses a namespace-only serviceSelector (io.kubernetes.service.namespace: <ns>), which matches any LoadBalancer Service in the tenant namespace. The metadata.name changed from <trim>-ingress to <trim>-exposure to reflect that the pool is not ingress-specific. Only the ingress-loadBalancer signal is wired in this commit (_cluster.expose-mode=loadBalancer plus .Values.ingress=true on the publishing tenant). The gateway branch is added in #2470 on top of this commit — it rebases, drops its own packages/extra/gateway/templates/cilium-lb-pool.yaml, and adds an OR branch for .Values.gateway in the tenant template. Pool-rendering unit tests moved from packages/extra/ingress/tests/ to packages/apps/tenant/tests/. The ingress chart tests keep the Service-level asserts. packages/apps/tenant/Makefile gains a test target so hack/helm-unit-tests.sh picks up the new suite. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
040d5d4681 |
docs(etcd): use "custom resource" instead of "CRD" for EtcdBackup
Address review feedback from coderabbitai on packages/extra/etcd/README.md:9: CRDs are schema definitions; what users apply to trigger a restore or one-shot backup is a custom resource instance of the EtcdBackup kind, not the CRD itself. Reword accordingly. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
aaf9dd047c |
fix(etcd): cast backup history limits to int in template
Address review feedback from gemini-code-assist on packages/extra/etcd/templates/etcd-backup-schedule.yaml:20-21: explicitly coerce successfulJobsHistoryLimit and failedJobsHistoryLimit to int so string values passed via helm --set do not produce a type mismatch in the EtcdBackupSchedule spec. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
6af74cec0e |
fix(ingress): accept pre-CIDR externalIPs in CiliumLoadBalancerIPPool
Address review feedback from gemini-code-assist on packages/extra/ingress/templates/cilium-lb-pool.yaml:19: if the operator passes an externalIP already in CIDR form (192.0.2.10/32 or 2001:db8::1/128), the template appended a second /32 or /128 suffix producing an invalid CiliumLoadBalancerIPPool block. Guard the suffix append on the absence of "/" in the input. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
adc7abe5c1 |
feat(ingress): add loadBalancer exposure mode via CiliumLoadBalancerIPPool
Service.spec.externalIPs is deprecated upstream in Kubernetes v1.36 (KEP-5707, kubernetes#137293). The AllowServiceExternalIPs feature gate is expected to default to off around v1.40 and the implementation to be removed around v1.43. For bare-metal installs that rely on externalIPs today, cozystack needs a migration path. This change adds an opt-in 'loadBalancer' exposure mode for the ingress-nginx Service: - New platform value 'publishing.exposure' (enum: externalIPs | loadBalancer, default externalIPs). Plumbed through cozystack-values into each tenant's ingress HelmRelease via the new 'expose-mode' key. - Unknown values and loadBalancer with an empty externalIPs list fail the chart render with explicit error messages, rather than silently producing a broken Service. - When exposure=loadBalancer and the current namespace matches publishing.ingressName, the Service becomes type: LoadBalancer with externalTrafficPolicy: Local. - A new template renders a CiliumLoadBalancerIPPool whose blocks come from publishing.externalIPs (IPv4 addresses get /32, IPv6 addresses get /128) and whose serviceSelector uses Cilium's synthetic io.kubernetes.service.namespace key combined with the standard app.kubernetes.io/name: ingress-nginx label. No custom label is written to the Service itself, avoiding cross-tenant collisions from user-defined labels. Default behaviour is unchanged: without opting in, the Service is still ClusterIP + spec.externalIPs as today. Scope: only ingress-nginx is migrated by this setting. Other cozystack components that still write Service.spec.externalIPs directly (notably the vpn app) must be migrated separately before the v1.40 feature gate flip. Tests: packages/extra/ingress/tests/exposure_test.yaml adds 13 helm-unittest cases covering both modes, IPv4/IPv6, empty-token filtering, unknown-mode rejection, and the non-matching-namespace fallback. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
c1508940bd |
fix(etcd): remove destructive post-upgrade cert-regeneration hook
The etcd chart shipped a `post-upgrade` Helm hook that `kubectl delete`d the
etcd TLS chain (`etcd-{ca,peer-ca,client,peer,server}-tls`) and then deleted
etcd pods on every chart upgrade, gated by a semver compare of an
`etcd-deployed-version` ConfigMap against `2.6.1`.
The hook was added as a one-shot migration for the chart `2.6.0 -> 2.6.1`
transition. Since commit
|
||
|
|
b52e2801b4 |
Prepare release v1.3.0
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com> |
||
|
|
2b6e20cc3f |
fix(platform): migrate ACME HTTP-01 to ingressClassName API
ClusterIssuer solver referenced IngressClass "nginx" which does not exist on cozystack clusters — real classes are named after tenant namespaces (e.g. tenant-root). Cert issuance only worked because every requesting Ingress overrode the ClusterIssuer via the legacy acme.cert-manager.io/http01-ingress-class annotation. Switch both sides to the modern cert-manager API (available since cert-manager 1.12; cozystack ships 1.19.3): - ClusterIssuer: http01.ingress.ingressClassName, value parameterized from _cluster.expose-ingress (default "tenant-root") - Ingress annotation: http01-ingress-ingressclassname These must migrate together — mixing ingressClassName (ClusterIssuer) with the old http01-ingress-class annotation triggers cert-manager's "fields ingressClassName and class cannot be set at the same time" validation and breaks issuance. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> |
||
|
|
9fc99eed50 |
chore(etcd): regenerate values.schema.json and cozyrd manifest
Run 'make generate' under packages/extra/etcd to refresh the JSON schema and the corresponding ResourceDefinition YAML after adding backup and resources fields. Matches cozyvalues-gen v1.3.0 output used by the pre-commit CI check. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
ed77e9c6e3 |
fix(etcd): add helm unit tests, simplify backup guards, harden e2e
Fourth review round fixes. Simplify backup validation by dropping placeholder-regex guards and switching default values of s3AccessKey, s3SecretKey, destinationPath, and endpointURL from angle-bracketed hints to empty strings. A single 'required' check on each field is now the whole validation for presence; the regex guard was brittle (silently stopped matching if the hint ever changed) and dropping it makes the values.yaml defaults match the behavior contract the README describes. s3:// prefix and '//' rejection still live in etcd-backup-schedule.yaml as template-time fail branches. Add helm-unittest suites in packages/extra/etcd/tests/ covering both templates end-to-end: rendered output assertions for the happy path (bucket/key split, region passthrough, conditional rendering of optional fields) and failedTemplate assertions for every fail branch. A 'test' target is wired into the package Makefile so hack/helm-unit-tests.sh picks it up on 'make unit-tests'. Add type: Opaque to backup-secret.yaml for consistency with the mongodb template. E2E: wait for HelmRelease, Secret, and EtcdBackupSchedule deletion in setup() so the empty-backup case does not flake against lingering state from the previous test. Replace the fictitious minio-e2e hostname with no-such-endpoint.invalid and explicitly document that the e2e verifies schedule/CronJob materialization, not backup execution. Restore via EtcdCluster.spec.bootstrap and the one-shot EtcdBackup CRD are still not exposed through this chart; both are explicit follow-ups called out in the README. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |
||
|
|
12fc9cd415 |
fix(etcd): address third review round
- Use name='etcd' in all three e2e cases. The chart pins the Helm release name to 'etcd' via check-release-name.yaml, so any other name causes template rendering to fail before the HelmRelease can reconcile. This is why the tenant chart hardcodes HR name 'etcd' in packages/apps/tenant/templates/etcd.yaml. - Consolidate all backup validation guards into etcd-backup-schedule.yaml so Helm's alphabetical render order no longer determines which failure a user sees first. Previously, a bad destinationPath would be masked by the earlier placeholder check in backup-secret.yaml. - Use regex match '^<.*>$' for placeholder detection instead of exact string equality. The previous check silently stopped firing if anyone updated the default placeholder string in values.yaml; the regex form catches any angle-bracketed default. - Reject destinationPath strings containing '//' after the s3:// prefix. Previously 's3://bucket//subdir' silently produced a key beginning with '/', confusing S3 clients and retention policies. - Document the backup contract in README.md: what enabling it produces, which fields are required, why placeholders are rejected, and that restore is a known follow-up gap. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> |