Commit Graph
2059 Commits
Author SHA1 Message Date
Aleksei Sviridkin cac514b60f fix(kubernetes): fail fast when tenant has no etcd DataStore
When a Kubernetes tenant is created without a parent tenant that has
etcd enabled, .Values._namespace.etcd is empty and the rendered
KamajiControlPlane spec carries an empty dataStoreName. The Kamaji
admission webhook then rejects every TenantControlPlane create with
"tenant-root DataStore does not exist" and the control plane never
comes up.

Add a helm template-level guard that fails rendering with a
descriptive, actionable error message before the HelmRelease even
reaches the webhook. This also closes the narrow race where a
Kubernetes HelmRelease reconciles before the etcd HelmRelease has
created the DataStore CR - flux retries on its interval and picks up
the DataStore once it appears.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-16 20:26:09 +03:00
Aleksei Sviridkin ca33cc4e3c fix(kubernetes): wait for admin-kubeconfig before starting CP-side pods
Three Deployments in the Kubernetes app chart mount the tenant
admin-kubeconfig Secret directly as a volume: cluster-autoscaler,
kccm, and the kcsi controller. That Secret is provisioned
asynchronously by Kamaji after control-plane bootstrap, so on a fresh
install the pods used to hit FailedMount and the parent HelmRelease
ran out of its wait budget.

Mark the Secret volume optional and add a wait-for-kubeconfig
initContainer that polls the mounted path until the Secret appears.
Kubelet remounts the optional Secret within its sync period once
Kamaji publishes it, the init container exits, and the main container
starts cleanly. The Deployment becomes Available without the helm-wait
ever seeing a FailedMount.

Pins a busybox image for the init container via
images/busybox.tag (same format as the other pinned tags in this
chart).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-16 20:25:42 +03:00
Aleksei Sviridkin 3e26234a1c test(kubernetes): assert admin-kubeconfig wait pattern and etcd guard
Adds failing helm unittest suite for packages/apps/kubernetes covering:

- cluster-autoscaler, kccm, and csi controller Deployments mount the
  admin-kubeconfig Secret with optional: true
- each of those Deployments has a wait-for-kubeconfig initContainer
  that mounts the same kubeconfig path
- cluster.yaml renders a helm fail with a descriptive message when the
  tenant has no etcd DataStore (empty _namespace.etcd)

Also wires up a test target in the chart Makefile so helm-unit-tests.sh
picks it up.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-16 20:24:20 +03:00
cozystack-ci[bot] 12bf6b0e26 Prepare release v1.3.0-rc.1
Signed-off-by: cozystack-ci[bot] <274107086+cozystack-ci[bot]@users.noreply.github.com>
2026-04-16 08:50:30 +00:00
myasnikovdaniil 482d813d01 Add vm-default-images package (#2258)
<!-- Thank you for making a contribution! Here are some tips for you:
- Start the PR title with the [label] of Cozystack component:
- For system components: [platform], [system], [linstor], [cilium],
[kube-ovn], [dashboard], [cluster-api], etc.
- For managed apps: [apps], [tenant], [kubernetes], [postgres],
[virtual-machine] etc.
- For development and maintenance: [tests], [ci], [docs], [maintenance].
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does


### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same [label] as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
[vm-default-images] Added package that brings set of images that can be used clusterwide
[vm-disk] Updated source "image" for prettier dropdown selection
[vm-disk] Added new source for vm-disk called disk - to use as source vm-disk from same namespace.
```

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

* **New Features**
  * Clone VM disks by specifying an existing vm-disk as the source.
* Global default image collection and chart to publish pre-provisioned
images.

* **UI**
* Forms provide selectable lists for default images and existing VM
disks.

* **Migration**
* Migration to rename existing image DataVolumes to the new
default-images naming and bumped migration version.

* **Documentation**
* VM disk docs and README updated to reflect image sourcing and cloning.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-16 08:05:23 +05:00
Myasnikov DaniilandClaude Opus 4.6 e6ba9817be [platform] Make vm-default-images opt-in, not default in iaas bundle
The package imports ~320Gi of golden-image PVCs (ubuntu-noble, fedora,
debian, centos, etc.) as soon as it's installed. On small test and dev
clusters that's enough to consume the entire replicated storage pool,
after which no tenant PVCs — including the ones E2E itself provisions —
can be bound. It's also unreasonable to force that cost on every iaas
user: many deployments don't need prebuilt images at all, and the ones
that do often want to curate their own subset.

Switch the bundle entry from 'package.default' to 'package.optional.default',
matching the treatment already applied to gpu-operator directly below it.
Users who want the golden images can opt in via:

    bundles:
      enabledPackages:
        - cozystack.vm-default-images

The package, its Source, and migration 38 all stay in place — nothing
else changes for users who explicitly enable it. Users who previously
relied on the bundle auto-installing it will need to add the package
to enabledPackages on upgrade; this is called out in the release note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-15 19:47:25 +05:00
myasnikovdaniil cbc79600dd [linstor-gui] Add Keycloak-protected Ingress for the UI (#2390)
## What this PR does

Stacked on top of #2382. Publishes the linstor-gui UI on
`https://linstor-gui.<root-host>` behind the cluster Keycloak realm
using the same oauth2-proxy gatekeeper pattern that the dashboard uses,
so the LINSTOR REST API is never exposed unauthenticated through
Ingress.

The Ingress is rendered only when **both** gates are satisfied:
- `_cluster.oidc-enabled == "true"`
- `linstor-gui` is listed in `_cluster.expose-services` (i.e. in
`publishing.exposedServices` in the core cozystack values)

If OIDC is disabled cluster-wide we intentionally do **not** ship an
unauthenticated ``token-proxy`` fallback — unlike dashboard, there is no
reason to front a raw storage-management API with k8s bearer tokens.
Operators can still reach the UI via \`kubectl port-forward\` to the
ClusterIP service.

## What's new

- \`templates/gatekeeper.yaml\` — oauth2-proxy Deployment (OIDC mode),
upstream \`linstor-gui.<ns>.svc:80\`
- \`templates/gatekeeper-svc.yaml\` — ClusterIP :8000 in front of it
- \`templates/gatekeeper-sa.yaml\` — dedicated SA, no auto-token
- \`templates/keycloakclient.yaml\` — persistent \`linstor-gui-client\`
+ \`linstor-gui-auth-config\` Secrets, \`KeycloakClient\` CRD that
auto-provisions the OIDC client with \`redirectUris:
[/oauth2/callback/*]\`
- \`templates/ingress.yaml\` — Ingress to the gatekeeper Service with
cert-manager ClusterIssuer, gated on expose-services + oidc-enabled
- \`tests/ingress_auth_test.yaml\` — unit tests for each conditional
branch, KeycloakClient rendering, oauth2-proxy args

README updated with Option 1 (Keycloak-protected Ingress) and Option 2
(port-forward).

### Release note

\`\`\`release-note
Added an opt-in Keycloak-protected Ingress for the linstor-gui package.
Add \`linstor-gui\` to \`publishing.exposedServices\` to publish
\`https://linstor-gui.<root-host>\` behind the cluster OIDC realm.
\`\`\`

## Test plan

- [ ] Deploy to dev10 with \`linstor-gui\` added to
\`publishing.exposedServices\`
- [ ] Verify cert-manager issues the TLS certificate
- [ ] Open \`https://linstor-gui.<root-host>\` in a browser, confirm
Keycloak login challenge
- [ ] After login, confirm LINSTOR node list loads via the nginx → mTLS
proxy
- [ ] Verify helm-unittest cases pass in CI
- [ ] Sanity-check that when \`oidc-enabled=false\` or the service is
not in \`expose-services\`, the Ingress + gatekeeper resources are
skipped
2026-04-15 17:52:29 +05:00
myasnikovdaniil c6739cf95a [linstor-gui] Add package for LINBIT linstor-gui web UI (#2382)
## What this PR does

Adds a new Cozystack system package `linstor-gui` that ships [LINBIT's
LINSTOR web UI](https://github.com/LINBIT/linstor-gui) (GPL-3.0) so
operators can manage nodes, resources, volumes and snapshots from a
browser instead of the `linstor` CLI.

Changes:
- `packages/system/linstor-gui/` — umbrella chart with:
- `images/linstor-gui/Dockerfile` builds the image from the upstream
`pkg.linbit.com` tarball (v2.3.0) on top of
`nginxinc/nginx-unprivileged:1.29-alpine`, mirroring the build pattern
already used for `piraeus-server` and `linstor-csi`.
- `templates/configmap-nginx.yaml` — chart-supplied `nginx.conf` that
proxies `/v1` and `/metrics` to
`linstor-controller.cozy-linstor.svc:3371` over **mTLS**, using the
existing `linstor-client-tls` secret created by the `linstor` package.
- `templates/deployment.yaml` — read-only rootfs, non-root (UID 101),
`runAsNonRoot`, `RuntimeDefault` seccomp, no SA token mounted,
`reloader.stakater.com/auto` on the Deployment.
- `templates/service.yaml` — **ClusterIP only**. No Ingress is shipped
because the LINSTOR controller API is a privileged cluster-wide storage
surface and auth depends on the deployment's OIDC setup; operators wire
up ingress + auth explicitly.
- `tests/deployment_test.yaml` — `helm-unittest` covering Service shape,
TLS secret mount, securityContext, and nginx proxy+mTLS config.
- `packages/core/platform/sources/linstor-gui.yaml` — new
`PackageSource` depending on `cozystack.linstor`.
- `packages/core/platform/templates/bundles/system.yaml` — registers
`linstor-gui` as an **optional** system package (controlled via
`bundles.enabledPackages`), alongside `velero`, `telepresence`, etc.

### Why standalone (not a sidecar in the controller pod)

LINBIT's upstream Dockerfile is designed for this standalone
reverse-proxy topology, and keeping the UI as its own Deployment means
it scales, upgrades, and restarts independently of `linstor-controller`.
The price is the small amount of mTLS config in `nginx.conf`, all of
which lives in the chart's ConfigMap.

### Release note

```release-note
[linstor-gui] Add an opt-in system package that deploys LINBIT's linstor-gui web UI alongside the LINSTOR controller. Enable via `bundles.enabledPackages: [cozystack.linstor-gui]`, then `kubectl -n cozy-linstor port-forward svc/linstor-gui 3373:80` to access.
```

## Test plan

- [ ] `make unit-tests` (requires `helm-unittest` plugin) passes on the
new `tests/deployment_test.yaml`
- [ ] `make image` in `packages/system/linstor-gui/` builds and pushes
the image
- [ ] Install the package on a dev cluster with LINSTOR already
deployed; confirm the pod comes up
- [ ] `kubectl -n cozy-linstor port-forward svc/linstor-gui 3373:80` →
browser loads the UI at `localhost:3373`
- [ ] UI lists LINSTOR nodes, storage pools and resources correctly
(proves mTLS proxy to `/v1` works)
- [ ] Disable via `bundles.enabledPackages` removal; verify the
`Package` resource is cleaned up


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

* **New Features**
* LINSTOR GUI added as an optional system package deployable to the
cozy-linstor namespace.
* Web UI serves on port 3373, proxies LINSTOR controller over mTLS, and
exposes a /healthz endpoint.
* Image build and release targets added to produce multi-arch container
images.

* **Documentation**
* New README with deployment guidance, connection examples, and
configurable options (endpoint, client secret, image, replicas).

* **Tests**
* Helm chart tests validating service, deployment, config and mTLS
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-15 17:32:50 +05:00
Myasnikov DaniilandClaude Opus 4.6 0b5a1df3c2 [platform] Bump migration targetVersion to 39 for migration 38
Address review feedback from lexfrei on migrations/38:2:
Migration 38 requires targetVersion 39 to execute, since run-migrations.sh
iterates seq $CURRENT_VERSION $((TARGET_VERSION - 1)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 20:00:25 +05:00
Myasnikov DaniilandClaude Opus 4.6 2def6cda67 [vm-default-images] Default to replicated storageClass and document storage requirements
The package is wired into the iaas bundle where LINSTOR replicated storage is
available. Set storageClass to "replicated" by default and add a note that the
full image set needs ~320Gi (16 images × 20Gi).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:20 +05:00
Myasnikov DaniilandClaude Opus 4.6 eeda31eb95 [api] Regenerate deepcopy and CRD manifests via make generate
Running make generate at repo root regenerates the zz_generated.deepcopy.go
files.  Key fix: Source.DeepCopyInto in the vmdisk package now handles
the new Disk *SourceDisk pointer field, which was previously missing and
would cause pointer aliasing on deep-copy.

Other generated changes are incidental updates to vminstance (Networks
field added, Subnet renamed to Network) and backups CRD manifests picked
up by controller-gen from the current HEAD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov DaniilandClaude Opus 4.6 551b4e65ac [vm-default-images] Fix CentOS Stream image URLs
The x86_64 architecture token was placed inside the filename segment
(CentOS-Stream-GenericCloud-x86_64-N-latest.x86_64.qcow2) which does
not match the actual CentOS mirror layout.  Correct both CentOS Stream 9
and 10 URLs to the canonical form without the extra architecture infix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov DaniilandClaude Opus 4.6 15dfc2521e [vm-disk] Remove unused DataVolume lookup in dv.yaml
The \$dv variable assigned by the lookup call on the image PVC was never
read.  Remove the dead line to keep the template tidy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov DaniilandClaude Opus 4.6 b9a17295cc [platform] Wire vm-default-images into iaas bundle
The PackageSource for vm-default-images existed but was never included
in any bundle, so it would never be installed.  Add it to iaas.yaml
immediately after kubevirt-cdi (which it depends on) so it is deployed
together with the rest of the KubeVirt IaaS stack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov DaniilandClaude Opus 4.6 6b6118ce56 [platform] Add migration 38 to rename vm-image-* DataVolumes to vm-default-images-*
The vm-disk package previously referenced golden-image DataVolumes with
the prefix "vm-image-<name>" in the cozy-public namespace.  The new
vm-default-images package creates them as "vm-default-images-<name>",
so any existing vm-disk that sources an image by name would fail to
find its PVC after upgrade.

Migration 38 renames every DataVolume in cozy-public whose name starts
with "vm-image-" to "vm-default-images-" (dropping the old object and
re-creating with the new name) so live vm-disk resources keep resolving
their source PVC transparently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov Daniil 939a7d8eb3 [vm-disk] Fix vm-disk-rd
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov Daniil bec35e3aad [vm-default-images] Added new optional package
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 19:59:19 +05:00
Myasnikov DaniilandClaude Opus 4.6 1509f5a620 [linstor-gui] Lock the right endpoints to disable in-app auth setup
The previous commit blocked /v1/security/* on the LINSTOR controller,
but that turned out to be wishful thinking: that path doesn't exist in
the LINSTOR REST API at all (the controller responds 404 for everything
under /v1/security), and the linstor-gui SPA never calls it.

The actual storage for the GUI's authentication panel is two LINSTOR
KeyValueStore instances managed by the SPA itself:

  - __gui__settings  — holds `authenticationEnabled` (the on/off toggle)
  - __gui__users     — holds the encrypted admin credential

The SPA reads them on every page load via
`GET /v1/key-value-store/__gui__settings`, so we cannot blanket-block
the path or the UI fails to render. Instead, restrict to read-only:
allow GET/HEAD (so the SPA can confirm auth=off and skip its login
screen), reject every mutating method (PUT/POST/PATCH/DELETE) with the
same explanatory 403 JSON. Other GUI key-value entries (e.g.
__gui__mode) are unaffected.

The dev10 cluster had this footgun tripped already
(authenticationEnabled=true, an admin entry in __gui__users); cleared
manually with `linstor key-value-store modify __gui__settings
authenticationEnabled false` so the existing release stops gating the
UI behind a login the user no longer has the password for.

Verified end-to-end on dev10 after upgrade:
  GET    /v1/key-value-store/__gui__settings -> 200
  PUT    /v1/key-value-store/__gui__settings -> 403 (with JSON body)
  POST   /v1/key-value-store/__gui__users    -> 403
  PUT    /v1/key-value-store/__gui__mode     -> 200 (other KV writes ok)
  GET    /v1/nodes                           -> 200

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 7bf1622172 [linstor-gui] Block in-app LINSTOR auth setup at the nginx proxy
The upstream linstor-gui SPA exposes a "Users" / sign-in panel that
POSTs to /v1/security/* on the LINSTOR controller. If a cozystack user
turns on HTTP auth via that panel, every subsequent REST call —
including from this very GUI, which talks to the controller via mTLS,
not bearer tokens — starts returning 401, locking the user out of
LINSTOR with no in-product recovery path.

Now that authentication for the cozystack-shipped GUI is enforced one
layer up at the Ingress (oauth2-proxy + Keycloak), the in-app auth is
both redundant and a footgun. Short-circuit /v1/security/* in the
gateway nginx with a 403 + explanatory JSON body so the setting cannot
be enabled regardless of what the SPA renders. Other LINSTOR REST paths
(/v1/nodes, /v1/resource-definitions, /metrics, …) continue to proxy
through unchanged.

Verified on dev10:
  POST /v1/security/sign-in  -> 403 (with explanation body)
  GET  /v1/security/users    -> 403
  GET  /v1/nodes             -> 200

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 574941123b [linstor-gui] Bump nginx proxy buffer for oauth2-proxy session cookies
Keycloak access+refresh+id tokens push the oauth2-proxy session cookie
to ~8-10 KB (split across multiple Set-Cookie headers), which overflows
ingress-nginx's default proxy_buffer_size and produces "upstream sent too
big header" -> HTTP 502 on /oauth2/callback right after a successful
Keycloak login.

Mirror the dashboard ingress: proxy-buffer-size: 100m and
proxy-buffers-number: "4". Verified on dev10 — unauthenticated requests
now return 302 to Keycloak instead of 502.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 80968410b0 [tenant] Widen linstor-gui egress to whole cozy-linstor namespace
Narrowing to (instance=linstor-gui, name=gatekeeper) also blocked the
transient cm-acme-http-solver pods that cert-manager spins up in the
same namespace during http-01 challenges — the solver pods carry
different labels, so the Certificate never became Ready and TLS
handshakes failed.

Broaden the rule to the whole cozy-linstor namespace to mirror
allow-to-dashboard / allow-to-keycloak. The ingress controller only
initiates upstream HTTP calls driven by Ingress resources, so allowing
namespace-wide egress is not a practical escalation.

Verified on dev10 after patching the live CNP: Certificate
linstor-gui-ingress-tls becomes Ready (Let's Encrypt prod), and the
Ingress returns HTTP 302 to the Keycloak authorize endpoint with the
correct client_id and redirect_uri.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 e2b6c2c3dd [tenant] Allow ingress to linstor-gui gatekeeper
The tenant chart declares per-namespace egress CNPs so the tenant-root
ingress-nginx can reach auth-fronted UIs in the system namespaces
(dashboard, keycloak, cdi-upload-proxy). linstor-gui's gatekeeper in
cozy-linstor needs the same allowance — without it, requests to
https://linstor-gui.<root-host> time out at the Ingress with 504
because tenant-root → cozy-linstor egress is denied by default-deny.

Scoped to the gatekeeper pods (instance=linstor-gui, name=gatekeeper)
rather than the whole cozy-linstor namespace — the ingress controller
does not need to reach the LINSTOR controller, satellites, or CSI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 dc4513a8af [linstor-gui] Fix gatekeeper label conflicts and drop realm-capped session attrs
Two issues surfaced while deploying to dev10:

1. The shared linstor-gui.labels / linstor-gui.selectorLabels helpers
   set app.kubernetes.io/name=linstor-gui, which overrode the
   app.kubernetes.io/name=gatekeeper that the gatekeeper templates set
   first. The resulting Deployment had selector.matchLabels=gatekeeper
   but template.metadata.labels=linstor-gui, failing admission with
   "selector does not match template labels". Drop the helper include
   from the three gatekeeper templates and inline the remaining common
   labels (managed-by, part-of) so the gatekeeper keeps its own
   name label.

2. Copying client.session.idle.timeout=86400 and
   client.session.max.lifespan=604800 from the dashboard KeycloakClient
   caused the keycloak-operator to reject the client ("Client session
   idle timeout cannot exceed realm SSO session idle timeout"). Our
   dev10 realm has smaller defaults, and dashboard's own client is
   also stuck in this failed state. Drop the overrides and let the
   realm defaults apply — the UI has no reason to demand longer
   sessions than the realm is willing to grant.

Verified on dev10:
  * gatekeeper Deployment rolls out cleanly
  * KeycloakClient reconciles to status=OK
  * https://linstor-gui.<root-host>/ returns HTTP 302 to
    keycloak.<root-host>/realms/cozy/protocol/openid-connect/auth
    with client_id=linstor-gui and the correct callback URI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 bdb0e3a72c [linstor-gui] Add Keycloak-protected Ingress for the UI
Follow-up to the initial linstor-gui package (#2382). Publishes the UI on
https://linstor-gui.<root-host> behind the cluster Keycloak realm using
the same oauth2-proxy gatekeeper pattern that dashboard uses, so the
LINSTOR REST API is never exposed unauthenticated through Ingress.

Rendered only when both gates are satisfied:
  * _cluster.oidc-enabled == "true"
  * "linstor-gui" is listed in _cluster.expose-services
    (i.e. in publishing.exposedServices in the core cozystack values).

If OIDC is disabled cluster-wide we intentionally do not ship an
unauthenticated token-proxy fallback — unlike dashboard, there is no
reason to front a raw storage-management API with k8s bearer tokens.
Operators can still reach the UI via `kubectl port-forward` to the
ClusterIP service.

New resources:
  * templates/gatekeeper.yaml       — oauth2-proxy Deployment (OIDC mode)
  * templates/gatekeeper-svc.yaml   — ClusterIP :8000 in front of it
  * templates/gatekeeper-sa.yaml    — dedicated SA, no autotoken
  * templates/keycloakclient.yaml   — persistent client + cookie
                                      Secrets, KeycloakClient CRD with
                                      redirectUris for /oauth2/callback
  * templates/ingress.yaml          — Ingress to the gatekeeper Service,
                                      cert-manager ClusterIssuer, gated
                                      on expose-services + oidc-enabled
  * tests/ingress_auth_test.yaml    — unit tests for each conditional
                                      branch and the client CRD

README.md updated with both Option 1 (Keycloak-protected Ingress) and
the existing Option 2 (port-forward).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:34:15 +05:00
Myasnikov DaniilandClaude Opus 4.6 43222e8be0 [linstor-gui] Fix test regex for proxy_ssl_certificate spacing
The matchRegex assertion used 5 literal spaces but the template
renders 9 (aligned with proxy_ssl_trusted_certificate). Use \s+
so the test is resilient to alignment changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-14 12:32:41 +05:00
Myasnikov DaniilandClaude Opus 4.6 727401c2c6 [linstor-gui] Address coderabbit feedback on /healthz and reload annotation
- Fix /healthz Content-Type header: nginx silently drops `add_header`
  after `return` because the response is already finalized. Switch to
  `default_type text/plain;` placed BEFORE the `return 200` so the
  probe response is actually labeled as text/plain.
- Drop the redundant `checksum/nginx-config` pod-template annotation:
  the deployment already carries `reloader.stakater.com/auto: "true"`,
  so Stakater Reloader handles ConfigMap rollouts. Matches the sister
  `linstor` package convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-13 20:42:14 +05:00
Timofei Larkin a8559e1a9b [scheduler] Make cozystack-scheduler storage-aware via LINSTOR extender (#2330)
## What this PR does

Makes `cozystack-scheduler` LINSTOR storage-aware by configuring it to
call the existing `linstor-scheduler-extender` as a scheduler extender.
This fixes #2328 (phase 1): pods with both a `SchedulingClass` and
LINSTOR PVCs now get storage-locality-aware placement instead of
bypassing LINSTOR's filter/prioritize logic.

Changes:
- **linstor-scheduler package**: expose the extender sidecar (port 8099)
via a new ClusterIP Service; patch the vendored deployment to add a
`app.kubernetes.io/component: scheduler` label for clean Service
targeting
- **cozystack-scheduler package**: bump to v0.3.0 which adds
configurable `extenders` support; set the LINSTOR extender URL in
wrapper values
- **tests**: helm-unittest for extender rendering in the ConfigMap

### Release note

```release-note
[scheduler] cozystack-scheduler now calls the LINSTOR scheduler extender for storage-aware pod placement. Pods assigned to a SchedulingClass that also use LINSTOR-backed PVCs will prefer nodes with local volume replicas.
```

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

* **New Features**
* Added scheduler extender support: exposes an extender Service and adds
pod metadata to enable extender routing.

* **Chores**
  * Bumped scheduler chart and image versions to 0.3.0.
* Added default extender configuration and a sample remote extender
entry.
* Updated build scripts to apply an additional patch and added a test
target.

* **Tests**
* Added Helm tests validating rendered ConfigMap with and without
extender configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-13 19:19:10 +04:00
Timofei LarkinandClaude Opus 4.6 37229aa0ac feat(scheduler): storage-aware scheduling
Expose the existing linstor-scheduler-extender sidecar as a ClusterIP
Service and configure cozystack-scheduler to call it during the
scheduling cycle. Pods with both a SchedulingClass and LINSTOR PVCs
now get storage-locality-aware placement.

- Add extender Service (port 8099) for linstor-scheduler
- Patch vendored deployment to label pods for Service selector
- Bump cozystack-scheduler to v0.3.0 (configurable extenders)
- Add "linstor" PackageSource variant with extender values
- Default variant ships without extender for non-LINSTOR clusters
- Select linstor variant in system bundle
- Add helm-unittest tests for both packages

Ref: #2328

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-04-13 17:15:09 +03:00
Myasnikov DaniilandClaude Opus 4.6 bbeaaf3dab [linstor-gui] Update test to match 0444 secret mount mode
Follow-up to 4b76a93d: the assertion still expected defaultMode 0400
and would fail in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-13 12:49:48 +05:00
Myasnikov DaniilandClaude Opus 4.6 4b76a93dc1 [linstor-gui] Fix secret mount mode so nginx can read mTLS client certs
The linstor-client-tls secret was mounted with defaultMode 0400 and
owned by root, so the nginx process (UID 101) got EACCES on tls.crt
and crash-looped with:

  [emerg] cannot load certificate "/etc/linstor/client/tls.crt":
  BIO_new_file() failed ... Permission denied

Set defaultMode to 0444. The secret volume is pod-local and
readOnlyRootFilesystem is on, so making it world-readable inside the
pod is not a broader disclosure.

Verified on dev10: pod Ready, /healthz 200, /v1/nodes proxied through
mTLS to linstor-controller:3371 returns real node data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-13 12:41:58 +05:00
Myasnikov DaniilandClaude Opus 4.6 ba9f9e9f2c [linstor-gui] Address review comments on #2382
- Dockerfile: chown html root to 101:101 (match runAsGroup, not 101:0)
- Makefile: declare .PHONY targets (image, image-linstor-gui, test)
- deployment: drop priorityClassName system-cluster-critical (GUI is
  not control-plane critical; chart does not need to reserve the slot)
- configmap-nginx:
  - fail the render if linstor.endpoint is not https:// so a misconfig
    cannot silently downgrade the mTLS-protected controller path
  - hoist proxy_set_header and proxy_ssl_* directives to the server
    block instead of duplicating across /v1 and /metrics locations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-13 11:59:46 +05:00
Myasnikov DaniilandClaude Opus 4.6 ae88fe3779 [linstor-gui] Add package for LINBIT linstor-gui web UI
Ships LINBIT's LINSTOR web UI (GPL-3.0) as an opt-in system package under
packages/system/linstor-gui so operators can manage LINSTOR nodes, resources,
and volumes from a browser instead of the linstor CLI.

The UI is built from the upstream pkg.linbit.com tarball onto an
nginx-unprivileged base image. A chart-supplied nginx.conf proxies /v1 and
/metrics to the LINSTOR controller REST API over mTLS using the existing
linstor-client-tls secret created by the linstor package.

Only a ClusterIP Service is created; no Ingress is shipped, because LINSTOR's
controller API is a privileged cluster-wide storage surface and auth depends
on the deployment's OIDC setup. Operators opt in via bundles.enabledPackages
and wire up ingress + auth themselves (port-forward works out of the box).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-13 11:48:35 +05:00
Aleksei Sviridkin fef0f10bfd docs(tenant): align chart README with tenant naming rules
The README stated that dashes are 'allowed but discouraged' in tenant names, but the tenant.name Helm helper explicitly fails on release names that contain more than one dash, and the platform guide on the website already says tenant names must be alphanumeric. Bring the chart README in line with both the website documentation and the enforcement now present on the aggregated API.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-12 14:16:41 +03:00
Aleksei Sviridkin abd6667eb3 [cilium] Move AppArmor podAnnotations to a non-Talos-only values file
E2E revealed that the kube-apiserver rejects a DaemonSet carrying a
per-container AppArmor annotation for a container that is not present
in the pod spec:

    DaemonSet.apps 'cilium' is invalid:
    spec.template.annotations[container.apparmor.security.beta.kubernetes.io/mount-cgroup]:
    Invalid value: 'mount-cgroup': container not found

On Talos (isp-full bundle) values-talos.yaml sets
cgroup.autoMount.enabled=false, so the upstream chart does not render
the mount-cgroup init container, and our unconditional podAnnotations
entry for it becomes an invalid reference.

Move the podAnnotations block from the shared values.yaml into a new
values-apparmor.yaml and include it only in the cilium-generic and
kubeovn-cilium-generic PackageSource variants, where
cgroup.autoMount.enabled is explicitly true. Talos variants
(cilium, cilium-kilo, kubeovn-cilium) get no AppArmor annotations,
which is safe because the Talos kernel does not load the AppArmor
LSM anyway.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 16:39:58 +03:00
Aleksei Sviridkin fc9ef55c42 [cilium] Harden vendoring guard and clarify Talos annotation comment
- Broaden the grep guard in the Makefile update: target to match any
  container.apparmor.security.beta.kubernetes.io key, not only the
  quoted cilium-agent line. The previous pattern would silently pass
  if upstream switched to an unquoted annotation value, masking a
  failed perl patch; grepping for the prefix catches any residual
  hardcoded AppArmor annotation regardless of format.

- Rewrite the values.yaml comment to distinguish the two Talos-safe
  reasons: mount-cgroup is simply not rendered when
  cgroup.autoMount.enabled is false (Talos default), while the other
  annotations are harmless because kubelet ignores AppArmor metadata
  on nodes without the LSM loaded.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 15:20:55 +03:00
Aleksei Sviridkin 0b0d3de99d [cilium] Clarify nsenter/AppArmor comment wording
Replace the misleading 'nsenter --ptrace' shorthand with an accurate
description: nsenter joins the host cgroup/mount namespace, and the
cri-containerd.apparmor.d profile reports the denial as a blocked
'ptrace' operation (operation name, not an nsenter CLI flag).

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 15:13:03 +03:00
Aleksei Sviridkin 39cd2658b5 [cilium] Add fail-fast guard for the vendored daemonset patch
The perl multi-line delete in the update: target silently becomes a
no-op if the upstream template is ever reformatted. Verify the patch
applied by grepping for one of the stripped annotation keys after the
perl runs; fail the update: target loudly if it still exists, so a
future upstream reformat is caught instead of silently reintroducing
the duplicate-key rendering on k8s < 1.30.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 15:03:54 +03:00
Aleksei Sviridkin 5a50df800d [cilium] Strip the entire k8s<1.30 AppArmor block from vendored daemonset
The previous sed patch removed only the four annotation lines and
left an empty nested '{{- if ... }}{{- end }}{{- end }}{{- end }}'
skeleton behind. That skeleton was dead code, but it also meant that
the next make update would re-fetch the upstream chart with the
annotations back inside the same block, and the sed would strip them
again — leaving the skeleton behind forever.

Replace the per-line sed patch with a single perl -i -0pe multi-line
delete that removes the entire <1.30 AppArmor block in one step, and
apply the same patch to the currently vendored copy for consistency.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 14:54:47 +03:00
Aleksei Sviridkin 182fc0b52d [cilium] Strip hardcoded AppArmor annotations from vendored daemonset
The upstream chart emits per-container AppArmor unconfined annotations
inside a k8s<1.30 branch of cilium-agent/daemonset.yaml. Cozystack now
owns these annotations via cilium.podAnnotations in values.yaml so they
also take effect on k8s>=1.30 (where upstream drops them in favour of
the containerd-broken podSecurityContext.appArmorProfile). Having both
sources emit the same keys produced a duplicate mapping on k8s<1.30.

Add a new SED_INPLACE invocation to the package Makefile that removes
the four hardcoded annotation lines from the vendored template, and
apply the patch to the currently vendored copy so make update will
reproduce the same state on future refreshes.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 14:39:37 +03:00
Aleksei Sviridkin 703dbca734 [cilium] Document duplicate-key rendering on unsupported k8s < 1.30
On Kubernetes versions below 1.30 the upstream cilium chart still emits
the deprecated per-container AppArmor annotations from its own
semverCompare '<1.30.0' branch. Our podAnnotations override then emits
the same keys again, yielding a YAML mapping with duplicate entries
(identical values, last-wins by kubernetes API server JSON semantics).

Cozystack's supported Kubernetes matrix starts at 1.30
(packages/apps/kubernetes/files/versions.yaml), so this rendering
quirk never affects any shipped version. Document the behaviour in a
comment so future readers are not surprised.

No functional change.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 13:54:16 +03:00
Aleksei Sviridkin 2531ab661a [cilium] Drop mount-bpf-fs from AppArmor unconfined annotations
mount-bpf-fs runs as privileged in the vendored chart, and the Linux
kernel does not apply AppArmor profiles to privileged containers. The
per-container unconfined annotation for this container is a no-op and
only widens the pod's admission-policy surface for no reason.

Keep the four meaningful annotations (cilium-agent, clean-cilium-state,
mount-cgroup, apply-sysctl-overwrites) which target unprivileged agent
and init containers actually affected by the cri-containerd.apparmor.d
profile.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 13:41:01 +03:00
Aleksei Sviridkin 6c0ae2570e [cilium] Opt-out of cri-containerd.apparmor.d for nsenter init containers
Cilium 1.19 init containers mount-cgroup, apply-sysctl-overwrites,
mount-bpf-fs and clean-cilium-state call nsenter --ptrace, which is
denied by the cri-containerd.apparmor.d profile on Ubuntu 22.04+ and
any other distro loading this AppArmor profile. As a result cilium
agent pods land in Init:CrashLoopBackOff and downstream HelmReleases
cascade into "dependency not ready".

The modern k8s >= 1.30 pod-level appArmorProfile: Unconfined field is
accepted into the DaemonSet spec but not honoured by containerd CRI
at runtime (kubernetes/kubernetes#125069), so the deprecated
per-container AppArmor annotations remain the only reliable opt-out.

Add the annotations via podAnnotations in the wrapper values.yaml.
The change is harmless on systems without AppArmor (Talos,
RHEL/Rocky/AlmaLinux with SELinux) because kubelet silently ignores
unknown AppArmor annotations when the LSM is not loaded.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-04-11 13:23:33 +03:00
Andrei Kvapil 78ca1f0789 Fix system postgresql images to 17.7-standard-trixie (#2364)
<!-- Thank you for making a contribution! Here are some tips for you:
- Start the PR title with the [label] of Cozystack component:
- For system components: [platform], [system], [linstor], [cilium],
[kube-ovn], [dashboard], [cluster-api], etc.
- For managed apps: [apps], [tenant], [kubernetes], [postgres],
[virtual-machine] etc.
- For development and maintenance: [tests], [ci], [docs], [maintenance].
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does


### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same [label] as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
Fix system postgresql images to 17.7-standard-trixie
```

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

* **Chores**
* Database Helm charts now preserve and reuse existing PostgreSQL image
settings, normalizing tags to the standard-trixie variant when
appropriate.
  * Migration configuration bumped to target version 38.

* **Bug Fixes**
* Migration logic updated to correctly locate and adjust CNPG Cluster
resources and to log per-cluster patch/skip decisions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-10 14:59:53 +02:00
Myasnikov Daniil a3f50ba2bd Fix system postgresql images to 17.7-standard-trixie
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-10 12:47:46 +05:00
Andrei Kvapil 2c6e0fe7d6 [monitoring] Fix infra dashboards missing in default variant (#2365)
## What this PR does

- Adds `cozy-monitoring` namespace to the infra dashboards rendering
condition in `dashboards.yaml`
- The default variant deploys the monitoring chart to `cozy-monitoring`,
but the condition from #2197 only checked for `tenant-root`, causing all
infrastructure dashboards to be missing
- This is consistent with the existing pattern in `vmagent.yaml` which
already handles both namespaces

## Release note

```release-note
[monitoring] Fix infrastructure dashboards not rendering in default variant where monitoring is deployed to cozy-monitoring namespace
```

Fixes #2349

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

## Summary by CodeRabbit

* **Infrastructure & Monitoring**
* Expanded Grafana dashboard availability across additional deployment
environments for infrastructure monitoring.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-10 01:23:03 +02:00
Andrei Kvapil 02ed1e0332 linstor: update piraeus-server to v1.33.2 with selected backports (#2331)
## Summary
- update `packages/system/linstor` to `linstor-server` `v1.33.1`
- keep only the selected `piraeus-server` backports needed for this
update
- document the upstream/source commits for each retained patch
- drop the older local patch set that is no longer carried in this
package revision

## Selected backports
- PR #475: backport maintainer implementation from
`3d97f71c95a493588d3d521c63eac4d846935fb3`
- PR #476: backport the TCP port preservation fix from
`79d6375c55d6181b35a7b7f0fe8dbdfb86e126cd` and
`bcc89902f4f61ac1589dd07ebb7f5aae1935370d`
- PR #472: backport the LUKS header sizing fix from
`ccc85fbd2c65f0b97c52403fa80f1efdb886ec4e` plus the required
`optimal_io_size` dependency from
`71b601554f41bcb50cd5bd06989c5b0d3a814acd`

## Verification
- applied all retained patches on top of upstream `v1.33.1` with `git
apply --check`
- verified the combined backport builds with `./gradlew --no-daemon
compileJava`
- ran `make image` in `packages/system/linstor` successfully


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

* **Chores**
* Upgraded LINSTOR server to v1.33.2 and refreshed related package
metadata.

* **Bug Fixes / Reliability**
* Safer toggle-disk flows with retry/abort handling and preservation of
DRBD TCP ports.
* Automatic retry/cleanup for stale bitmap attach errors to improve
recovery.

* **Storage / Compatibility**
* Improved LUKS2 header sizing, optimal I/O size detection, and related
patches for more reliable disk formatting and alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-09 21:44:06 +02:00
mattia-eleuteri b8d48ad711 [monitoring] Fix infra dashboards missing in default variant
The default variant deploys monitoring to the cozy-monitoring namespace,
but the infra dashboards condition only checked for tenant-root. This
adds cozy-monitoring to the condition, consistent with the existing
pattern in vmagent.yaml.

Fixes cozystack/cozystack#2349

Signed-off-by: mattia-eleuteri <mattia@hidora.io>
2026-04-09 16:31:24 +02:00
Andrey Kolkov 2234824feb feat(backups): restore vmi to copy in another namespace
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2026-04-09 14:06:33 +04:00
Andrei KvapilandClaude 7fb0c575c0 linstor: bump piraeus-server from v1.33.1 to v1.33.2
All existing patches (allow-toggle-disk-retry, fix-duplicate-tcp-ports,
fix-luks-header-size, retry-adjust-after-stale-bitmap) apply cleanly on
the new version without modifications.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
2026-04-08 17:08:54 +02:00
mattia-eleuteri 026b1c7811 [virtual-machine] Exclude external VM services from Cilium BPF LB
Add service.kubernetes.io/service-proxy-name label to LoadBalancer
services when external: true. This prevents Cilium from adding the
service to its BPF service map, fixing two issues:

1. Inter-tenant connectivity via public LB IPs: Cilium's kube-proxy
   replacement DNATs traffic to LB IPs before policy evaluation,
   causing the CiliumClusterwideNetworkPolicy to block legitimate
   cross-tenant traffic through public IPs.

2. WholeIP broken on Cilium 1.19+ (#2327): wildcard service drop
   entries block all ports not declared in the Service spec before
   traffic reaches cozy-proxy's nftables rules.

With this label, Cilium completely ignores the Service. Routing is
handled by kube-ovn (via the wholeIP annotation) and MetalLB
continues to advertise the IP via L2 unaffected.

Tested on Cilium 1.18.6 and 1.19.1: inter-tenant via LB IP works,
pod IP isolation preserved, external access unaffected.

Signed-off-by: mattia-eleuteri <mattia@hidora.io>
2026-04-08 15:20:58 +02:00