Commit Graph
6564 Commits
Author SHA1 Message Date
Aleksei Sviridkin a75919087e fix(opensearch-operator): bound every call to a cluster's HTTP API (#4341)
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `kind/backport` if it's a bugfix that needs to be
backported to a previous version.
-->

## What this PR does

A call to a cluster's HTTP API has no bound of its own.
`NewOsClusterClient` builds its own `http.Transport`, and a fresh
transport carries no dial, TLS-handshake or response-header timeout; the
config sets no `http.Client`, so there is no timeout there either; the
calls the client makes on its own behalf pass `context.Background()`;
and the calls that take a context from the caller are handed one with no
deadline on it, since nothing on the reconcile path sets one. The
`Timeout` field the request types carry is the server-side query
parameter: it bounds how long the server waits before answering, which
helps only once something answers.

Each controller runs a single worker, so one call that never returns
stops every `OpenSearchCluster` from being reconciled, and the finalizer
on the next deletion waits out its whole termination timeout with
nothing in the log to say why. Reported in #4337, measured in #4332.

A fourth patch on the image bounds every request the operator's client
sends, at two levels.

- Every request goes out through a `RoundTripper` that puts a 30s
deadline on it. That is the only level that reaches the request
opensearch-go builds for itself: before a client's first request the
library runs a product check with a `context.Background()` of its own
and decodes the body of the answer, and a client is built per reconcile,
so that check runs on every one. The deadline cannot be released when
`RoundTrip` returns, because the body goes back to the caller still
open, so the cancel rides on the body and is released by `Close`, which
is how `http.Client` applies its own `Timeout`.
- That bounds one attempt. opensearch-go retries a request up to three
more times on 502, 503 and 504, and the transport's deadline starts over
on each of them, so the calls carry a 30s deadline a second time for the
series: the 15 calls in `os_client.go` that request and decode before
returning through `requestContext`, and the 28 that go through the
`doHTTP*` helpers in `performRequest`.
- The transport keeps a 10s dial and a 10s TLS handshake, so an
unreachable node fails in a third of the budget with an error that names
what went wrong.

`ResponseHeaderTimeout` is gone from the transport. It stops counting
the moment the headers arrive, so it is no bound on a body that starts
and then stops, and at 60s it sat above a request deadline that now
covers everything it covered.

This bounds the wait, it does not make an unresponsive cluster answer: a
call that passes its deadline fails the reconcile, which requeues.

The change belongs upstream, and there is no upstream issue for it yet.
The Dockerfile entry carries the drop condition the other entries carry.

### Tests

Seven, in the package the image's own `go test -race` step already runs:

- a call to a cluster that accepts the request and answers nothing
returns instead of running forever;
- a new client, against a cluster whose product check flushes its
headers and then stops, returns instead of running forever;
- a request through the operator's transport, to a server that accepts
the connection and then says nothing, returns instead of running
forever;
- a body that arrives with its headers and then stops is abandoned;
- a call retried on 503 is bounded as a series rather than per attempt;
- a body is still readable after the call that fetched it has returned;
- the transport carries the handshake bound by value, and the dial bound
through the dialer it is built from.

Every bound was checked against the mutation that removes it, and all
seven turn a test red: the transport wrapper dropped from the
constructor, the deadline dropped from `RoundTrip`, the cancel moved off
the body there, the deadline dropped from `performRequest`, one call
site reverted to `context.Background()`, and the dial and handshake
timeouts dropped in turn. Waits are measured from the test goroutine, so
an unbounded call fails the test rather than hanging the run until CI
kills it. The silent-server test holds its accepted connections and
forces a collection while the call is in flight, because an unreachable
`net.Conn` is closed by the collector (`net/fd_posix.go` puts a
finalizer on `netFD`) and that turns the silent server into one that
resets the connection.

The full four-patch sequence applies to a pristine v2.8.0 tree in the
glob order the build relies on, with no offsets and no fuzz; `gofmt` and
`go vet` are clean; and `docker buildx build images/opensearch-operator`
builds the image on linux/arm64 with the `go test -race` step green.

### Screenshots

Not a UI change.

### Downstream repositories

<!--
Cozystack is upstream for repositories that are NOT kept in sync with it
automatically, and nothing in CI compares the two sides, so a change
here can break them silently.

Which change forces what, and which file to touch, is the trigger map in
docs/agents/contributing.md under "Downstream Repositories". Walk it
before you tick anything.

IF YOU ARE AN AI AGENT OR AN LLM, THIS IS PART OF THE TASK, NOT A FORM
TO FILL IN:

1. Walk the trigger map against the actual diff of this PR, file by
file. Do not decide from the PR title or from your memory of what you
changed.
2. For every repository you tick, open the follow-up PR there and link
it on the same line. A ticked box with no link claims work that does not
exist, which is worse than an empty box. Search that repository for an
open PR or issue covering it first, and link that instead of filing a
duplicate.
3. When the follow-up is out of scope, or needs a decision that is not
yours to make, open an issue in that repository instead, link that, and
say so in the PR body. Do not push a speculative PR into another
repository just to fill a line here.
4. Do not tick "No downstream repository is affected" to make the
checklist go away. If you are unsure, leave every box empty and say so
in the PR body, so a human decides.
-->

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

Walked the trigger map against the diff: one comment block in
`packages/system/opensearch-operator/images/opensearch-operator/Dockerfile`
and one new file in that image's `patches/`. No package added, renamed
or removed, no values key, no schema, no enum, no `hack/` file, no make
target, no namespace and no `ApplicationDefinition`.

### Release note

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

```release-note
fix(opensearch-operator): time out calls to a cluster's HTTP API, so one unanswered request no longer stops the operator reconciling
```


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

* **Bug Fixes**
* Added timeout handling for OpenSearch cluster connections, including
connection, TLS-handshake, response-header, and request deadlines.
* Prevented requests from hanging indefinitely when a cluster is
unavailable, unresponsive, or stops returning data.
* Ensured response bodies remain readable after the initial request
completes when applicable.
* Bounded the full request lifecycle, including response-body reads and
retried calls.

* **Tests**
* Added coverage for connection, response, retry, and body-read timeout
scenarios.
  * Enabled race detection for relevant package tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-23 00:47:46 +04:00
IvanHunters ff0b851bbc test(vpn): add default render helm unit tests (#4347)
## What this PR does

vpn was one of the eight user-facing apps in `packages/apps/` called out
in #3631 as shipping with zero helm-unittest coverage. This adds a
`tests/` suite pinning the default render and wires the standard `test:
helm unittest .` target the sibling charts already use, following the
same pattern as #4081, #4074 and #4093.

Ran `helm unittest .` locally against the chart, it passes. `make show`
needs a live cluster connection through `cozyhr`, which was not
available in this environment, so I relied on `helm unittest` for local
verification, same as the referenced sibling PRs.

### Screenshots

Not applicable, test-only change.

### Downstream repositories

Walked the trigger map in docs/agents/contributing.md against the diff:
this only adds a test file and a Makefile target inside an existing
package, it does not add, rename or remove a package, and it does not
touch `values.yaml`, `values.schema.json`, `Chart.yaml` or `README.md`,
so none of the listed triggers apply.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
test(vpn): add Helm unit test coverage for the default render
```


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

## Summary by CodeRabbit

- **Tests**
- Added automated validation for the VPN deployment’s default
configuration, including replica count, container image, service port,
and generated resource naming.

- **Chores**
- Added a convenient command for running the VPN chart’s automated
tests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 23:07:18 +03:00
Aleksei Sviridkin 4bf95815a8 fix(kubernetes): render the aggregation-layer UID header for tenant OIDC (#3539)
## What this PR does

Tenant clusters with `spec.oidc.mode: System` or `CustomConfig`
authenticate users fine, but a UID does not survive the hop to an
aggregated API server. The tenant kube-apiserver publishes
`requestheader-uid-headers` into the
`extension-apiserver-authentication` ConfigMap only when the flag is
set, and the chart never set it. The aggregation layer still sends
`X-Remote-Uid`, but the extension server trusts only the header names
published in that ConfigMap, so it ignores the one it gets and the call
arrives without a UID. The field documentation even told operators to
add the flag by hand via `controlPlane.apiServer.extraArgs`.

It forwards a UID rather than creating one. ServiceAccount tokens carry
one, and for those the aggregation hop starts working. Users
authenticated by `spec.oidc.mode: System` still have none, because the
generated AuthenticationConfiguration maps `username` and `groups` only;
giving them one means mapping a claim, which changes the identity of
users who have none today, and is tracked separately.

The chart now renders `--requestheader-uid-headers=X-Remote-Uid` on
v1.32 and newer, and adds `--feature-gates=RemoteRequestHeaderUID=true`
on v1.32, where the gate is still alpha and the apiserver rejects the
flag without it. From v1.33 the gate is beta and on by default, so only
the header flag is rendered. v1.31 gets nothing: the flag does not exist
there, and writing it by hand stops the apiserver from starting.
`--emulated-version` in the same passthrough moves that decision,
because the gate resolves through the versioned spec at the emulated
version.

Rendering the gate on every version from v1.32 rather than on v1.32
alone was the alternative, and it would remove the migration step
entirely, because an opt-out would then drop two entries instead of one
and move the entry count by itself. It loses on what it leaves behind: a
redundant gate term on every cluster, which stops the apiserver from
starting on the day the gate is retired from the known list. The design
also prefers rendering over failing for the same reason the fail
branches are narrow, since a cluster stranded on a HelmRelease that no
longer converges is a worse outcome than one that never gets the UID
fix.

The chart reads an operator entry the way the apiserver reads it: header
list elements matched whole and untrimmed and folded as `EqualFold`
folds them, gate names compared exactly, gate values resolved as
`strconv.ParseBool` resolves them, `AllAlpha` and `AllBeta` blankets
honoured, an empty value treated as no entry. On v1.32 an entry that
says nothing about `RemoteRequestHeaderUID` gets a second one carrying
the operator's own terms plus `RemoteRequestHeaderUID=true`. Carrying
those terms into the merged copy is what makes it land correctly whether
the tenant control plane collapses duplicate flags into the last one or
hands both to the apiserver, which merges them in the order it reads
them.

Tests cover the render matrix over mode, version and operator input:
every fail path with its exact message, the ownership and merge paths,
duplicate entries in both orders, blanket gates, empty values, and case
variants on the header name and on the gate value. The latest-version
tenant lane creates its tenant with `spec.oidc.mode: System` and asserts
the flag on the rendered KamajiControlPlane, so the tenant apiserver
starts with the flag in CI. Nothing asserts the UID arriving at an
aggregated apiserver; that needs an extension server inside the tenant
and is not in this change. The layer where the count-keyed reset
actually bites is the tenant Deployment's arguments, and asserting there
needs a live tenant control plane, so it is not in this change either.

The rule this follows is that a configuration which renders and boots
today keeps rendering. Both flags are new, so an `extraArgs` shape
reaching this code was rendering before it existed, and stranding a
running tenant on a HelmRelease that no longer converges is a worse
outcome than one that never gets the fix. A few shapes do stop
rendering, and reaching any of them takes writing
`--requestheader-uid-headers` into the passthrough by hand, attempting
exactly what the chart now does and getting it wrong; in each the
kube-apiserver fails option validation and never starts, so nothing that
was working is lost. `docs/oidc-tenant.md` covers these shapes. One
known limitation is in there and worth naming here: on v1.33 and newer,
adding the documented opt-out is count-neutral in `extraArgs`, and so is
removing it again, and on v1.33 and v1.34 adding or removing
`--emulated-version=1.31`. The pinned Kamaji build rewrites a running
apiserver's arguments only when that count changes and otherwise keeps
every flag that has left the list, so either the header flag stays
beside a now-disabled gate or the disabled gate stays beside the header
flag, and the apiserver refuses to start. Changing the entry count in
the same edit is the mitigation, and the fix belongs to the pin rather
than to this chart, tracked in #3541. The paths that need no operator
edit are clear: turning the feature on for a default tenant moves the
count, and a tenant already carrying the opt-out renders
byte-identically before and after.

Out of scope: `mode: None` still publishes no UID header even though
ServiceAccount tokens carry UIDs. That is pre-existing, and widening the
condition changes the render for every default tenant. Tracked
separately, along with the claim mapping above.

Closes #3377

### Screenshots

Not a UI change.

### Downstream repositories

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

Checked the trigger map against the diff: no package added or removed,
no schema field, enum or default changed (description text only), no
ApplicationDefinition surface touched, no hack/ layout moved. The
managed-apps reference page picks up the new field description through
release-time README regeneration.

### Release note

```release-note
fix(kubernetes): tenant clusters with `spec.oidc.mode` set to `System` or `CustomConfig` now render `--requestheader-uid-headers=X-Remote-Uid` on the tenant kube-apiserver, plus the `RemoteRequestHeaderUID` feature gate on v1.32, so that an aggregated API server can read the caller's UID out of the request headers. Without that flag the tenant apiserver never publishes `requestheader-uid-headers`, and an aggregated API server ignores the UID header even for callers that have one, ServiceAccount tokens among them. It forwards a UID rather than creating one: users authenticated by `spec.oidc.mode: System` still have none, because the generated AuthenticationConfiguration maps `username` and `groups` only, and that is tracked separately. Adding the flag by hand through `controlPlane.apiServer.extraArgs` is no longer necessary, and an entry of your own is respected: the chart steps aside when yours already carries `X-Remote-Uid` or already enables the gate, merges `RemoteRequestHeaderUID=true` into a copy of a v1.32 `--feature-gates` entry that says nothing about the gate, and renders neither flag when you switch the gate off. The render fails where a `--requestheader-uid-headers` list of your own would stop the kube-apiserver from starting anyway: one that omits `X-Remote-Uid`, one with an empty element, and a list of your own while the gate is off. On v1.33 and newer there is a migration step, and it applies in both directions: the pinned Kamaji build rewrites a running apiserver's arguments only when the number of `extraArgs` entries changes, and otherwise keeps every flag that has left the list. Adding the documented opt-out keeps that number the same, so the header flag already on the Deployment stays beside the now-disabled gate. Removing the opt-out later keeps it the same too, so the disabled gate stays on the Deployment beside the header flag the chart renders again, although your spec no longer shows it. Adding or removing `--emulated-version=1.31` has the same shape both ways on v1.33 and v1.34. The apiserver refuses to start in each case, so change the entry count in the same edit.
```
2026-09-23 00:03:20 +04:00
IvanHunters 48058dcc74 build(opensearch-operator): run the patched tree's tests under -race
The timeout tests drive a listener from one goroutine and inspect what it
accepted from another. The plain run passes an unsynchronised access there, so
nothing in this repository would catch one. The builder image carries cgo and
gcc, and the package images are built natively on x86-64 runners, so the
detector is supported where this runs.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-22 22:50:24 +03:00
IvanHunters 1a2c87635a fix(opensearch-operator): bound every call to a cluster's HTTP API
Upstream gives the operator's calls to a cluster no deadline of any kind. The
transport it builds carries no dial, handshake or response-header timeout, no
http.Client is configured, the calls the client makes on its own behalf pass
context.Background(), and the ones that take a context from the caller are
handed one with no deadline on it. A cluster that accepts a connection and then
says nothing holds such a call for as long as the process lives, and with one
worker per controller that single call stops every OpenSearchCluster from being
reconciled. The deletion that then waits out its whole termination timeout is
the visible end of it.

The bound goes on the RoundTripper, the one level every request passes through,
the ones opensearch-go builds for itself included: before a client's first
request the library runs a product check that carries a context.Background() of
its own and decodes the body of the answer, and a client is built per reconcile,
so that check runs on every one. The deadline cannot be released when RoundTrip
returns, because the body goes back to the caller still open; it rides on the
body and is released by Close, the way http.Client applies its own Timeout.

That bounds one attempt. opensearch-go retries a request up to three more times
on 502, 503 and 504 and the transport's deadline starts over on each, so the
calls carry a deadline a second time for the series: through requestContext
where the call decodes the answer before it returns, and through performRequest
where the still-open body goes back to the caller. The dial and the TLS
handshake keep a shorter bound of their own, so an unreachable node fails in a
third of the budget with an error that names what went wrong.

Each of those bounds has a test that fails when it is removed.

Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-22 22:50:24 +03:00
Aleksei Sviridkin 08bbed3fb5 feat(talos-log-collector): add talos-log-collector package (#3260)
## What this PR does

Adds `talos-log-collector`, an optional system package: a node-local
Vector
DaemonSet that receives Talos machine and kernel logs and forwards them
to the
tenant VictoriaLogs (vlinsert), alongside the container/audit/event logs
already collected by monitoring-agents.

Talos keeps system and kernel (kmsg) logs inside the OS and does not
write them
to a host file the Fluent Bit `tail` input could read, so these logs are
currently never collected. Vector receives them over the node loopback
(Talos pushes to 127.0.0.1:5170). Talos exposes the two streams through
independent config paths, and both must point at the loopback socket:
`machine.logging.destinations` forwards the JSON-lines service logs, and
a
`KmsgLogConfig` document forwards the kernel (kmsg) stream (including
DRBD
kmsg). The node-side setup for both is in the README.

Design notes:
- The pod stays on the pod network so cluster DNS resolves vlinsert with
no
  extra config. Talos pushes to 127.0.0.1:5170; a `hostPort` bound to
`hostIP: 127.0.0.1` publishes that loopback socket into the pod via
portmap,
  keeping the receiver off the host network (no node-wide pod exposure).
- The receiver binds inside the pod netns, so a NetworkPolicy is
load-bearing:
a CiliumNetworkPolicy restricts ingress to the host entity when Cilium
is
present, otherwise a default-deny-ingress `networking.k8s.io/v1`
fallback
  keeps the socket protected on non-Cilium variants.
- Runs non-root, read-only root filesystem, all capabilities dropped.
- Optional package (opt-in via `bundles.enabledPackages`): inert until
each
Talos node is configured to push logs. Node-side setup is in the README.

Verified end-to-end on a Talos v1.12 dev cluster: service and kernel
logs
(including DRBD kmsg) reach VictoriaLogs; pods run non-root with 0
restarts.

### Release note

```release-note
feat(talos-log-collector): add talos-log-collector package to collect Talos machine and kernel logs into VictoriaLogs
```


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

* **New Features**
* Added an optional node-local Talos log collector (via system bundle
packages) that forwards Talos service and kernel logs over loopback to
the platform log ingestion endpoint.
* Introduced the collector Helm chart (Vector-based) with configurable
log level, loopback TCP receive port, digest-pinned image, default
resources, and routing target.
* Added conditional Cilium network policy support with a safe
deny-by-default fallback.
* **Security**
* Hardened the collector to run non-root with reduced privileges,
read-only filesystem, and dropped Linux capabilities (and disabled
service account token automount).
* **Documentation**
* Documented configuration requirements (including kernel log
enablement), operational behavior, and teardown steps.
* **Build & Release**
  * Extended the build to include building the collector’s image.
* **Tests**
* Added Helm rendering/contract tests covering daemonset, configmap,
service account, and network policy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 23:49:33 +04:00
Aleksei Sviridkin 4fbef512b4 fix(opensearch-operator): name the component reconciler a stall sits in (#4333)
## What this PR does

The cluster controller runs nine component reconcilers in sequence, and
none of them logs unconditionally when it is entered. The TLS one has a
line above its work, but it sits on the early return for a cluster with
no security section, which
`packages/apps/opensearch/templates/opensearch.yaml` never renders, and
the rolling restart one opens at `V(1)`, off at the verbosity the
operator ships with. So a call that stops returning leaves nothing
behind at all: the operator log ends on whatever ran before it, the
object stops being reconciled, and from the outside that is
indistinguishable from an operator with nothing to do.

That is not hypothetical. Two e2e runs on 2026-09-18 ended that way, and
the evidence is the operator's own log, read out of the crust-gather
host snapshot each failed run uploads, at
`snapshots/opensearch/host/namespaces/cozy-opensearch-operator/v1/pod/opensearch-operator-controller-manager-*/operator-controller-manager/current.log`.

In run 35343282947 the operator reconciled `OpenSearchCluster` 27 times
on a steady cadence, gaps of 10.2s for almost all of them and 26.5s at
the widest. Its last line is at 13:51:33.225Z. helm-controller applied
the CR revision that moves the cluster onto the chart CA at 13:51:35Z.
Nothing was written for the remaining four minutes, the pod stayed
Running and Ready with 0 restarts, and the admin certificate the step
was waiting on was never re-minted. Run 35333289958, a different branch
and a different operator pod, has the same shape: last line
10:56:35.706Z, Apply at 10:56:38Z, widest gap before that 24.2s, then
silence. The securityconfig Job had completed successfully in both, and
the cluster itself was quiet. #4332 tracks it.

A patch on the image now wraps each component so a call still in flight
after a minute reports the component's own name and how long it has been
running, and keeps reporting on the same interval while it is stuck. A
pass through the components takes seconds on a healthy cluster and the
controller requeues itself every 30s, so a minute is well past anything
normal. Nothing at all is written while a component behaves, which is
why this is a watchdog rather than an entry log: logging every entry
would put nine lines per cluster into the log on every reconcile. The
finalizer path is deliberately left alone, because every
`DeleteResources` at this version returns immediately and a watchdog
there could only spawn goroutines for calls that cannot block.

The e2e lane already collects that log and nothing in it changes here.
#4279 put a `podLogs` leg on the operator's own namespace into the
opensearch catch, `namespace: cozy-opensearch-operator`, `selector:
control-plane=controller-manager`, `tail: 2000`, and this branch sits on
top of it. The other `podLogs` leg takes the cluster's own pods in the
test namespace, and cozyreport's per-pod walk keeps only pods that are
not Running or not Ready, which a stalled operator is neither, so before
#4279 these lines reached the job log only through the crust-gather
snapshot.

This is a diagnostic, not the fix. Where the reconcile stops is still
unknown, and the next occurrence should name it. The Dockerfile comment
says to drop the patch once the stall itself is understood and fixed.

The new code lives in `pkg/reconcilers`, already covered by the image's
`go test` step. Five specs hold it: the wrapped result and error pass
through untouched, a call that returns before the threshold is never
reported, a call that returns before the threshold also takes the
watchdog with it, the report names the component and a moving elapsed
while the call is in flight, and the report repeats rather than firing
once. Six mutations each fail the spec that owns them: dropping
`close(done)`, `time.NewTimer(0)`, an empty component name, a frozen
elapsed, a fire-once ticker, and a swallowed result. The full four-patch
sequence applies to a fresh v2.8.0 tree in the alphabetical order the
build relies on, and builds, vets and tests clean there, with five
`-race` runs of the new specs.

### Screenshots

Not a UI change.

### Downstream repositories

Walked the trigger map against the diff: one Dockerfile comment block
and one new patch file under
`packages/system/opensearch-operator/images/`. No values key, schema,
make target or documented workflow changes.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

- **New Features**
- Added stall monitoring for OpenSearch component reconciliation. When
reconciliation remains in progress beyond the configured threshold, the
operator logs the component name and elapsed time at regular intervals.
- Monitoring remains silent for promptly completed operations and
preserves existing results and errors.

- **Documentation**
- Clarified reconciliation watchdog behavior and documented why certain
resource deletion operations are not monitored.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 23:44:30 +04:00
scooby87 95b8549238 feat(dashboard): brand the token-proxy login page from the console config (#4255)
## What this PR does

Part of #4217. Dashboard branding (title, logo) is only reachable
through the authenticated dashboard/kube-api path, so the pre-auth
surfaces render unbranded. This PR fixes the first of them: the
token-proxy login page — the first screen an unauthenticated user sees
on a non-OIDC cluster — hardcoded `<title>Login</title>` and had no
branding hookup at all.

The branding config lives in the `cozy-dashboard-console-config`
ConfigMap, which the SPA reads only through the authenticated kube-api.
This mounts that same ConfigMap read-only into the token-proxy container
and renders its `titleText`, `logoText` and `logoSvg` on the login page,
falling back to the historical unbranded page when the file is absent or
unreadable so a cluster without branding is unaffected. The SVG is
encoded into an `<img>` data URI rather than inlined, which keeps any
embedded script inert. The OIDC branch is untouched: its login is served
by Keycloak, already branded through the realm login theme.

The unauthorized/access-denied screens rendered by the SPA itself (for a
logged-in user without kube-api RBAC) are handled in a separate PR.

Verified with `go test`, `go vet`, `gofmt`, and `helm unittest`
(branding mount asserted present in the non-OIDC branch and absent in
the OIDC branch).

### Downstream repositories

The diff touches only `packages/system/dashboard` (the in-repo
token-proxy image and its chart). No app is added, renamed or removed,
no `values.schema.json` / enum / default changes, no CRD or namespace
changes, and the `config.json` branding keys are unchanged. Nothing in
the trigger map applies.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(dashboard): the token-proxy login page now renders the deployment's branding (title and logo from `branding.titleText` / `branding.logoText` / `branding.logoSvg`) instead of the hardcoded default, so unauthenticated users on non-OIDC clusters see a branded sign-in screen.
```


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

## Summary by CodeRabbit

* **New Features**
* Added deployment branding to the login page, including a custom title,
logo, and heading.
* Branding is automatically loaded from the dashboard configuration when
available.
* Login pages continue to display a default unbranded experience when
branding configuration is unavailable or invalid.

* **Tests**
  * Added coverage for branded and unbranded login-page rendering.
* Added validation for branding volume mounts across authentication
modes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 22:07:32 +03:00
Aleksei Sviridkin 12e64a99cd fix(rabbitmq): right-size the default resources preset to s1.nano (#3936)
## What this PR does

Raises the RabbitMQ default resource preset from `t1.nano` (128Mi
memory) to `s1.nano` (250m CPU / 512Mi memory).

A default-sized RabbitMQ 4.2 broker on Cozystack 1.6.1 was OOMKilled
with exit 137, reached 10 restarts, and never became Ready. A canary on
a larger preset reached 3/3 Ready with zero restarts; its working set
peaked at 125.8Mi. Against the 128Mi `t1.nano` ceiling that is 98% of
the limit in steady state, and cgroup accounting (working set plus page
cache) crosses it under load. The canary and its storage were removed
after validation.

### Sizing

`s1.nano` keeps the CPU of `t1.nano` (250m) and quadruples memory to
512Mi — a 4x margin over the observed 125.8Mi peak.

An earlier revision of this PR proposed `u1.nano` (1Gi). Review
correctly objected that 1Gi reserves 8x the observed working set with no
measured peak to justify it, and asked to either report that peak or
right-size the default. The peak is reported above, and the default is
right-sized to the smallest preset that clears the ceiling with a real
margin rather than to the first one that is comfortably large.

### Upgrade impact

cozystack-api applies OpenAPI schema defaults on the read path and
stores what the client sent: `Get` runs `applySpecDefaults` on its way
out (`pkg/registry/apps/application/rest.go:1232`) and the write puts
`app.Spec` into `HelmRelease.spec.values` (`rest.go:1652`). That splits
existing brokers in two.

**Brokers with no stored `resourcesPreset`** pick up the new chart
default on the next Helm reconcile. For them this upgrade is:

- a rolling restart of every replica, and
- a rise in guaranteed memory reservation from 128Mi to 512Mi per
replica, because the sanitize helper sets memory request equal to limit
— roughly 1.1Gi extra reserved per 3-replica instance.

**Brokers whose values went through a read-modify-write keep `t1.nano`
and are not resized.** `Update` opens with `r.Get` (`rest.go:489`), so a
GET-then-PUT — `kubectl edit` is the unambiguous case, and any client
that reads before writing behaves the same — freezes the schema default
of the moment into the stored values, and a stored value beats the chart
default. Those brokers are not restarted, not resized, and keep hitting
the ceiling this PR raises.

Nothing downstream can separate a deliberate `t1.nano` from one a round
trip baked in, so this is documented rather than migrated. After the
upgrade the affected brokers are the ones that still report the old
preset:

```
kubectl get rabbitmq -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,PRESET:.spec.resourcesPreset
```

The same command before the upgrade tells you nothing: the cozyrd
default is still `t1.nano` then, so the read path fills it in for both
groups. Afterwards, anything reading `t1.nano` carries a stored value;
setting `resourcesPreset: s1.nano` explicitly on the ones that should
move is the fix.

PVCs are untouched, so there is no data loss, but node headroom should
be checked before upgrading.

Also worth knowing when reading the parameter table: the preset does not
only apply to a fully omitted `resources`.
`cozy-lib.resources.defaultingSanitize` merges it key by key, so a
broker pinned to `resources: {cpu: 700m}` keeps its CPU and moves from
128Mi to 512Mi with everyone else. The field descriptions now say so,
and a test pins it.

The values, generated schema, ApplicationDefinition, typed API, and
README all carry the same default. Tests cover the new default, an
explicit legacy override, explicit resources, and a partially set
`resources` map inheriting the preset for what it leaves out.

Validation:

- `helm template` with the chart's `cozy-lib` dependency renders `limits
250m / 512Mi` and `requests 0.025 / 536870912` for the default; an
explicit `t1.nano` renders `128Mi / 134217728`; explicit `700m / 2Gi`
renders `0.07 / 2147483648`. The unit test asserts exactly these values.
- Provider counterpart: cozystack/terraform-provider-cozystack#34.

### Screenshots

Not a UI change.

### Downstream repositories

The default is restated by the Terraform provider, so the linked
provider PR updates it and documents the state-plan implication. No
other trigger-map entry matches this diff.

- [ ] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [x]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
https://github.com/cozystack/terraform-provider-cozystack/pull/34
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

```release-note
fix(rabbitmq): raise the default resource preset from t1.nano to s1.nano so default-sized brokers are no longer OOMKilled. Brokers with no stored `resourcesPreset` are restarted on upgrade and their guaranteed memory reservation grows from 128Mi to 512Mi per replica; check node headroom before upgrading. Brokers whose values already pin `resourcesPreset`, including ones where a read-modify-write through the API froze the old default into them, keep their old size: list them after the upgrade with `kubectl get rabbitmq -A -o custom-columns=NAME:.metadata.name,PRESET:.spec.resourcesPreset` and set `resourcesPreset: s1.nano` explicitly on the ones that should move. The preset also fills any resource a partially set `resources` leaves out, so a broker pinned to cpu alone moves too. A tenant that opted into `resourceQuotas` and sits near its memory ceiling has the extra reservation rejected by quota admission, which shows up as a StatefulSet that never scales rather than as pods left Pending.
```
2026-09-22 21:53:02 +04:00
Alexey Artamonov 05b121809c feat(dashboard): brand the token-proxy login page from the console config
The dashboard branding (title, logo) lives in the
cozy-dashboard-console-config ConfigMap, which the SPA reaches only
through the authenticated kube-api. The token-proxy login page — the
first screen an unauthenticated user sees on a non-OIDC cluster — had no
access to it and rendered the hardcoded default title and heading,
defeating branding for customer-facing deployments.

Mount that ConfigMap read-only into the token-proxy container and render
its titleText, logoText and logoSvg on the login page, falling back to
the historical unbranded page when the file is absent or unreadable so a
cluster without branding is unaffected. logoSvg is already base64 in the
config — the SPA's Logo.tsx consumes it that way — so it is served
verbatim through an <img> data URI, never re-encoded, which keeps any
embedded script inert. The OIDC branch is untouched: its login is served
by Keycloak, already branded through the realm theme.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-22 19:20:36 +03:00
scooby87 7d07bbe3ce [kafka] Migrate from ZooKeeper to KRaft mode (#2352)
## Summary

- Replace ZooKeeper-based Kafka deployments with KRaft (Kafka Raft)
metadata mode
- Existing ZooKeeper instances migrate automatically and in place via a
Helm `pre-install,pre-upgrade` hook Job that drives Strimzi's
ZK-to-KRaft migration state machine
- New instances deploy directly in KRaft mode with broker and controller
`KafkaNodePool`s
- The node pools are owned by the migration hook, not by regular chart
manifests (see "Why the pools are hook-managed")
- Kafka version is user-configurable (v3.9 default, v3.8 available)
- Strimzi operator is unchanged in this PR (stays at 0.45.1-rc1); 0.45.x
is the last line that supports ZooKeeper→KRaft migration
- ZooKeeper removed from the dashboard UI (ApplicationDefinition)

## Migration strategy

Both fresh installs and ZooKeeper→KRaft migrations are driven by a
single Helm hook Job (`templates/migration-hook.yaml`,
`pre-install,pre-upgrade`) that owns the `KafkaNodePool`s. On every
install and upgrade the Job:

1. Ensures both node pools exist (idempotent): a broker pool named
exactly `kafka` for a migrating cluster — so Strimzi adopts the existing
`<cluster>-kafka-N` brokers and their PVCs in place — or `b-<hash>` for
a fresh cluster, plus a controller pool `c-<hash>`. The controller pool
pins `strimzi.io/next-node-ids` above the broker range so the broker
pool keeps node IDs `[0..N-1]` and Strimzi cannot hand the existing
broker IDs to the fresh controllers. The shared `kafka` name is claimed
atomically (via `kubectl create`) so only one migration per namespace
can win it.
2. Inspects the Kafka CR: no CR → fresh install (pools ensured; the main
apply then creates the KRaft Kafka CR, nothing to migrate); already
`strimzi.io/kraft=enabled` or `status.kafkaMetadataState: KRaft` → done;
any other read error → fail closed. Otherwise (a ZooKeeper cluster) it
migrates.
3. Annotates `strimzi.io/node-pools=enabled` then
`strimzi.io/kraft=migration`, polls `status.kafkaMetadataState` through
`ZooKeeper → KRaftMigration → KRaftDualWriting → KRaftPostMigration →
PreKRaft → KRaft`, and only once a safe state is reached flips
`strimzi.io/kraft=enabled`. If it times out in an intermediate state it
aborts without finalising, so Helm fails the upgrade and the next
reconcile retries from where it left off.

Data is migrated in place: brokers keep their node IDs and reuse their
existing PVCs, nothing is copied. Migration is one-way — Strimzi has no
supported KRaft→ZooKeeper rollback once complete, so lowering the
`version` field or rolling the app back to a pre-KRaft chart is
unsupported (take a backup first).

### Why the pools are hook-managed

The hook must create the pools and walk the migration state machine
*before* the main release apply. A resource a hook creates cannot also
be a regular chart manifest across the ZK→KRaft boundary: Helm's main
apply finds the pool live in the cluster but absent from the previous
(ZooKeeper) release revision and aborts with `original object
KafkaNodePool ... not found`, leaving the release `failed` even though
the cluster migrated fine (reproduced under server-side apply, the
helm-controller default). Managing the pools only from the hook avoids
that, so `templates/kafkanodepools.yaml` and the deployed-version gate
ConfigMap (`templates/version.yaml`) are removed and the pools are
applied idempotently by the Job on every install and upgrade.

## Bug fixes included

- ZooKeeper `metricsConfig` referenced wrong ConfigMap key
(`kafka-metrics-config.yml` instead of `zookeeper-metrics-config.yml`)
- ZooKeeper `storageClass` used `kafka.storageClass` instead of
`zookeeper.storageClass`
- WorkloadMonitor used `.Values.replicas` instead of
`.Values.kafka.replicas`
- `validateNameLength` now caps a Kafka app name at the KRaft controller
pod-hostname budget (`<release>-c-<hash>-<id>` must fit 63 chars),
instead of admitting names that later fail the chart render forever

## What's next

A follow-up PR will upgrade Strimzi to 0.51.0 and Kafka to 4.2 once all
instances are confirmed migrated to KRaft. That PR is already prepared
on branch `KRaft-upgrade-strimzi-051`.

## Test plan

- [x] `helm template` renders correctly for new KRaft instances (no
ZooKeeper refs)
- [x] `helm template` with custom values (1 replica, external, topics,
v3.8)
- [x] Version validation rejects unsupported versions via JSON schema
- [x] helm-unittest: 86/86 across 9 suites
- [x] `hack/kafka-migration-hook.bats`: 12/12 — the real hook script
executed against a stub kubectl (resolution branches, fail-closed reads,
atomic collision guard, finalize guard)
- [x] Operator template renders with the unchanged Strimzi 0.45.1-rc1
- [x] Live migration on a disposable cluster under server-side apply:
fresh install → healthy KRaft, release `deployed`; in-place migration of
a 20000-message ZooKeeper cluster → release `deployed`, broker PVCs
adopted in place (same UIDs), and the KRaft data byte-for-byte identical
to the pre-migration ZooKeeper data (matching sha256 over every
`partition:offset:key:value`)

### Release note
```release-note
[kafka] Enable KRaft metadata mode with automatic, in-place ZooKeeper-to-KRaft migration. Kafka version is now configurable (v3.9, v3.8). The Strimzi operator is unchanged (0.45.1-rc1).
```

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

* **New Features**
* Added chart-level Kafka version selector (default v3.9) and
KRaft-aware node pool rendering with controller storage sizing.

* **Updates**
* Default Kafka appVersion updated to 3.9.0; operator and image tag
defaults aligned to 0.45.0.
* Templates now omit ZooKeeper resources and metrics when KRaft is in
use.

* **Tests**
* New Helm unit tests for Kafka CR, node pools, metrics, topics,
dashboard mappings, and workload monitors.

* **Documentation**
* Values, schema, and README updated to document new version and
controller storage options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 19:19:21 +03:00
Aleksei Sviridkin ad4ea5c937 feat(keycloak): publish only the named realms on the login host (#4363)
## What this PR does

`ingress.adminHost` moves the admin console and Administration REST API
onto
their own hostname and narrows the public login route to `/realms`,
`/resources` and `/.well-known`. That `/realms` prefix publishes every
realm
the deployment has, `master` among them, so the super-admin login page
and
token endpoint stay reachable from the internet on the login hostname —
a
brute-force target the admin split was meant to remove. The Keycloak
reverse
proxy guide marks `/realms/master/` as internal-only.

The login route now enumerates the realms that are meant to be public:
the
platform realm (`authentication.oidc.realmName`, default `cozy`), which
every
component's issuer URL points at and which is therefore always
published, plus
whatever a deployment adds in the new `ingress.exposedRealms`.

Each realm is published as an exact path plus a prefix carrying the
trailing
slash. ingress-nginx renders `pathType: Prefix` as a plain nginx prefix
location rather than the element-wise match the Ingress API specifies,
so a
lone `/realms/cozy` would also serve `/realms/cozy-admin` there; the
pair
bounds the match on the segment and is equivalent to element-wise
matching on
a conformant controller and on Gateway API.

`master` is refused outright — both in `ingress.exposedRealms` and as
`authentication.oidc.realmName`, where the guard sits next to the
existing
realm-name checks so it applies whether or not a separate admin hostname
is
configured. Naming that realm puts the platform clients, the
cluster-admin
roles and every end user inside the realm that holds the Keycloak
super-admin.

This is a path boundary, not a network one. `KC_HOSTNAME_ADMIN` never
was a
security boundary, `expose-ingress-admin` defaults to the public
Gateway/ingressClass, and with `adminHost` unset the login route still
serves
`/`. Which hostnames the edge accepts and where the admin route attaches
decide the rest.

**Behavior change**, confined to deployments that already set
`ingress.adminHost`: a realm published on the login hostname beyond the
platform one now answers 404 until it is listed in
`ingress.exposedRealms`.
The `master` guard on `authentication.oidc.realmName` breaks nobody,
since
that parameter has not shipped in a release yet.

### Screenshots

Not applicable — no UI change.

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

Walked the trigger map against the diff: it touches
`packages/system/keycloak`
and the realm-name guard in
`packages/core/platform/templates/apps.yaml`. No
app package, no `packages/core/platform/values.yaml` key the Ansible
role
sets, no platform variant or component, no `ApplicationDefinition`, no
`hack/`
layout, no telemetry metric, no vendored chart contract.

### Release note

```release-note
feat(keycloak): with `ingress.adminHost` set, the login hostname publishes only
the platform realm and any realm listed in the new `ingress.exposedRealms`,
keeping the `master` realm login page and token endpoint off the public route.
A deployment that serves additional realms on that hostname must list them.
`authentication.oidc.realmName` no longer accepts `master`.
```


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

## Summary by CodeRabbit

- **New Features**
- Added `ingress.exposedRealms` to control which additional identity
realms are reachable through the login hostname.
- Login routes now target the platform realm and configured exposed
realms with precise realm-specific paths.
  - Duplicate realm names are handled automatically.

- **Bug Fixes**
  - Prevented publishing the privileged `master` realm.
  - Added validation for invalid realm names and configurations.
- Enforced the gateway route limit, supporting up to 31 published
realms.

- **Documentation**
- Updated configuration guidance for realm exposure and security
restrictions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 19:47:53 +04:00
scooby87 176dd78c1a feat(dashboard): serve console branding without kube-api access (#4256)
## What this PR does

Part of #4217. The SPA fetched its branding (title, logo) only by
reading the `cozy-dashboard-console-config` ConfigMap through the
kube-api. That read already works for any signed-in user —
`console-rbac.yaml` binds `get` on it to `system:authenticated` — but it
still depends on the kube-api being reachable and on the apiserver
authenticating the session's token. A dashboard login whose token the
apiserver rejects (an OIDC session the kube-api does not accept) gets no
branding at all. (The unauthenticated login page is a separate surface,
branded in #4255.)

This mounts that ConfigMap read-only under the nginx web root and serves
it at `/branding/config.json`, and reads that static asset first in
`loadConfig`, falling back to the kube-api ConfigMap for charts that
predate the mount (their static path serves the SPA `index.html`, whose
non-JSON body trips the fallback). Branding then resolves with no
kube-api call at all, so it renders regardless of whether the apiserver
authenticates the session and without the `console-config-reader` RBAC.

Because `config.json` is now a static asset reachable by anyone past the
proxy, it is restricted to the documented white-labeling keys: the
branding map also carries Keycloak-only keys, including
`loginThemeImage`, which can point at a private registry. Keycloak reads
those from `_cluster.branding` directly. The mount uses an `items`
selector so only `config.json` is exposed under the web root.

Verified with `pnpm typecheck`, `pnpm test` (7 `loadConfig` cases),
`eslint`, and `helm unittest` (mount + `items`, the `config.json` key
the mount serves, white-label keys kept incl. `tenantText`,
Keycloak-only keys absent). Also validated live on a dev cluster:
`console-rbac.yaml` binds the ConfigMap read to `system:authenticated`
(`kubectl auth can-i get` this CM as an unbound user → yes; another CM →
no), and a throwaway pod on the real console image serving the mounted
ConfigMap returns `200 application/json` at `/branding/config.json`
while an `items`-excluded key falls through to `index.html`.

### Screenshots

No visual regression to show in a static render: this changes where the
branding config is fetched from, not how it is displayed.

### Downstream repositories

The diff touches only `packages/system/dashboard` (the in-repo console
image and its chart). No app is added, renamed or removed, no
`values.schema.json` / enum / default changes, no CRD or namespace
changes, and the branding keys the SPA reads are unchanged — only the
transport (static asset vs kube-api) and a narrowing of which keys are
served. Nothing in the trigger map applies.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(dashboard): the console serves its branding as a static asset (`/branding/config.json`) read with no kube-api call, so branding renders even when the apiserver does not authenticate the session and without the console-config-reader RBAC; the served file is narrowed to the documented white-labeling keys.
```
2026-09-22 18:36:47 +03:00
Aleksei Sviridkin 187e4ce12b test(e2e): assert the tenant OIDC UID header on the control plane
The render matrix proves the chart emits --requestheader-uid-headers,
but nothing checked that the flag survives the path a real tenant takes:
aggregated API admission of spec.oidc, the HelmRelease, and the rendered
KamajiControlPlane. Assert it in both OIDC helpers, next to the existing
--authentication-config check on the same resource.

Read the arg list once into a variable and grep the capture twice. The
existing check consumed a pipeline, which is single-use, and a second
kubectl call would double the management-cluster reads these helpers
already make.

Only the header flag is asserted. The chart also renders
--feature-gates=RemoteRequestHeaderUID=true, but only on v1.32 where the
gate is still alpha, while this lane takes the highest version in the
map, which is above that. The header flag is the part that holds
wherever the flag exists at all.

The bats case for the missing flag runs the helper as a standalone
subshell under set -e rather than as an if condition. Shells suppress
errexit for a command whose status is being tested, and that reaches
inside the subshell, so the if spelling reports success and pins
nothing.

The binding query next to these assertions piped kubectl straight into
sort and so returned sort's status. Its callers capture it in a command
substitution, where errexit does not reach, so a failed query read as an
empty binding list. It now returns kubectl's failure itself.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-22 18:23:48 +03:00
Aleksei Sviridkin 97832f3a40 docs(kubernetes): explain the aggregation-layer UID hop for tenant OIDC
A tenant cluster with OIDC on authenticates users fine while every call
through an aggregated API server reaches it without the caller's UID,
and nothing in the operator guide connected the two. Describe the hop,
what the chart now renders per Kubernetes version to make it work, and
how a hand-written flag in the extraArgs passthrough interacts with it.

The migration step is described in both directions. The pinned tenant
control plane rewrites a running apiserver's arguments only when their
count changes and otherwise keeps every flag that has left extraArgs,
so removing an opt-out or an --emulated-version entry strands a flag
exactly as adding one does, and the operator can no longer see it in
their own spec.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-22 18:23:48 +03:00
Aleksei Sviridkin f4e923aa82 docs(kubernetes): restate the apiServer extraArgs contract for OIDC
The extraArgs field documentation told operators to add
--requestheader-uid-headers themselves, which the chart now renders
whenever OIDC is on. Describe what it renders per version, when it
defers to an operator entry, when it merges into one, and that it still
refuses to render shapes the apiserver would not start on.

The version field says it too: choosing v1.32 is what makes the chart
render the feature gate next to extraArgs, and a reader of that field
alone would otherwise not learn that the two interact. The package's
hack/update-versions.sh writes that description itself on every make
update, so it carries the same text, or the next version bump would
drop the sentence again.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-22 18:23:48 +03:00
Aleksei Sviridkin d870b392fa fix(kubernetes): render the aggregation-layer UID header for tenant OIDC
A tenant kube-apiserver publishes requestheader-uid-headers into the
extension-apiserver-authentication ConfigMap only when the flag is set,
and the chart never set it. Without that key the request-header
authenticator on an aggregated apiserver does not know which header
carries the UID and ignores it, so a caller who has a UID is served
without one. Operators had to add the flag by hand through the
controlPlane.apiServer.extraArgs escape hatch.

This forwards a UID the caller already has and does not create one.
ServiceAccount tokens carry a UID and gain from it. OIDC users
authenticated through spec.oidc.mode: System do not: the
AuthenticationConfiguration the chart generates maps username and
groups only, and with no claimMappings.uid upstream returns an empty
UID (plugin/pkg/authenticator/token/oidc/oidc.go, getUID, read at
v0.35.0), so there is nothing to carry. Mapping a claim would give
those users a UID and change the identity of users who have none
today, which is a separate decision and is tracked separately.

The half read rather than run: that an existing UID is lost without the
published key comes from the controller writing the ConfigMap and the
request-header authenticator consuming it, not from a live cluster. That
OIDC users have no UID is unambiguous in the source named above.

Render --requestheader-uid-headers=X-Remote-Uid whenever OIDC is on,
plus --feature-gates=RemoteRequestHeaderUID=true on v1.32, where the
gate is still alpha and the apiserver rejects the flag without it.
v1.31 gets nothing: the flag does not exist there and an unknown flag
is fatal to the apiserver.

The deciding version is the effective one, not the image tag.
--emulated-version in the same passthrough moves it down, and the gate
resolves at the emulated version through featureSpecAtEmulationVersion
(featureSpecAtEmulationAndMinCompatVersion by v1.35), so an emulated
1.32 needs the gate line even on a v1.35 cluster, and below 1.32 the
gate is PreAlpha and cannot be enabled at all, which leaves nothing
safe to render.

Both flags are new, so every extraArgs shape reaching this code was
rendering before it existed, and turning one of those into a render
error would strand a running tenant on a HelmRelease that stops
converging while its control plane carries on. The chart therefore
yields to an operator entry that already satisfies the requirement,
renders nothing at all when the operator turned RemoteRequestHeaderUID
off, and otherwise merges its gate term into a copy of the operator's
--feature-gates value. Carrying that value into the merged entry
decides the gate whether the tenant control plane collapses duplicate
flags into the last one or hands both to the apiserver, which merges
them in the order it reads them.

Operator entries are read the way the apiserver reads them: header list
elements matched whole and untrimmed, folded as EqualFold folds them,
and refused when one is empty after trimming, as checkForWhiteSpaceOnly
refuses it; gate names compared exactly; gate values resolved as
strconv.ParseBool resolves them; blanket AllAlpha and AllBeta honoured;
an empty value treated as no entry at all. Each flag is judged by its
last entry carrying a value. The apiserver's CSV reader strips quotes
the chart does not parse, so quotes on that entry make the chart render
nothing, while quotes on an earlier entry the collapse drops do not.

The render fails only where a uid-headers list of the operator's
describes a control plane that already refuses to start: one that
omits X-Remote-Uid, one with an element that is empty after trimming,
and any such list while the gate is off. Withholding the chart's own
flag rescues none of them, because the flag the apiserver rejects is
the operator's.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-22 18:23:48 +03:00
Aleksei Sviridkin 9efe78ce44 test(e2e): hold the tenant-cluster install wait to an ordering rule (#3802)
A test that creates a tenant `Kubernetes` cluster must not end while
that release is still installing. The kind drops the install wait on its
main resources, but the chart's blocking post-install hook still holds
the install open, so a test that asserts and ends hands the rest of it
to teardown. Teardown then reds on a step that tests nothing. Reading
the cluster mid-install is fine. Ending mid-install is not.

`hack/e2e-chainsaw/_lib/run-kubernetes.sh` already waits for the parent
release before its tenant-cluster assertions, and that wait is older
than this change. Nothing held it there. This adds the guard that does.

The guard reads the script, not Chainsaw fixtures: the cluster comes
from a heredoc, so a guard walking `apply.file` would find nothing to
check and pass. It requires the CR, then a readiness wait on the parent
release, then the assertions the test ends on. The wait has to block,
and it has to be unconditional in the body that holds it. One more case
holds this script to being the only creator of that kind, since a second
one would be governed by no rule here.

The ordering is read within one function body, so splitting
`run_kubernetes_test` reds the unit lane until the rule is pointed at
the new shape.

Timeout length stays with review. The floor depends on where the wait
sits, and the script text does not carry that.

Which spellings each matcher takes, which rewrites it refuses instead of
judging, and the blind spots it names are written in the guard's header
next to the code, each with its mechanism and whether it fails loudly or
quietly. Fixtures mark their own lines, so a probe resolves by name
instead of by a line number that drifts.

No runtime code here: the guard, its fixtures, and the conventions that
state the rule.

```release-note
NONE
```
2026-09-22 19:00:52 +04:00
Akanksha Trehun 422b6f65ea test(vpn): add default render helm unit tests
vpn was one of the remaining user-facing apps with no
helm-unittest coverage.

Signed-off-by: Akanksha Trehun <akankshatrehun@gmail.com>
2026-09-22 18:43:00 +04:00
myasnikovdaniil 41ef4a6437 fix(ci): teach the overlay to repair an unstamped images/*.tag ref (#4362)
## What

`hack/overlay-main-images.sh` walks `images/*.tag`, but every branch of
its line classifier needs either a YAML key or an `@sha256:`. A `.tag`
file holds one bare reference and nothing else, so until a build stamps
it the line has neither, the drift guard reads it as a non-ref change,
and the committed reference survives.

For a package onboarded with the `:v0.0.0` placeholder this repo already
uses for a new image, that surviving reference is one no registry
serves. The overlay is the only thing that would replace it for a PR
that does not rebuild the package, so once such a package is on main
every full-suite run that skips its build keeps the placeholder. The
same placeholder inside a `values.yaml` repairs itself, because there it
sits under an `image:` key the classifier recognises. The difference is
the file shape, not the placeholder.

## Reproduction

A fixture holding a committed bare tag against an artifact copy carrying
a stamped one:

```
$ cd fixture/fakerepo && sh ../../hack/overlay-main-images.sh ../mainpkgs '[]' ''
overlay: packages/system/x/values.yaml -> current-main
drift (non-ref change) in packages/apps/x/images/x.tag -> keeping committed ref
```

Both files differ only in their reference. The `values.yaml` one is
repaired; the `.tag` one is not. Adding any digest to the same
placeholder makes it pass, which is what narrows this to the classifier
rather than the file.

## Why it has not bitten yet

17 of the 19 `.tag` files in the tree carry `@sha256:` and pass on that
branch alone. The only other digest-less one is
`packages/extra/seaweedfs/images/seaweedfs-cosi-driver.tag`, and it
survives only because it is third-party: `build-main` never rebuilds it,
the artifact copy is byte-identical, and the classifier is never
reached. The first digest-less first-party `.tag` is what turns the gap
live, and one is coming in #3426.

## The fix

`hack/promote-rewrite-tags.sh` already carries a second match branch for
exactly this shape, with a comment saying why ("the second branch is
what catches a bare `.tag` file, whose content has no YAML key at all").
This gives the overlay the same one, scoped to `*.tag` so an unkeyed
line in a `values.yaml` still counts as drift, because only in a `.tag`
file is a whole line known to be a reference and nothing else.

Two tests in `hack/overlay-main-images_test.bats`: the unstamped
placeholder is overlaid, and a `.tag` whose non-reference line differs
still counts as drift, so the rule cannot quietly widen into "every line
in a `.tag` file is a reference". Removing the fix fails the first one.

```release-note
NONE
```


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

- **Bug Fixes**
- Fixed image overlay handling for `.tag` files containing bare image
references, allowing current references to be applied correctly.
- Preserved drift detection when `.tag` files also contain unrelated,
non-reference changes.

- **Documentation**
- Clarified how image-reference tooling handles bare references and
package-tree processing.

- **Tests**
- Added coverage for bare-reference overlays and mixed reference/comment
changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 18:53:28 +05:00
Yan Bondarenko 78f01d5542 docs(rabbitmq): point the preset section at the shared size matrix
The preset table in this README listed the seven legacy aliases only, and
its CPU column disagreed with cozy-lib on every row, so a reader looking up
what the new s1.nano default buys them found neither the name nor a correct
number. Nine app READMEs already replaced that table with the two paragraphs
that describe the <series>.<size> convention and link the full matrix in
docs/operations/resource-presets.md; this README now carries the same text.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
2026-09-22 17:47:55 +04:00
Yan Bondarenko 6d5666f7e6 fix(rabbitmq): describe the preset as filling what resources leaves unset
cozy-lib merges the preset under resources key by key, so a partially set
resources map still takes the preset for everything it omits: with
resources.cpu=700m alone the broker renders 700m of CPU against the
preset's 512Mi of memory, and this upgrade moves that broker from 128Mi to
512Mi as well. The description said the preset applies when resources is
omitted, and cozyvalues-gen copies it verbatim into values.schema.json, the
README parameter table and the cozyrd openAPISchema the dashboard renders,
so one sentence made the claim on three surfaces.

The suite covered a fully omitted resources map and one that sets both cpu
and memory, which is the one shape that does not exercise the merge. The
new case pins it: reverting defaultingSanitize to replace the preset
instead of merging over it turns that case red and leaves the other three
green.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
2026-09-22 17:47:55 +04:00
Yan Bondarenko 03394bd5fe chore(rabbitmq): drop the test target the rebase duplicated
main already carries a test target at the bottom of the Makefile; the rebase
kept the earlier copy at the top and GNU make warns about the override. The
Makefile matches main again.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
2026-09-22 17:47:55 +04:00
Yan Bondarenko cf749988a4 fix(rabbitmq): right-size the default resources preset to s1.nano
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
Assisted-by: LLM
2026-09-22 17:47:55 +04:00
Aleksei Sviridkin c0ff783026 fix(hack): make bats unit tests pass on a BSD/macOS checkout (#4284)
## What this PR does

Refs #4272 (Defects 1 and 2). On a clean checkout of `main`, `make
unit-tests` is red out of the box on macOS for reasons that have nothing
to do with the change under test: the BSD userland shipped with macOS
differs from the GNU tools on the Linux CI runners, and CI never sees
any of it. This PR closes the two worst of those, both scoped to
`hack/`.

**Defect 1 — the runner cannot parse a non-ASCII `@test` title.**
`hack/cozytest.sh` rebuilds each test's function name by walking its
title one character at a time with awk `substr()`. BSD awk aborts that
walk on the first multibyte byte (`towc: multibyte conversion failure`)
rather than returning it, so a file carrying a non-ASCII `@test` title
never parses and not one of its tests runs — silently, because the suite
only reports that the runner errored. Four `hack/*.bats` files carry
such a title today. The fix pins `LC_ALL=C` on all three awk passes
(title parse, run selection, and the exit-code scan of the captured log)
so each reads the input byte-wise; the parse and selection passes must
share the ctype or they would derive mismatched function names for one
title. A new regression test, `hack/cozytest-nonascii-title.bats`,
builds a multibyte-titled fixture at runtime and asserts the runner
actually runs it; it is red against the pre-fix runner
(mutation-verified) and green after.

**Defect 2 — GNU-only `sed -i`.** `hack/nightly-mirror.sh`,
`hack/promote-rewrite-tags.sh`, and the fixture builder in
`hack/promote-rewrite-tags_test.bats` edit files in place with `sed -i`.
BSD sed reads the argument after `-i` as a mandatory backup suffix, so
`sed -i -e` and `sed -i -E` swallow the following flag as that suffix
and the edit either errors or silently no-ops. Each is rerouted through
a temp file, portable to both seds. The `nightly-mirror.sh` rewrite
additionally relied on the GNU `\?` optional quantifier, which BSD BRE
reads as a literal `?` so a bare host was never matched; it is replaced
with the POSIX `\{0,1\}`, equivalent on both. All three suites are green
on macOS after the change, and behaviour on GNU sed is unchanged.

### Scope

This PR is deliberately scoped to Defects 1 and 2. Defect 3 from the
issue — a GNU-only `date -d` in `hack/seaweedfs-naming-audit.sh` — is
tracked separately as #4273 and is left untouched here.

For transparency: with Defect 1 fixed, several `hack/*.bats` suites that
previously never ran now run, and a handful still fail on macOS because
they depend on GNU coreutils `timeout`, which base macOS lacks
(`capture-dataplane`, `cozytest-capture-gate`,
`run-kubernetes-join-timing_test`, `run-kubernetes-runner-cpu_test`,
`runner-identity`). Those failures are pre-existing — identical before
and after this change when the runner is swapped in place — and are a
distinct defect from the two fixed here, so they are out of this PR's
scope. A fully green `make unit-tests` on macOS additionally depends on
#4273 and on that separate `timeout` gap.

### Verification

All fixes were verified on a genuine macOS host (Darwin, BSD awk
20200816 / BSD sed). `hack/cozytest-nonascii-title.bats`,
`hack/nightly-mirror_test.bats` (5 tests), and
`hack/promote-rewrite-tags_test.bats` (17 tests) are green; the two
files the issue reports as running zero tests
(`overlay-main-images_test.bats`, `promote-gate-contract.bats`) now
yield 13 and 15 `Test OK`, matching the issue's numbers. Each fix is
mutation-proven: reverting it turns the corresponding suite red again.

### Downstream repositories

The diff touches only internal `hack/` tooling — the bats test runner
and two release scripts. No downstream repository vendors or restates
these: `cozystack/ccp` and `cozystack/external-apps-example` gate on
`hack/package.mk`, `hack/common-envs.mk` and `hack/update-crd.sh`, none
of which this PR changes, and nothing renames or moves a file or alters
what a make target does.

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

### Release note

```release-note
fix(hack): make the bats unit-test runner parse non-ASCII `@test` titles under BSD awk, and route the in-place edits in `nightly-mirror.sh` and `promote-rewrite-tags.sh` through a temp file so they work under BSD sed — closing two of the host-tool gaps that make `make unit-tests` red on a clean macOS checkout
```


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

* **Bug Fixes**
* Improved test runner compatibility with non-ASCII test titles and
output.
* Improved text-processing and file-rewrite behavior across BSD and GNU
environments.
  * Preserved file permissions and metadata during automated rewrites.
* Improved handling of host values and release tags across supported
platforms.

* **Tests**
  * Added coverage for non-ASCII test titles.
  * Updated rewrite tests for portable BSD and GNU tooling.

* **Documentation**
  * Clarified supported BSD and GNU command-line tool compatibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 17:37:41 +04:00
IvanHunters 6a34c9f037 fix(opensearch-operator): name the component reconciler a stall sits in
The cluster controller runs nine component reconcilers in sequence and none
of them logs unconditionally when it is entered: the TLS one's only line
above its work sits on the no-security early return, which the chart in this
repository never renders, and the rolling restart one opens at V(1), off at
the verbosity the operator ships with. So a call that stops returning leaves
nothing behind at all. The operator log ends on whatever ran before it, the
object is silently no longer reconciled, and from the outside that is
indistinguishable from an operator with nothing to do. Two e2e runs ended
exactly that way, the operator going quiet at the moment the chart rewrote
the CR and never coming back: https://github.com/cozystack/cozystack/issues/4332.

Each component is now wrapped so a call still in flight after a minute
reports its own name and how long it has been running, and keeps reporting on
that interval while it is stuck. A single line dated at the start of a stall
does not answer the question a reader has five minutes in, which is whether
the call is still there. Nothing is written while a component behaves, so a
healthy cluster costs no extra lines.

The finalizer path is left alone: every DeleteResources at this version
returns immediately, so a watchdog there could only spawn goroutines for
calls that cannot block.

A diagnostic rather than a fix: drop it once the stall itself is understood.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-22 16:07:36 +03:00
Alexey Artamonov bad152443e fix(kafka): correct the stale co-namespaced migration render error
The brokerPoolName render guard still told operators to "migrate one at a time
or use separate namespaces" when a sibling cluster owned the shared "kafka"
pool. That predates automatic name freeing: the migration hook now drains the
first cluster off the shared pool with a Cruise Control rebalance and releases
the name, and the waiting cluster's HelmRelease retries into it — every
co-namespaced Kafka reaches KRaft, no manual action required. Reword the fail
message and the helper comment to describe the transient wait-and-retry, and
update the unit test's error pattern to match.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-22 16:05:44 +03:00
Alexey Artamonov 0b9332877e test(kafka): drop the false-green ZK->KRaft migration chainsaw suite
The kafka-zk-to-kraft-migration suite did not exercise the pre-upgrade hook,
it reimplemented a simplified flow beside it: a controller pool named
"controller" instead of the hashed c-<hash>, no strimzi.io/next-node-ids pin,
and a single broker so the controller-vs-broker node-id collision could never
occur. That is exactly the defect the hook exists to prevent, so the suite
stayed green on a revision whose in-place migration lost every message —
reproduced by hand 2/2 on a live cluster. A suite that cannot fail on this
code is worse than no suite: it reads as migration coverage and invites trust
it has not earned, while costing ~380s on every Kafka PR and asserting only
that a PVC object exists, never reading a record back. The migration hook's
bats suite and the live upgrade runs are the real coverage; the Strimzi bump
would delete this suite regardless.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-22 16:05:44 +03:00
Aleksei Sviridkin 1c88014661 fix(installer): keep node_modules out of the packages artifact (#4290)
## What this PR does

`flux push artifact` walks the whole `packages/` tree and reads neither
`.gitignore` nor `.sourceignore`, so a working copy where somebody ran
`pnpm install` under the vendored console ships the console's dependency
tree inside the bundle. Helm then refuses the dashboard chart on every
cluster that pulls it, because one dependency file sits above the 5MB
per-file limit, and the build itself reports nothing:

```text
Could not load chart: decompressed chart file "dashboard/images/console/node_modules/.pnpm/@rolldown+binding-darwin-arm64@1.0.0-rc.15/node_modules/@rolldown/binding-darwin-arm64/rolldown-binding.darwin-arm64.node" is larger than the maximum file size 5242880
```

Passing `--ignore-paths` replaces flux's built-in list rather than
extending it, so the defaults are restated alongside `node_modules/`.
`hack/check-packages-artifact-ignores.bats` holds both halves: dropping
`node_modules/`, dropping one of the defaults, or declaring the variable
without passing it to the recipe each turn the test red. Its second case
re-reads the defaults out of the installed flux, so an upstream addition
surfaces as a failure rather than as a pattern quietly missing from the
artifact.

Measured on a tree with the console's dependencies installed: the
artifact goes from 80MB carrying 32397 `node_modules` entries to 6.7MB
carrying none, with the dashboard chart still in it.

### Screenshots

Not a UI change.

### Downstream repositories

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

The two workflows that push the same artifact themselves
(`e2e-fork.yaml`, `nightly.yaml`) run from a clean CI checkout, where
the tree has no `node_modules` to pick up.

### Release note

```release-note
fix(installer): the packages OCI artifact no longer picks up node_modules from a developer's working copy, which made the dashboard chart unloadable on every cluster that pulled the bundle.
```


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

* **Bug Fixes**
* Package artifacts now exclude unnecessary files, including
version-control metadata, common media and archive formats, and
dependency directories.
* This helps prevent oversized files from exceeding Helm’s per-file size
limit.

* **Tests**
* Added validation to ensure package artifact ignore patterns remain
complete and aligned with the packaging tool’s defaults.
* Optional checks report when the packaging tool is unavailable without
failing the test suite.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 16:17:52 +04:00
Aleksei Sviridkin b18281ef0c feat(dashboard): serial console for virtual machines, and paste into the VNC console (#4356)
## What this PR does

Adds a serial console tab to the VM detail page, and makes the VNC
console accept a paste.

Moving text into a VM was the starting point, and the two consoles
answer it very differently. The VNC console carries pixels and key
events and nothing else, so a paste there has to be typed, one emulated
key press per character. The serial console carries text, so a paste is
a single write and the guest's output is real text the user can select
and copy.

Measured on a running VM, 1000 characters, checked by piping the pasted
string through `md5sum` in the guest and comparing with the digest
computed locally:

| | time | integrity |
| --- | --- | --- |
| serial console | **0.87 s** | digest matched |
| VNC, 25 ms per key event | 56.6 s | digest matched |
| VNC, no pacing | 10.3 s | **characters dropped and reordered** |

The VNC rows were taken by driving the typing loop directly rather than
through a real keypress, so they measure the typing and not the
shortcut. The pacing is the measured floor either way: with none, the
guest loses and reorders characters.

### Serial console

New tab backed by KubeVirt's `virtualmachineinstances/console`
subresource and xterm.js. A VM's tabs now lead with its two consoles —
serial first, labelled Console, with VNC beside it — because reaching a
console is why people open a VM. VNC keeps its place: the serial port
does not reach a desktop session, a bootloader, or a Windows guest, and
Windows guests are supported here, down to their own scheduling in the
VM charts.

Copy stays on the terminal's own chord, Ctrl+Shift+C or Cmd+C, so a
plain Ctrl+C still interrupts the foreground process. Paste needed one
thing handled, and only off a Mac: xterm turns Ctrl with a letter into a
control byte and cancels the keydown, so Ctrl+V never reached the
browser's paste and the guest received SYN (0x16) instead. Reproduced on
a real terminal, where the shortcut put a single `0x16` on the wire and
nothing else. The key handler now returns that chord to the browser, at
the cost of the terminal's quoted-insert. On a Mac the browser pastes on
Cmd+V, so there the terminal keeps the key.

One detail worth knowing before touching this code: virt-api negotiates
the `plain.kubevirt.io` subprotocol and reads **binary frames only**. A
text frame is accepted by the socket and then silently dropped, which is
indistinguishable from a dead console. That is why the stream is a small
module of its own with its own tests, rather than xterm's stock attach
addon, which sends text.

### Paste in the VNC console

The graphical console still matters where the serial port cannot reach —
a desktop session, a bootloader, Windows — so a paste works there too,
typed key by key.

**The shortcut is the platform's own: Cmd+V on macOS, Ctrl+V everywhere
else.** That means Ctrl+V no longer reaches the guest on Linux and
Windows, where it is visual block in vim or the guest's own clipboard.
Taking both chords bought nothing, because no browser pastes on Ctrl+V
on a Mac, and cost the guest a key it has its own use for.

Nothing is added to the interface: the toolbar's existing status line
reports `Pasting 12/270` while the text goes in, then returns to the
connection state.

Four constraints shaped the rest, each found by running it rather than
by reading:

- `navigator.clipboard.readText()` puts a permission prompt in front of
**every** paste. Instead the shortcut moves the focus to a hidden field
and lets the browser paste into it, which costs nothing and asks
nothing.
- noVNC calls `preventDefault()` on keydown. Letting the shortcut reach
it would cancel the browser's paste outright, so the shortcut is taken
on the capture phase and only its propagation is stopped. If no paste
follows, the focus returns to the console instead of being parked in an
invisible box.
- The paste starts while the user is still holding the chord, and noVNC
told the guest that modifier is down the moment it was pressed. Typing
into that state sends chords rather than characters — under Ctrl an `m`
is Return — so the host's modifiers are released before the first
character. On Windows noVNC sits on the first Ctrl for 100 ms and
delivers it on a timer, after that release has already happened, so the
release is sent once more when the pacing has covered that window.
- A character the keymap cannot produce changes what the rest means, and
the newline after it still submits: `rm -rf /srv/данные` would become
`rm -rf /srv`. Nothing is sent unless the whole paste can be typed, and
the toolbar says how many characters were in the way.

The keymap covers US only. A guest in another layout is the case the
serial console answers, where text travels as bytes and no layout is
involved.

### Screenshots

The serial console on a running VM: the tabs lead with it, the terminal
is live, and the middle line was pasted from the browser clipboard.

<img width="2252" height="1606" alt="cozystack-serial-console"
src="https://github.com/user-attachments/assets/6fac5f95-c142-405b-9b54-8ed040d65167"
/>

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

<!-- The diff touches only packages/system/dashboard/images/console.
Walked the trigger map in
docs/agents/contributing.md file by file: no entry matches a
console-only change, and the map
states outright that UI changes belong in this repository, in the same
PR. -->

### Notes for the reviewer

- New dependencies: `@xterm/xterm` and `@xterm/addon-fit`. The tab is
split at the route with `React.lazy`, the way the YAML editor already
is, so both land in a 347 KB chunk fetched when a console is opened.
Built both revisions with the same `node_modules`: the entry chunk every
dashboard page loads goes 935.91 KB → 896.39 KB, so it ends up smaller
than before this branch.
- The serial console needs a getty on `ttyS0` in the guest. Cloud images
enable it by default; a Windows guest or a graphical desktop is the VNC
console's job.
- The serial console opens with the user's own identity — nginx forwards
the session's `Authorization` untouched — and
`virtualmachineinstances/console` is already granted next to `/vnc` in
`cozy:tenant:use:base`, so no RBAC change is owed.
- Verified against a live VM on a dev cluster: connection, typing, a
1000-character paste round-tripped through `md5sum` on the serial
console, text typed before the serial socket opens (held and flushed), a
real Ctrl+V on the VNC console arriving intact, and two pastes in quick
succession (the second is refused rather than interleaved).
- The Windows modifier path is read from the pinned noVNC in
`node_modules`, not run: there is no Windows host here. The test fake
models that delivery, so the behaviour is at least held by a test.
- There is no cancel affordance for a paste in progress. It stops on its
own when the session drops or the tab is left, and the toolbar then says
how far it got; Reconnect is the manual way out. A length cap is worth
adding separately — at 25 ms per character a large clipboard types for a
long time.
- 418 tests pass; build and typecheck are clean. `eslint .` reports 54
pre-existing problems across twenty files, none of them in the files
this PR touches.

### Release note

```release-note
feat(dashboard): virtual machines gain a serial console tab, where pasting is instant and the output can be selected and copied. The VNC console now pastes the host clipboard on the platform's own shortcut — Cmd+V on macOS, Ctrl+V elsewhere — typing it into the guest on a US keyboard layout; Ctrl+V no longer reaches the guest on Linux and Windows.
```


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

- **New Features**
- Added a serial console for running virtual machines, including
terminal input, output, resizing, reconnection, and connection status.
- Added clipboard paste support to the VNC console with keyboard-layout
handling, progress feedback, and unsupported-character reporting.
  - Added platform-aware paste shortcuts for macOS and Windows/Linux.

- **Improvements**
  - Console content now loads on demand with a loading indicator.
  - Added handling for stopped and unsupported virtual machine states.

- **Documentation**
  - Documented keyboard, clipboard, and console testing behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 12:39:03 +04:00
Kirill Ilin 938595afe3 fix(keycloak): validate the realm list wherever it is set
The list was read only inside the adminHost branch, so a value the chart
would refuse — master, a scalar, a name that splits the path — sat in the
values unnoticed until someone turned the admin hostname on, and the refusal
then landed on the change of authentication topology rather than on the edit
that caused it. Build the list on every render and let the branches use the
result.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:54:28 +05:00
Kirill Ilin 13a4029b8b fix(keycloak): count the realms the route budget rejects
The over-budget message derived the realm count from the path list, which
also carries the two static paths, so it named one realm more than the
deployment has — the one number an operator acts on. Keep the realm list in
its own template and count it there, and derive the budget from the static
paths rather than restating 31.

The shape guard also let a falsy scalar through, so `exposedRealms: false`
reached range and failed with a Go template error instead of the message that
says what to write.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:44:08 +05:00
Kirill Ilin 714ced9bab fix(keycloak): refuse more realms than a route rule can match
A Gateway API route rule takes 64 path matches and each published realm takes
two of them, so past 31 realms the apiserver rejects the whole HTTPRoute and
the login hostname stops answering. Fail while rendering, naming the
parameter to shorten, instead of leaving the limit in a comment. The Ingress
path has no such cap, so the check lives with the route.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:35:37 +05:00
Kirill Ilin c96fe3d3ef fix(platform): refuse master as the OIDC realm name
The realm named here is where keycloak-configure creates every platform
client and the cluster-admin role, and where dashboard users sign in. Naming
master puts all of that inside the realm that holds the Keycloak super-admin,
and publishes its login page wherever the platform realm is published — and
the guard belongs where the value is set, since the Keycloak chart only reads
it once a separate admin hostname is configured.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:35:31 +05:00
Kirill Ilin e87232b822 fix(keycloak): always publish the platform realm and never master
exposedRealms replaced the published realm instead of adding to it, so a
deployment naming only its back-office realm took its own login off the
public route: every component's issuer URL points at the platform realm, and
they would all have met a 404. Publish the platform realm unconditionally and
deduplicate, which is also what the parameter's description promised.

Refuse master in either position rather than warning about it in a comment —
publishing it is exactly what the realm enumeration exists to prevent.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:24:58 +05:00
IvanHunters 0b8296d689 fix(dashboard): release the held modifier before the first character, not after some
The window this had been measuring started before the paste did. noVNC
arms its timer on the keydown, and the modifier is delivered once — by
that timer or by the next keyup, whichever comes first, and the V keyup
is not intercepted. Both land within 100 ms of the keydown, which at the
default pace is somewhere inside the opening releases.

Counting from the end of those releases therefore started the clock
again and let three characters through while the guest still held Ctrl,
where an m is Return. The second release now waits out whatever is left
of the window, measured from the keydown itself, and happens before any
character goes in.

The test that was written for this pinned the wrong placement: it handed
the modifier over after the first character, later than noVNC can, and
the fake's method was the immediate one under a second name. The fake
now delivers on its own schedule, partway through the opening releases,
so a test cannot choose the moment.

The component's own typing pace had no test at all once every existing
one passed its own value, so setting the default to zero left the suite
green. One test renders without the prop and holds it.

The lifecycle tests key on characters typed rather than on a count of
sleeps, which is what let them drift past their code when the opening
releases changed shape.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-22 08:21:56 +03:00
Kirill Ilin 64229b154e fix(keycloak): bound each published realm path to a path segment
ingress-nginx renders `pathType: Prefix` as a plain nginx prefix location
instead of the element-wise match the Ingress API specifies, so a lone
/realms/cozy also served /realms/cozy-admin — a realm the chart claims is
unpublished. Publish each realm as an exact path plus a trailing-slash
prefix, which bounds the match on every controller and on Gateway API.

While here, reject a scalar in place of the realm list instead of failing
inside range, and point a malformed platform realm name at the parameter
that carries it rather than at exposedRealms.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:17:00 +05:00
Kirill Ilin 5149e67dd7 feat(keycloak): publish only the named realms on the login host
The narrowed login route carried a bare /realms prefix, so it also published
realm master: its token endpoint accepted super-admin credentials from the
internet. Enumerate the published realms instead — the platform realm by
default, ingress.exposedRealms when a deployment serves more than one.

Behavior changes only where ingress.adminHost is already set.

Assisted-By: LLM
Signed-off-by: Kirill Ilin <stitch14@yandex.ru>
2026-09-22 10:08:07 +05:00
Aleksei Sviridkin cd91627c78 feat(marketplace): register a tapped repository's apps on connect (#4308)
## Problem

Connecting a marketplace repository materialized its `PackageSource` but
created no `Package`, so its `ApplicationDefinition`s never rendered and
its apps never appeared in the dashboard catalog until a manual `cozypkg
add`. The dashboard cannot drive that step: the package list it shows is
computed from the `ApplicationDefinition`s, which do not exist yet, a
chicken-and-egg that leaves a connected repository unbrowsable.

## Fix

The tap materializer creates a tap-managed registration `Package` for
each materialized `PackageSource` that has a `default` variant, the same
way the platform ships a `Package` per built-in app. Its install-marked
components (the `ApplicationDefinition` registrations) deploy on
connect, so the apps become browsable; the app components carry no
`install` block and are still instantiated per user resource, so
registration deploys no user workload.

Privileged install components are not installed without confirmation.
The check is at the install site: the Package reconciler skips a
privileged install component of a Package that still carries the
marketplace-tap label (a tap auto-registration the operator has not
confirmed) and reports `Ready=False PrivilegedNotConfirmed`, and
`resolvePrivilegedNamespaces` gates the PodSecurity label on the same
predicate. `cozypkg add --allow-privileged` sheds the label (an explicit
ownership handover), which is the confirmation; a platform Package never
carries the label. Putting the check where the install happens closes
the window a materializer-side guard would race, so the earlier
install-safety de-register machinery is removed.

Two further best-effort layers close the paths a lingering artifact or
release could take when a source revision flips a previously benign
component to privileged: `cleanupOrphanedHelmReleases` treats an
unconfirmed privileged component as not-desired and removes a
HelmRelease left from an earlier benign revision, and the PackageSource
reconciler withholds a privileged component's `ExternalArtifact` until
confirmed (re-running on confirmation through a Package watch), so the
helm-controller has no privileged content to upgrade to. A component
flipped to privileged in a new revision can still be materialized by
source-watcher before any reconciler observes the flip; closing that
race fully requires decoupling the catalog registration from install,
tracked in #4359.

`cozypkg untap` and the dashboard disconnect remove the registration
`Package` too, so the apps de-register (its registration HelmReleases
and their `ApplicationDefinition`s are garbage-collected); running
instances are left in place but unmanaged until re-tapped, the same
outcome as `cozypkg del`. Teardown deletes the Package under a UID +
ResourceVersion precondition with a re-classify-on-conflict retry, so it
never clobbers a Package a concurrent `cozypkg add` handover has just
confirmed. `cozypkg add` re-opens a tap auto-registration for variant
selection and hands it over to the user.

## Verification

- The install-site privileged gate is covered by a Package-reconciler
test (a managed registration with a privileged default variant creates
no privileged HelmRelease and does not raise the namespace; a confirmed
Package installs) and is mutation-verified.
- The two best-effort layers each have a mutation-verified test: cleanup
of a HelmRelease left when a benign component flips to privileged, and
withholding of a privileged component's `ExternalArtifact` while the
registration is unconfirmed (a non-tap platform source is never gated).
- The `cozypkg add` handover is covered by a test asserting the tap
label, the source annotation and the PackageSource ownerRef are shed and
the variant pinned.
- The teardown delete precondition is covered by a test in which a
handover lands between the read and the delete; the precondition makes
the delete conflict and the retry re-classifies and spares the user's
Package.

Earlier end-to-end on a dev cluster (predates the install-site refactor
and the layers above) with a real public demo repository
`oci://docker.io/999669/cozy-gitea:v1.0.0` confirmed the
connect→auto-register→instance→disconnect flow: `cozypkg tap` alone
registered the app within ~10s; a `Gitea` instance reached Ready and
served `/api/healthz`; `cozypkg untap` de-registered it and left the
running instance. etcd healthy, `cozystack-platform` untouched.

```release-note
Tapping a marketplace repository now registers its applications in the catalog on connect, without a manual `cozypkg add`.
```
2026-09-22 01:41:34 +04:00
IvanHunters 92b8df076c fix(dashboard): a paste could still type chords on a Windows host
Windows is the platform where noVNC holds the modifier back: the first
Ctrl keydown tells the guest nothing, arms AltGr detection and starts a
100 ms timer, and Ctrl reaches the guest when that timer fires. Taking
the shortcut on the capture phase hides the V from noVNC, so the timer
is what delivers — after the paste released a modifier the guest had not
yet been told about. Everything typed between the two goes in as chords.

The release is now sent a second time, once the pacing has covered that
window. It happens once rather than per character, and it leaves Shift
alone, because that one is held deliberately for capitals.

Two tests had drifted past the code they are named for. Both dropped the
session on the first sleep, which since the prelude lands before
anything is pressed, so they held whatever the typing loop did: removing
the session check from the loop left them green. They drop it after a
character now, and a third covers the closing release, which no test
reached at all.

The fake learns the Windows path it could not express, and decodes
function keysyms: Enter is 0xff0d, and treating it as a Unicode offset
produced a negative code point, so the first test to paste a newline
would have thrown instead of failing an assertion.

The typing pace is injectable, so tests no longer run at the speed of a
real keyboard and stop crowding their neighbours out on timeouts.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 23:48:25 +03:00
IvanHunters 6209a0b8d3 refactor(dashboard): code-split the serial console at the route
The tab pulled xterm through a dynamic import inside its own effect,
which split the chunk but left the stylesheet on a fragile path: the dev
server failed to fetch it once after a dependency rebuild, and the tab
came up with the terminal missing.

Splitting at the route instead is what the YAML editor next door already
does. The imports go back to plain ones, React.lazy gives the tab its
own chunk, and the stylesheet travels with it rather than through an
import() of a CSS file.

The entry chunk every dashboard page loads ends up smaller than before
this branch: 935.91 KB at the merge base, 895.99 KB here, with the tab
and xterm in a 347 KB chunk fetched when a console is opened.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 23:09:44 +03:00
IvanHunters 4a10497d3d fix(dashboard): a VNC paste typed chords, and could run a command nobody pasted
Three defects on the same surface, all of them where this code meets
noVNC, the platform and the guest — and all of them invisible to every
test here, because the fake had no keyboard.

A paste began while the user was still holding the chord. noVNC tells
the guest a modifier is down the moment it is pressed and releases it
only on keyup, so the pasted text arrived as chords: under Ctrl an m is
Return, and on macOS, where noVNC maps Cmd to Alt, the same under Alt.
The host's modifiers are now released before the first character.

A character the keymap cannot produce was dropped and the rest was typed
anyway, newline included, with the notice arriving after the line had
run: rm -rf /srv/данные became rm -rf /srv. Nothing is sent now unless
the whole paste can be typed.

The shortcut was taken on every platform, so the guest lost a key it has
its own use for — visual block in vim, its own clipboard on Windows —
and on macOS gained nothing, because no browser pastes on Ctrl+V there.
Each platform's own chord only.

The fake now models the guest's keyboard: which modifiers it believes
are down, and what it received with chords marked. That is what makes
the first defect expressible as a test rather than something only a
person at a keyboard could notice.

Also pins three behaviours the suite was not holding: a frame arriving
after cleanup, a real close reason behind a synthetic 1006, and the
25 ms pacing the description calls measured.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters d113d02e03 feat(dashboard): lead a VM's tabs with its consoles, serial first
Reaching a console is why people open a virtual machine, so the two
consoles now sit directly after Overview instead of below the resource
tabs. Serial leads and is labelled Console: text goes in and out of it
directly, at a speed the emulated-keyboard path cannot reach.

VNC keeps its place beside it, because the serial port does not reach a
desktop session, a bootloader, or a Windows guest — and Windows guests
are a supported case here, with their own scheduling in the VM charts.

The order is pinned by a test: it was ordinary prose in a push() call
before, so the next edit to this list could reorder it in silence.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 25cd86c560 fix(dashboard): Ctrl+V pasted nothing in the serial console
xterm turns Ctrl with a letter into a control byte and cancels the
keydown, so on Windows and Linux Ctrl+V never reached the browser's
paste and the guest received SYN (0x16) instead. Cmd+V and Shift+Insert
were unaffected, which is why testing on a Mac missed it: reproduced on
a real terminal, where the shortcut put a single 0x16 byte on the wire
and nothing else.

The key handler now returns the event to the browser, at the cost of the
terminal's quoted-insert on that chord — the same trade the VNC console
already makes for the same shortcut. The test stub keeps the handler now
instead of dropping it, so the chord is reachable from a test at all.

Three smaller repairs in the same paths:

A frame arriving after cleanup was written into a disposed terminal,
because the message handler never consulted the stream's own state.

onerror synthesised a 1006 and the real onclose behind it was swallowed
by the already-closed latch, so a close carrying 1008 Forbidden reached
the user as connection error. The error is recorded and the close
reports what the server actually said.

navigator.clipboard is SecureContext-only and absent over plain http,
which the console's own URL builder supports; copying threw there
instead of falling back to the selection the user can copy by hand.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 58d0c90e0b test(dashboard): pin the console behaviours nothing was holding
Three behaviours the code promises had no test: the refusal of a second
paste while one is still typing, the focus-restore timer, and the serial
console's copy chord. The chord was untestable by construction — the
Terminal stub accepted the handler and dropped it, so nothing could ever
call it. The stub keeps it now.

Each test was checked by reverting the behaviour it covers and watching
that one test, and only that one, go red.

The VNC paste also acts on how typeKeystrokes ended instead of
discarding it. A paste cut short by a dropped session used to leave the
toolbar reading as though the text had all arrived.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 71bdad69ae fix(dashboard): console input state across a session's lifetime
Five repairs, all on state that outlives a single interaction.

The serial stream marked itself writable before the socket opened, so a
keystroke typed while the pill still said Connecting reached
WebSocket.send() during CONNECTING, which throws. Input is now held and
flushed on open, and discarded if the socket closes first.

A paste in the VNC console outlived the tab. The effect cleanup dropped
the RFB reference but not the sender, and noVNC's disconnect event
arrives too late to clear it, so the typing loop kept running against a
dead session. Cleanup now aborts the paste and clears the sender.

Clipboard blocked had a renderer and no producer: the state survived the
move from the panel to the shortcut, while the only thing that used to
set it did not. The sink timeout sets it now, which is the case the
message was written for — an empty or refused clipboard raises no paste
event at all.

The shortcut matched the physical KeyV position only, so on Dvorak
Ctrl+V reached noVNC instead and was cancelled there.

A clean server-side close left the status pill on amber Connecting,
because the tab tracked a boolean where it needed three states.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 3d026ff611 refactor(dashboard): keep only the US keymap for VNC paste
The layout was never selectable — the tab picks one and nothing offers
the others — so the German, French and Russian tables, and the AltGr
handling that only they needed, were data and code with no reader.

What remains states its assumption plainly: US, the layout a default
cloud image boots with. A guest in another layout is the case the serial
console answers, where text travels as bytes and no layout is involved.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 2f6ec92a20 feat(dashboard): paste into the VNC console with Ctrl+V
The graphical console still matters for guests the serial port cannot
reach — a desktop session, a bootloader, Windows. There a paste has to
be typed, so this maps the clipboard text to key presses for the layout
the guest has active and paces them: without the pacing the guest drops
and reorders characters, which was measured rather than assumed.

The shortcut is taken before noVNC sees it. noVNC calls preventDefault()
on keydown, which would cancel the browser's own paste and hand the
guest a Ctrl+V it cannot use; instead the focus moves to a hidden field
and the browser pastes into it. Reading the clipboard through
navigator.clipboard would have put a permission prompt in front of every
paste. The focus returns to the console even when no paste follows.

Nothing is added to the interface: the toolbar's existing status line
reports progress while the text is typed.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters 27d7b0ee03 feat(dashboard): serial console tab for virtual machines
The VNC console carries pixels and key events, so moving text through it
means emulating a keypress per character: measured at 57 seconds for a
1000-character paste, and the text corrupts if the pacing is dropped.

KubeVirt also exposes the guest's serial port as a websocket, and that
one carries text. The same 1000 characters land in under a second, the
guest's output is real text the user can select and copy, and tabs,
newlines and non-Latin characters travel as bytes instead of depending
on the keyboard layout the guest happens to have active.

virt-api negotiates the plain.kubevirt.io subprotocol and reads binary
frames only — a text frame is accepted by the socket and then dropped,
which is indistinguishable from a dead console, so the stream is kept
deliberately narrow and tested on its own.

Copy stays on the terminal's own chord (Ctrl+Shift+C, or Cmd+C) so a
plain Ctrl+C still interrupts the foreground process.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 22:45:15 +03:00
IvanHunters aa20495df3 feat(marketplace): register a tapped repository's apps on connect
Connecting a marketplace repository materialized its PackageSource but created no Package, so its ApplicationDefinitions never rendered and its apps never appeared in the dashboard catalog until a manual `cozypkg add` the dashboard cannot drive (the package list is computed from the ApplicationDefinitions, which do not exist yet).

The tap materializer now creates a tap-managed registration Package for each materialized PackageSource, the way the platform ships a Package per built-in app, so the ApplicationDefinition registrations deploy on connect and the apps become browsable; the app components carry no install block and are still instantiated per user resource.

Privileged install components of an unconfirmed tap auto-registration (a Package that still carries the marketplace-tap label) are refused on several best-effort layers: the Package reconciler skips their HelmRelease and prunes a lingering one at the install site, and the PackageSource reconciler withholds their ExternalArtifact so the helm-controller has no privileged content to deploy, re-running on confirmation via a Package watch. `cozypkg add --allow-privileged` sheds the label as an explicit ownership handover, which is the confirmation; a platform Package never carries the label. A benign component flipped to privileged in a new revision can still be materialized by source-watcher before these layers observe the flip; closing that race fully requires decoupling the catalog registration from install and is tracked in issue #4359.

`cozypkg untap` and the dashboard disconnect remove the registration Package (de-registering the apps) under a UID + ResourceVersion precondition, so a concurrent `cozypkg add` handover is never clobbered; running instances are left but unmanaged. `cozypkg add` re-opens a tap auto-registration for variant selection and hands it over. The cozystack-api ClusterRole gains delete on packages for the disconnect path.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-21 21:57:04 +03:00