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

## What this PR does

`hack/e2e-capture-dataplane.sh` runs after an e2e failure to snapshot
the host-to-pod data plane. Every `kubectl` read in it was unbounded, so
a read against a wedged apiserver hung until the caller's own backstop
killed the script, and then no capture file was written at all. The run
that most needs the diagnostic is the run least likely to produce one.
Each read now carries its own bound, 20s for a single read and 28s for a
list, well inside the 300s the callers allow.

Bounding the reads creates the second problem, which is most of this
diff. A bounded read that gives up returns empty, and empty was already
how the script recognised "there is nothing here". Five places turned a
read that never answered into a fact about the cluster: no affected
pods, no ovn-central, no LoadBalancer Service, no cilium-agent on a
node, and a LoadBalancer recorded as reachable. Two of the five only
became reachable because of the bounds. Before them the script died
instead of writing the file, so a lookup that never answered could not
be recorded as a pod that is not there.

Each of those claims now has a counterpart chosen by the status of the
read that produced it, and the counterpart says what is left unknown
rather than what the read did. The last one is the subtlest, because
nothing about it looks like a claim of absence: the probe resolves a
cni-server pod before probing, and a lookup that never answered produced
no probe outcome at all, which the decision helper read as "nothing
failed" and the artifact stamped as reachable. That is a verdict about
an address the script never touched. The probe now emits an explicit
unknown, and a set of outcomes that is entirely unknown stays unknown.
Only a wholly unknown set: one unrun probe beside real failures leaves
the failures standing and still captures, because they are the evidence
the capture exists to characterise.

Every note ships beside the capture it explains, not only in the job
log, because the reader who has the uploaded report and not the run is
exactly the reader who cannot otherwise tell a capture that found
nothing from one that never ran. Both sibling collectors already hold
that contract.

The middle commit is a separate and measurable thing. Each per-node pod
lookup was issued up to three times per node; it is asked once now and
memoised, with only answers and cutoffs cached, since an instant failure
costs nothing to re-ask and caching it would make one transient
permanent for the whole run. On an 8-pod, 8-node stub that is 264
kubectl calls down to 229 and 59 per-node lookups down to 24, with
byte-identical output.

Deliberately left, and worth naming so nobody reads this as the whole
class. The two EndpointSlice reads still discard stderr and report
neither outcome. The "reachable, skipped" wording still covers every
path that reaches it having run zero probes, among them an unresolved
probe node, a `_lbport` of 0, a cni-server image carrying none of `nc`,
`curl` or `wget`, and a failure of the probe `exec` itself rather than
of the lookup before it; that leftover is tracked in #3658. And the
three call sites still disagree about what a partial answer means:
`host_http_probe` discards a name that arrived with a non-zero status,
while `capture_node` and `capture_lb_node` use a non-empty name
regardless of it. All of these are verbatim at the merge base and this
diff does not change their behaviour.

Covered by 39 cases in `hack/capture-dataplane.bats`, each branch pinned
by a stub that answers one read and fails another, and each with a
positive anchor so a stub that stops short of the branch fails rather
than passing quietly.

relates to #3642


### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### 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:

### 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(tests): bound every kubectl read in the e2e data-plane capture so a hung read no longer costs the whole capture, and stop the collector reporting an unanswered read as an absent pod, service or reachable address
```
2026-08-08 10:55:00 +03:00
Aleksei Sviridkin c1071dab9b feat(kubernetes): let a node group log the guest serial console (#3637)
## What this PR does

Both tenant Kubernetes e2e suites keep missing the node-Ready budget
with zero Nodes registered, and the time goes inside the guest, between
the worker VMI reaching Running and Talos asking for its apid
certificate. Nothing collected today reaches that window. The in-guest
capture the harness already has drives `talosctl`, so it needs the apid
the worker never got to, and the management side has neither a Node nor
a certificate request to read. Four attempts at this failure have been
designed from outside the guest; the most recent raised the budget from
12m to 18m and the same fingerprint came back at the new deadline.

KubeVirt can stream a VM's guest serial console into a
`guest-console-log` container beside virt-launcher, readable with
`kubectl logs` and needing nothing from inside the guest — no talosctl,
no client certificate, no helper Pod, no reachable apid. That is the one
property this failure class demands, so this turns it on for the suite's
worker node group and collects it into the diagnostic bundle when
node-join fails, ahead of the `talosctl` capture that cannot describe
the same runs.

Reads start at the beginning of the stream rather than tailing it: a
guest that repaints its console would push the boot output out of any
`--tail` window, while `--limit-bytes` truncates from the far end
instead. Every read is bounded, and the walk stops at a cap that records
both counts, so the collector cannot become an unbounded term in a
failure path that already runs several others under one deadline.

A read that returns no console is not filed as a quiet guest. Three
outcomes get three labels, because only one of them says anything about
the guest: the read failed and returned nothing, the read succeeded and
returned nothing, or it broke off part way. Each read's stderr is kept
beside the capture rather than inside it — kubectl's own `container
guest-console-log is not valid for pod` would otherwise fill the file,
and a console that never started would read as one that had spoken. That
case is the headline one here, so the Pod state and events that explain
it are collected alongside, once for the whole selector.

The switch is a new opt-in `logSerialConsole` on a node group, off by
default. It renders nothing unless set to true, including when set to
false, because the `KubevirtMachineTemplate` is content-hash named and a
byte emitted for a group that did not ask for it renames the template
and rolls every live worker VM on upgrade. I verified that by rendering
the base tree and this branch: absent and explicit-false produce a
byte-identical template name on both charts. Worker VMs come from two
charts, `kubernetes` and `kubernetes-nodes`; both carry the field, and
the golden-parity check between them gains a case that keeps their
output identical.

The passing path checks separately that the container actually attached,
and runs that check last, after everything the suite exists to prove — a
debugging aid should not preempt the assertions that cover the cluster.
Without it the suite would first learn the setting was inert in the one
run that needed the console, when it can no longer be recovered.

Relates to #3513.

### The risk this carries, stated up front

The platform turns serial console logging off cluster-wide in the
KubeVirt CR. That setting is a mitigation added in February 2026 for
kubevirt/kubevirt#15989, where the `guest-console-log` container kept
virt-launcher from starting and left VMs in `PodInitializing`. A per-VM
`logSerialConsole` takes precedence over it —
`isSerialConsoleLogEnabled` reads the VMI field first and only then
falls back to the cluster setting — which is what makes this work, and
also makes this the first thing on the platform to go through that
mitigation. The mitigation was written against KubeVirt v1.6.x; the
platform now ships v1.8.4, and the upstream issue is still open with no
reproduction reported either way on the newer version.

So the first run of these suites is also the experiment, and it doubles
as a revalidation of that February mitigation on the version actually
shipping, which nobody has done. The two outcomes are distinguishable
rather than ambiguous: kubevirt/kubevirt#15989 shows up as virt-launcher
wedged in `PodInitializing` with the VMI never reaching Running, while
the failure this instruments shows the VMI Running and zero certificate
requests. A red run with the first signature is an answer, not noise —
and the collector is built so that it says which one happened instead of
reporting silence. The blast radius is small: the product field is
absent by default and only the e2e node group sets it, so backing this
out is one line.

The revert criterion, stated before the run rather than after it. If
worker virt-launcher Pods sit in `Init` on `guest-console-log` and no
Node registers, kubevirt/kubevirt#15989 still reproduces on v1.8.4: drop
the `logSerialConsole: true` line from the e2e node group, keep the
chart field, and the upstream issue gains the reproduction on a current
version that its thread does not have. If the Pods reach Running and the
console logs carry boot output, the mitigation is a candidate for
removal platform-wide, which is its own change. Either way the run
answers something that was open before it.

Dropping `virtualMachineOptions.disableSerialConsoleLog` outright was
the alternative. It is a smaller diff and needs no values key, but it
exposes every VM on the platform to a race nobody has re-tested, instead
of one node group in one test. That is its own revalidation exercise and
belongs in its own change, on evidence this one produces.

### Verification

`make unit-tests` passes. The new bats suite runs green under both
`bash` and a `dash` `/bin/sh`, which matters because `hack/cozytest.sh`
is `#!/bin/sh` and an earlier revision of this branch carried a
bash-only construct that failed silently there. Every test in it is
killed by a specific mutation of the code it covers. Both charts'
helm-unittest suites pass, the golden-parity script reports
byte-identical output across all four cases including the new one, and
`make generate` is idempotent at the repo root.

Reviewed by Claude across several rounds and by an independent second
reader; the review loop found and fixed real defects in this branch,
including a green-path check that had become unable to report the one
thing it existed for.

### Screenshots

Not a UI change.

### Downstream repositories

- [ ] 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/issues/26
- [ ]
[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
feat(kubernetes): worker node groups accept `logSerialConsole`, which streams a worker VM's guest serial console into a `guest-console-log` container readable with `kubectl logs`. Off by default. It is the only way to observe a worker that stalls during boot before Talos apid answers, when no Node and no certificate request exist to diagnose from. Enabling it rolls the node group and overrides the platform's cluster-wide `disableSerialConsoleLog`.
```


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

* **New Features**
* Added an optional `logSerialConsole` setting for Kubernetes worker
node groups.
* Streams worker VM guest serial-console output to a dedicated log
container when enabled.
* Configuration is disabled by default and available through Kubernetes
application settings.
* **Bug Fixes**
* Improved worker failure diagnostics with bounded serial-console output
and pod-state information.
* Validates that enabled worker pools attach the expected console-log
container.
* **Documentation**
* Documented configuration behavior, rollout effects, defaults, and
timeout considerations.
* **Tests**
* Added coverage for rendering, attachment validation, capture limits,
and failure scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 10:47:00 +03:00
Aleksei Sviridkin 93ffb38a9b fix(cozy-lib): make the cozyconfig helpers survive uninjected values (#3647)
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

`_cluster` and `_namespace` reach a chart through valuesFrom on the
cozystack-values Secret, so they are absent whenever a chart is rendered
on its own: `helm lint`, a plain `helm template`, and any helm-unittest
suite that does not set them. Twelve of the helpers in `_cozyconfig.tpl`
read straight through the missing map and abort the render instead of
returning the empty default they document.

Two spellings, one cause. `index .Values._cluster "root-host"` raises
`index of untyped nil`; `.Values._namespace.host` raises `nil pointer
evaluating interface {}.host`. In both, the trailing `| default ""`
never runs, because it applies to the result and there is no result,
only an error. That default covered a key missing from a map that
exists, never a missing map.

The guard goes on the map, which is what `cozy-lib.images-registry` in
this same file already did:

```
(index (.Values._cluster | default dict) "root-host") | default ""
```

`index` on an empty map returns nil harmlessly, so the trailing default
applies as it always read, and with the map present `| default dict`
returns it untouched.

**This fixes no chart that is failing today.** The charts that cannot
render standalone read the injected values directly in their own
templates rather than through these helpers, so a change to the library
does not reach them; they are handled where they live. Worth being blunt
about the same point from the other side: twelve of the thirteen helpers
here currently have no callers anywhere in `packages/`. Only
`cozy-lib.images-registry` is reached, from `cozy-lib.image`, and it was
already guarded. The guard is still worth having on the rest, because
they are the library's public surface, they are what a chart author
copies when reaching for cluster configuration, and a broken spelling
propagates by being read. `cozy-lib.image` is called from four
templates, so these helpers do get adopted once they exist.

**Rendered output does not change, and that is measured rather than
argued.** All 34 charts that symlink cozy-lib were rendered before and
after, both with the injected values populated and with them set to
empty maps, and every result is byte-identical. That check needs a
control: the first comparison showed eight charts differing, which looks
like a regression in a library change. Rendering the *unchanged* tree
twice reproduces exactly those eight, because they generate passwords.
Subtracting the control leaves zero real differences.

The suite lives in `cozy-lib-tests`, which is where library behaviour is
pinned, since a library chart renders nothing of its own. One case
covers the uninjected state, which needs no mocking because neither key
appears in any values file, and one covers the injected state so the
guard cannot quietly change what a populated map returns. The injected
case pins `scheduling` and `branding` as whole YAML documents rather
than by substring: those two had their bodies restructured rather than
wrapped, and a substring assertion survives the loss of the `toYaml`
that produces the document, because the key name also appears in Go's
default map formatting. Against the unguarded helpers the first case
fails with `index of untyped nil`.

Three things left deliberately undone, so they are not mistaken for
oversights. The header comments in `_cozyconfig.tpl` say where these
values come from but not what the helpers now do when they are absent,
which is the contract this change creates and would be better stated
next to the helpers than only in the suite. `Chart.yaml` in
cozy-lib-tests still carries `name: quota` from when that chart covered
one helper family, so the runner prints `### Chart [ quota ]` for a
suite that now covers three. And the mixed state, one map injected and
the other not, has no case of its own; the two guards are independent so
composition covers it, and a third case would pin nothing new.

Relates to #3646.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### 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:

### 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(cozy-lib): the cozyconfig helpers now return their documented empty defaults when the platform has not injected `_cluster` / `_namespace`, instead of aborting the render, so a chart using them can be rendered and unit-tested on its own
```


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

## Summary by CodeRabbit

- **Bug Fixes**
- Improved configuration handling when cluster or namespace settings are
missing, preventing template rendering errors.
- Preserved existing configuration values for scheduling, branding,
networking, registries, and component namespaces.

- **Tests**
  - Added coverage for configurations with missing platform values.
- Added validation that injected cluster and namespace settings render
correctly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 10:46:56 +03:00
Aleksei Sviridkin db590b493c fix(hack): arm cozytest cluster captures for e2e suites only (#3645)
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

`hack/cozytest.sh` arms three cluster captures from its on-failure EXIT
trap, and gates each on a binary being installed rather than on there
being a cluster to read. A unit runner has kubectl and no cluster, so a
red unit test walked into those legs on its way out and paid for
snapshots of nothing.

What that costs turns on how kubectl fails, which is why the defect
survived: against an endpoint that refuses it is a second, and locally
that is what happens. Against one that hangs, which is what a runner
with a context pointing at a torn-down cluster has, I measured ~28s for
the previous-logs leg and the full 600s for the data-plane one. The 28s
is the leg self-bounding its own pod list, so it never reaches the 300s
ceiling above it; the 600s is real, because that leg's first pod list
carries no bound of its own. Ten minutes of dead waiting for a failure
that should have cost seconds.

Where crust-gather is installed too, the bill stops being empty and
starts being wrong. That leg needs no reachable cluster to fire, only an
ambient KUBECONFIG, and it will spend up to 360s snapshotting whatever
cluster the current context happens to name.

The gate now keys on the `e2e-` prefix, read from the suite's own name
or from the directory holding it. At the top level that is the split the
project already sorts on: the Makefile builds `BATS_UNIT_FILES` as
`$(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats))`, so a top-level
`e2e-*` suite is exactly the one `make unit-tests` refuses to run and
`packages/core/testing/Makefile` runs against a live cluster. That
wildcard is not recursive, and `hack/e2e-apps/` holds live-cluster
suites whose own filenames carry no prefix, so the directory is checked
too. Matching the basename alone would take the captures away from
suites that need them, which is the opposite of the point. Every leg,
bound and message on the e2e path is unchanged.

It is deliberately not a reachability probe, though a probe would also
tell a cluster from a bare binary. A failing e2e run is frequently one
whose apiserver is degraded, and a probe answering "no" there would skip
the snapshot in precisely the case the snapshot exists for. A diagnostic
should not condition itself on the health of the thing it diagnoses.

The test drives the runner as a subprocess over fixtures that differ
only in where they sit, with stub kubectl and crust-gather on PATH so
neither direction is decided by a binary being absent. Each leg is
pinned by the artefact it leaves in the report tree rather than by the
one leg that echoes, so no single leg can be ungated without a test
going red; that was checked by ungating each in turn. The two armed
cases are the positive control for the disarmed one, whose assertion is
negative and would otherwise pass just as well against a harness that
ran nothing.

Relates to #3626.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### 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:

### 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(hack): cozytest.sh no longer arms its cluster-capture hooks for unit BATS suites, so a failing unit test fails fast instead of waiting out capture timeouts against a cluster that is not there
```


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

## Summary by CodeRabbit

* **Bug Fixes**
* Cluster diagnostics are now collected only for end-to-end test suites.
* Unit tests no longer trigger unnecessary Kubernetes or cluster-capture
operations.
* End-to-end suites nested under application directories are correctly
recognized.
* Diagnostic capture remains subject to existing tool availability and
timeout checks.

* **Tests**
* Added coverage verifying diagnostic artifacts are collected for
end-to-end failures and omitted for unit-test failures.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 10:46:53 +03:00
Aleksei Sviridkin 7b36a5d2f2 test(tenant): give these template lists their real reason (#3651)
## What this PR does

Two helm-unittest suites in `packages/apps/tenant/tests/` said they
restrict rendering because `namespace.yaml`'s `lookup` errors under
helm-unittest. It does not. The lookup returns empty offline and the
template renders, which `namespace.yaml`'s own header states and which
`tests/namespace_ancestor_labels_test.yaml` depends on.

I measured what each list actually does by removing it, and the two
suites turned out to be different cases rather than two copies of one.

In `cleanup_role_watch_test.yaml` the list is load-bearing, for a
template the comment never mentioned. That suite sets no values, and
`keycloakgroups.yaml` indexes `.Values._cluster` on its first line, so
the whole-chart render fails with `index of untyped nil` and all 8 tests
error. The comment now says that, and says it conditionally: a guard on
that index is already in flight, and once it lands the list stops being
load-bearing. I checked that too, by applying the guard and re-running
with the list removed, which passes 8/8.

In `vmo_webhook_dependson_test.yaml` the list is not load-bearing at
all. That suite sets `_cluster`, the chart renders without the list, and
all 5 tests pass. It stays only to scope the suite, and the comment says
so instead of inventing a technical reason.

Each comment now states what holds for its own file and nothing wider.

### Screenshots

Not a UI change.

### Downstream repositories

Walked the trigger map against the diff. It touches two files, both
under `packages/apps/tenant/tests/`, and no chart, template, values file
or tooling. Nothing in the map keys on test files.

- [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:

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

* **Documentation**
  * Clarified test-rendering rationale and suite-scoping comments.
* Documented that rendering requires `_cluster` values and that template
lists control test scope.
* **Tests**
* Updated explanatory comments only; test behavior and configuration
remain unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 10:46:41 +03:00
Aleksei Sviridkin 4740daa7be test(tenant, harbor, seaweedfs, api): anchor guards that pass on a missing document (#3639)
## What this PR does

Five helm-unittest suites asserted things that stayed green when the
thing they guard disappeared. No chart or template logic changes here —
only the tests, and each fix is measured in both directions.

**tenant `no_upgrade_force_test.yaml`.** Every test asserted `notExists:
spec.upgrade.force` and nothing else. A `notExists` passes on a template
that renders no document at all, so renaming the value that gates one of
these releases leaves its test green while the release it guards is
gone. Measured on `etcd.yaml`: with the gate renamed, the suite reported
6 passed and a sibling suite's `equal` was the only thing in the chart
that noticed. Each test now pins the HelmRelease name alongside the
absence.

The same file also covers `computeplane.yaml` now. Seven templates in
this chart render a HelmRelease and the list held six; the module landed
after the suite was written. The invariant holds there today, so this
closes an unguarded template rather than a live regression. That is the
gap that produced this suite in the first place, when the first sweep
missed `gateway.yaml`.

Two rationales in that header were wrong and are corrected. The template
list was said to be there because `namespace.yaml`'s `lookup` errors
under helm-unittest; it does not, it returns empty offline and the
template renders, which is what `namespace.yaml`'s own header says and
what `namespace_ancestor_labels_test.yaml` relies on. Removing the whole
list leaves the chart's suites passing, so the list stays for the reason
that actually applies. And helm-controller does not deprecate
`upgrade.force`: the CRD vendored at
`internal/fluxinstall/manifests/fluxcd.yaml`, which is what the pinned
Flux 2.8.x ships, describes it as a replacement strategy with no
deprecation marker. `recreate` is the neighbouring field that carries
one.

**harbor, both suites.** Both addressed their subject by document index.
An index is a position, so a template that gains a document ahead of the
one under test moves every later assertion onto a different object.
Measured by inserting one document at the head of each template: the
cleanup suite reddens outright, while two of the four jobservice tests
keep passing: the two `notExists` ones, satisfied against an object that
never had the path. Every document these suites assert on is unique by
kind within its template, so each test now names its subject. A selector
that matches nothing fails loudly, including on the `notExists` tests.

Indices are also easy to get wrong by hand: helm-unittest numbers
documents in template source order while `helm template` prints them
sorted by kind, so the rendered output is not the order being counted.

**seaweedfs and cozystack-api.** Both forbade a rule by naming it in
full, and helm-unittest compares every field it is handed by value
equality, so one respelling walks past. For seaweedfs that is not
theoretical: ungating the cnpg block and adding a verb to the rule is
the same grant and more, and the guard stayed green while a Client
instance held delete on another app's Postgres cluster. The Role is now
bounded by shape. A Client renders exactly two rules, both in the core
group, and the cnpg grant is gated between them, so ungating it adds a
third whatever its spelling.

For cozystack-api the count bound sits beside the existing named-rule
assert rather than replacing it. Neither covers the other:
`templates/rbac.yaml` carries no template directives, so the list is
pinned at exactly sixteen rules from both sides, while a grant swapped
in for an existing one leaves the count where it was and only the
named-rule assert sees it. Measured on all three: append, delete, and
swap.

### Screenshots

Not a UI change.

### Downstream repositories

Walked the trigger map against the diff. It touches five files, all
under `packages/*/*/tests/`, and no chart, template, values file or
tooling. Nothing in the map keys on test files.

- [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:

### Release note

```release-note
NONE
```


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

## Summary by CodeRabbit

- **Tests**
- Improved validation of rendered Kubernetes resources across Harbor,
tenant, SeaweedFS, and system API configurations.
- Tests now identify resources by kind and relevant properties, making
checks more reliable when rendered document ordering changes.
- Expanded coverage for tenant upgrade settings, compute plane
resources, storage class propagation, cleanup permissions, and network
policy access.
- Added stricter assertions for expected resource counts, names, rules,
and omitted settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 10:46:27 +03:00
Aleksei Sviridkin f92306681d fix(e2e): an unanswered read is not the same as an absent thing
Five places turned an empty or missing result into a fact about the
cluster: no affected pods, no ovn-central, no LoadBalancer service, no
cilium-agent or ovs on a node, and a LoadBalancer recorded as reachable.
Each is also what the script produces when the read behind it never
answered, and with a note now saying the read failed, lines sat next to
each other and disagreed.

Each claim has a counterpart chosen by the status of the read that
produced it, and each says what is left unknown rather than what the
read did:

  no scheduled NotReady pods      / whether any pod is affected
  no ovn-central pod in <ns>      / whether <ns> runs ovn-central
  no Service type=LoadBalancer    / whether any LoadBalancer needs capturing
  (no cilium-agent pod found)     / could not determine whether one runs there
  LB reachable, skipped           / whether the LB is reachable is unknown

The last of those is the subtlest, because nothing about it looks like a
claim of absence. host_http_probe resolves a cni-server before probing,
and a lookup that never answered produced no probe outcome at all, which
the decision helper read as "nothing failed" and the artifact stamped as
reachable -- a verdict about an address the script never touched. The
probe now emits an explicit unknown, and a set of outcomes that is
entirely unknown stays unknown rather than collapsing into either
answer. Only a wholly unknown set: one unrun probe beside real failures
leaves the failures standing and still captures, because they are the
evidence the capture exists to characterise.

Wording that describes the read instead would be wrong in a case these
bounds create. A list can fail after emitting rows, since a bound firing
mid-stream leaves output on stdout and 124 in $?, so an empty result
with a non-zero status can mean the read said nothing or that it said
only uninteresting things. Before the bounds a read either answered or
hung forever, and a partial answer was not a state this script could
reach. For the same reason a failure note is unconditional on the status
while the consequence -- skipped, unresolved -- is stated only by the
branch that takes it, which is why the note inside pod_on_node names
none at all: five call sites share it and their consequences differ.

Two of these five are in the artifact rather than the log, and they are
there because of the bounds. Unbounded, the per-node lookup blocked
until the caller's backstop killed the script and no capture file was
written; bounded, the script survives and writes the file, so a read
that never answered would have been recorded as a pod that is not there
or an address that answered. pod_on_node returns its status with the
name and the memo stores it, since a hit that dropped it would launder a
cutoff into a confident absence. The status travels packed with the
value through stdout because every caller reads the helper through a
command substitution, where a variable dies with the subshell -- the
same property that makes the memo itself a file. Only answers and
cutoffs are memoised: an instant failure costs nothing to ask again, and
caching it would make one transient permanent for the whole run.

Every note also ships beside the capture it explains, not only in the
job log, because the reader who has the uploaded report and not the run
is exactly the reader who cannot otherwise tell a capture that found
nothing from one that never ran. Both sibling collectors hold that
contract and this script's notes are modelled on them.

The two phrasing helpers move above the sourcing guard so the unit suite
can assert their branches directly. The stderr sink stays below it,
because anything created above is created again by every test that
sources this file and by every host that exits early for want of
kubectl.

What is deliberately left is the same distinction for the two
EndpointSlice reads, which still discard stderr and report neither
outcome.

Each branch is pinned by a stub that answers one read and fails another,
each partial-answer case by a stub that emits a row and then fails, the
artifact cases in the artifact rather than the log, and the memo's
status round-trip by consuming one lookup from both a miss and a hit.
Every one of them carries a positive anchor as well, so a stub that
stops short of the branch fails rather than passing quietly.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 10:07:21 +03:00
Aleksei Sviridkin b921952fb0 perf(e2e): ask each per-node pod lookup once
The same (namespace, label, node) is asked up to three times per
affected pod across the sections of this script. That was free while the
first call hung forever and the script died inside it; with a per-read
bound each repeat costs its own timeout, and against a wedged apiserver
the repetition becomes the largest consumer of the caller's envelope --
enough that a 300s budget buys two pods where it could buy five.

Answers are memoised, empty ones included: "there is no cilium-agent on
this node" is exactly the lookup worth not repeating.

The memo is a file rather than a variable, which is not a style choice.
The walk over affected pods runs on the right-hand side of a pipeline,
so it executes in a subshell and a variable memo is discarded at the end
of it, leaving every lookup to be paid for again in the sections that
follow. With the variable form the suite still recorded each of the
three lookups twice. That is also why this is not the shape of the node
memo above it, which is a space-delimited string matched with a case
glob and is correct for a dedup living entirely inside the walk.

Keys are compared as whole tab-separated fields, so the '=' inside a
label selector is ordinary payload; what the scheme rests on is that no
namespace, label selector or node name contains a tab, which Kubernetes
names and selectors cannot.

An empty pod list is also separated from one that never answered, so the
branch below it stops reporting a failed read as a cluster with nothing
wrong.

Pinned by a stub that records every per-node lookup it is given: the
test fails if any is issued twice.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 09:11:51 +03:00
Aleksei Sviridkin 7d1137a889 fix(e2e): bound the dataplane capture's reads and name why one failed
Six `kubectl get` calls ran with no bound of their own, while all 27
`kubectl exec` calls in the same script went through one. Against an
apiserver that hangs rather than refuses, the first of them -- the
cluster-wide pod list -- held the script until the caller's backstop
killed it, so the capture wrote nothing at all. From the Chainsaw catch
that is worse than empty: the run is cut off by the outer `timeout -k`
and the script says nothing about having been cut short, so a truncated
capture reads exactly like a complete one that found no affected pods.

Measured against a kubectl stub that never returns: before, the script
was still blocked on its first call when a 60s backstop killed it;
after, it reaches its own end and says which read was cut off and what
that leaves missing. Roughly 4s at a 1s bound, 40s at the 20s default.

A per-read bound buys forward progress, not a smaller total. The sum of
the bounds here is far past any caller's envelope and deliberately so,
because the MAX_PODS and MAX_LBS caps bound work while the outer
`timeout -k` bounds wall clock. What changes is that no single hung read
can consume that envelope before anything is written.

The reporting around them is built to survive being read by someone who
was not there. Only 124 and 137 may name a timeout, since those are the
only statuses a bound produces and every other one is kubectl answering
-- refused, denied, a kind the cluster does not serve -- so the reads
keep their stderr instead of discarding it and quote it back. 137 is
described as what it is, a SIGKILL that cannot be told apart from the
bound's own kill grace. Notes print through `printf` rather than `echo`,
because under /bin/sh echo expands backslash escapes and kubectl's
stderr now flows through the logger: a message holding a literal \n
would otherwise split the note and forge a second "[capture-dataplane]"
line that reads as this script's own verdict, which needs no malice
given the jsonpath in these reads contains {"\n"} and kubectl quotes the
expression back when it fails to parse it.

The per-node lookups ask with `{.items[*].metadata.name}` rather than
`{.items[0]...}`. client-go's evalArray has no allowMissingKeys escape
the way evalField does, so indexing into an empty list is a hard error
and kubectl exits 1 -- which meant nothing while the status was
discarded, and would report "there is no cilium-agent on this node", the
ordinary answer, as a read that failed.

The tests drive the script as a subprocess against a stub kubectl that
hangs, fails instantly, or returns a forged line, and read what it
wrote. The stub that proves forward progress answers the pod list and
hangs afterwards, so the walk over affected pods is actually entered.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 09:11:51 +03:00
myasnikovdaniil 68e1e41124 fix(ci): escalate unclassified paths in TIA instead of skipping e2e (#3511)
## What this PR does

Closes #3392. Stacked on top of #3262 — that PR turns the `E2E Tests`
merge gate into a commit status, which is what makes this fail-open path
matter: a skipped required *job* used to pass branch protection either
way, but a status posted `success` with no suite run is an explicit
green verdict on untested code.

`hack/select-e2e.sh` silently selected nothing for a changed path it did
not recognise outside `packages/`. Both lanes read an empty selection as
"skip Chainsaw" and then report green. The worked example from the issue
is `hack/common-envs.mk`, which supplies the tag, push and output flags
of every image in the tree and matched nothing: `full_suite_pattern`
covered `hack/*.sh` and `hack/*.bats` but not `hack/*.mk`. Walking the
tree turned up five more in the same position — `pkg/`, `tools/`,
`go.mod`/`go.sum`, `hack/lib/` and `hack/buildkitd.toml` — plus
`.github/workflows/e2e-fork.yaml`, which #3262 adds while
`pull-requests.yaml` was already escalated.

As the issue argues, a blanket "empty selection means full suite"
fallback would be the wrong fix, because it escalates exactly the inert
paths the design deliberately skips. Instead every changed path now
lands in one of three classes by an explicit rule:

- `full_suite_pattern` — shared build inputs and the e2e harness:
`packages/library/`, `packages/core/`, the Go trees (`api/`, `cmd/`,
`internal/`, `pkg/`), the codegen under `tools/`, `go.mod`/`go.sum`,
`hack/*.sh|*.bats|*.mk`, `hack/lib/`, `hack/buildkitd.toml`,
`hack/e2e-*.yaml`, the `Makefile`, and the workflows that run the suite.
- `inert_config_pattern` — paths that cannot affect what e2e exercises:
`examples/`, `.github/` (bar the e2e workflows), `.claude/`, `.gemini/`,
`img/`, `hack/testdata/`, the codegen boilerplate header, and the
top-level meta files (`LICENSE`, `.gitignore`,
`.pre-commit-config.yaml`, `.coderabbit.yaml`).
- the PackageSource graph for `packages/(apps|system|extra)/`,
unchanged.

A path matching none of them escalates to the full suite and logs which
path forced it, so the next path someone adds fails safe rather than
fails open. The two lists are checked in that order, so a specific
escalation beats a broad inert directory — `.github/` is inert,
`.github/workflows/e2e-fork.yaml` is not. `*.md`, `docs/` and
`dashboards/` stay ahead of both, so a `README.md` under
`packages/core/` does not inherit that tree's escalation.

The second commit removes the `|| [ -z "$SELECTED_APPS" ]` arm from the
Chainsaw step in both lanes. Under a step gated on `skip != 'true'` it
could never decide anything the `FULL_E2E` test had not, while the
comment above it advertised "full suite when the selection is empty" in
the file whose whole point is that a green status means tests ran.

### Deliberate choices worth a second opinion

- **Which workflows escalate.** The four that actually invoke the suite
(`pull-requests`, `e2e-fork`, `e2e-tag`, `nightly`), enumerated rather
than matched by prefix so an unrelated workflow does not burn a full
run. That does mean an `e2e-tag.yaml` or `nightly.yaml` edit now costs a
full PR suite even though the lane it changes is not the PR lane. Easy
to narrow to the two PR lanes if that is the wrong trade.
- **`go.mod`/`go.sum` escalate.** A dependency bump changes the shipped
binaries, so this is the honest classification, but it does make
Renovate's Go PRs pay for a full suite.

### Verification

- `hack/select-e2e_test.bats`: 25 tests green (15 existing, 10 new),
covering both sides of the classification.
- Mutation-checked rather than asserted: reverting the fall-through
fails the unclassified-path test; dropping `.github/` from the inert
list fails the non-e2e-workflow test; swapping the order of the two list
checks fails the existing `pull-requests.yaml` test.
- The per-entry escalations are belt-and-braces by design: removing
`hack/*.mk` from the pattern still escalates via the fall-through, so
that test pins the outcome rather than the pattern entry. Called out so
nobody reads it as stronger coverage than it is.
- `make bats-unit-tests`: 417 tests, 0 failures. `actionlint` clean on
both workflows.
- Self-check: this branch's own diff selects the full suite (it touches
both e2e workflows and a `hack/*.sh`).

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the diff
file by file. The only `hack/`-related trigger is `cozystack/ccp`, which
gates on `hack/package.mk` and `hack/common-envs.mk` existing as anchor
files and on what `make generate` does — nothing here is moved or
renamed, neither anchor file is touched, and no make target changes
behaviour (`BATS_UNIT_FILES` already globbed `hack/*.bats`). The rest of
the diff is e2e workflows and agent docs, which appear nowhere in the
map.

- [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:

### Release note

```release-note
fix(ci): Test-Impact Analysis now classifies every changed path explicitly and escalates an unrecognised one to the full E2E suite. Previously a path the selector did not recognise (`hack/*.mk`, `pkg/`, `go.mod`, `hack/lib/`, the fork E2E workflow) selected no suites, which both E2E lanes read as "nothing to test" and reported the required `E2E Tests` gate green with no suite run.
```

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

* **Bug Fixes**
* Improved end-to-end test selection so unrecognized or high-impact
changes trigger the full suite instead of being skipped.
* Inert-only changes now safely skip testing, while empty selections no
longer trigger the full suite.
* Full-suite testing is triggered explicitly by the `full-e2e` label or
applicable change types.

* **Tests**
* Added coverage for build files, source code, workflows, scripts,
documentation, metadata, and unclassified paths.

* **Documentation**
* Clarified end-to-end test selection, escalation rules, skipped file
categories, and workflow handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 06:22:45 +03:00
Aleksei Sviridkin 8e273a7341 docs(ci): record what escalating on the non-PR workflows costs
Three of the four workflows on the escalation list cannot be exercised
by the run they trigger. `e2e-fork` runs on `workflow_run`, which always
takes the default-branch copy; `e2e-tag` runs on `workflow_call` from a
tag push; `nightly` runs on a schedule. Only `pull-requests.yaml` is
executed from the pull request's own head, so only there does escalating
test the edit rather than the tree around it.

That argument existed in the reference doc and was removed by this
branch rather than answered, and the list it argued against is what
replaced it. Measured over six months of main, 26 commits touch one of
the three with no other escalating path; each now pays a full Chainsaw
run it used to skip, on a suite whose flake rate is the reason people
avoid running it for nothing.

Keeping them is defensible: the rule stays one idea rather than two, and
dropping them leaves those files inert, which reads as an oversight
rather than a decision. What was not defensible was paying it silently.
The trade now sits next to the pattern that makes it, with the number,
so the next person weighing it starts from measurement instead of
rediscovering the mechanism. The reference doc points there rather than
carrying its own copy.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:55:37 +03:00
Aleksei Sviridkin 50c5e2c14c test(e2e): capture the tenant worker guest serial console
The tenant Kubernetes suites fail the node-Ready budget with zero
Nodes registered, and the time is lost inside the guest between the
worker VMI reaching Running and Talos requesting its apid
certificate. Nothing collected today covers that window: the existing
in-guest capture drives talosctl, so it needs the very apid the
worker never reached, and on the management side there is neither a
Node nor a certificate request to read.

Turn on the guest console log for the suite's node group and collect
each virt-launcher's guest-console-log container from the management
cluster. Reads start at the beginning of the stream rather than
tailing it, so a guest that repaints its console cannot push the boot
output out of the window; every read is bounded, and the walk stops
at a cap that records both counts, so the collector cannot become the
unbounded term in a failure path that already runs several others
under one deadline.

A read that returns no console is not filed as a quiet guest. Three
outcomes get three labels, because only one of them says anything
about the guest: the read failed and returned nothing, the read
succeeded and returned nothing, or it broke off part way. Each read's
stderr is kept beside the capture rather than inside it, since
kubectl's own "container is not valid for pod" would otherwise fill
the file and a console that never started would read as one that had
spoken. That case is the headline one here, so the Pod state and
events that explain it are collected alongside, once for the whole
selector.

Enabling the console overrides a cluster-wide setting the platform
holds for an open upstream defect, so the run is also an experiment,
and its own failure mode is the one that hides best: the container
wedges virt-launcher in Init, no worker boots, and the suite reports
"fewer than 2 tenant nodes Ready" -- the same sentence the failure
under study produces. Read as the known signature, the experiment's
answer would be filed as the flake it was meant to explain. So the
failure path names that case first, before any other diagnostic, and
only when it is true.

The passing path checks separately that the container attached at
all, and reports the ratio it saw rather than a bare success, since
the check is deliberately satisfied by any one Pod. It keeps three
outcomes apart too: a read that did not answer, a selector that
matched no Pod, and Pods that matched and carry nothing. It reads the
Pod name alongside the container names so the last two cannot
collapse, and both container lists so a future move of the container
cannot fail a run over a diagnostic. It runs last, after everything
the suite exists to prove, because a debugging aid must not preempt
the assertions that cover the cluster.

The capture runs before the talosctl one because it is the only of
the two that can describe a worker which never reached apid, the
shape this failure keeps taking. kubectl reports a warning and an
empty result alike, on stderr with a zero status, so the exit status
decides which artifact its output lands in.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:48:38 +03:00
Aleksei Sviridkin a6c94c4528 fix(ci): correct an inverted caveat and clean up after the assertion
Two defects introduced by the previous two commits.

The in-tree lane's comment said `skip=true` cannot mean "a workflow
nobody classified", and then explained, after a colon, exactly how it
can: `.github/` is inert as a directory and the e2e workflows are
exempted from it by name, so a new one reads as inert. The clause meant
to warn a reader instead told them the one unsafe case is safe, in the
copy consulted when deciding whether to trust a green gate. The other
three copies of the caveat state it correctly.

The scratch-directory cleanup landed before the last assertion in every
test added here. That passes the EXIT-trap guard and inverts the reason
the guard exists: both runners set `-e`, so cleanup placed last is
unreachable on failure and the fixture survives for inspection, which
`docs/agents/e2e-testing.md` §3 states outright and the already-converted
`hack/select-install_test.bats` follows. Placed early, a failing test
deletes the evidence of its own failure, and one test had the call
between two assertions, so which of them failed decided whether the
directory survived.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:37:07 +03:00
Aleksei Sviridkin bbee3d7a1b docs(ci): finish the sweep for the third copy of the inert claim
The previous pass corrected the same explanation in the selector header
and in the in-tree lane and missed this one, which sits in the fork
lane's selection step. It still said an empty selection means "verified
inert" and pointed only at the inert pattern, and it lacked the caveat
the other two copies gained: that a workflow added under
`.github/workflows/` reads as inert until it is named in the escalation
list.

Three copies of one explanation is the underlying problem; leaving one
of them a version behind is the way that problem shows up. A correction
is not finished while a copy of the old claim is still alive.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:22:36 +03:00
Aleksei Sviridkin 3eee4c5590 test(ci): clean up the new selector tests without an EXIT trap
The ten tests added with the classifier each installed `trap 'rm -rf
"$tmp"' EXIT`, taking this file from fifteen such traps to twenty-five.
`hack/cozyreport.bats` freezes that count per unconverted file, so
`make unit-tests` failed, and that runs in the required checks job.

The count is the visible half. The reason it is frozen is that an EXIT
trap replaces the one the bats binary installs for its own bookkeeping,
and a test failing under it prints no TAP line at all. Reverting the
step-6 escalation in a scratch copy made the flagship pin for this whole
change, the one asserting an unclassified path escalates, vanish from
the output: the run reported executing twenty-six of twenty-seven tests
and never named the missing one. After this change the same mutation
prints `not ok 21` with its name, file and line.

Clean up inline before the assertion instead, which is what the two
tests added alongside them already did. Behaviour is unchanged, all
twenty-seven still pass, and the frozen count returns to fifteen rather
than being re-frozen higher, since re-freezing would bank ten fresh
instances of the thing the freeze exists to stop.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:22:01 +03:00
Aleksei Sviridkin 55f3c79e9f docs(ci): stop pointing at the inert list for paths it does not hold
Four places said an empty selection means the inert pattern matched, and
sent the reader to `inert_config_pattern` to see which paths those are.
Documentation, dashboards and `*.md` are dropped before that pattern is
ever consulted, and a backups example whose app has no suite is dropped
where backup examples are matched. A reader checking the pattern for any
of them finds nothing and has to reconstruct the order by reading the
script. Name the rules that produce an empty selection instead, and keep
the claim to the one the lanes rely on: empty is a decision some rule
reached, not a path nothing looked at.

The same bullet claimed every path is classified. One is not. The inert
pattern makes `.github/` inert as a whole directory and the workflows
that run the suite are exempted from it by name, so a workflow added
under `.github/workflows/` reads as inert and the fall-through never
sees it. Today's enumeration is correct and I checked it against `rg -l
test-chainsaw`, so this is not a live hole, but the sentence saying it
cannot exist is what makes the reminder next to that list look
decorative. Record it as the guard it is.

Separately, the fork lane's rationale for appending a newline described
a mechanism this branch removes: the reader now guards its own loop, so
the terminator is no longer what keeps the last path alive. Say that it
is now the text-file contract and a second line of defence across two
files, which is a reason to keep it and a different reason than the one
written there.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 05:02:25 +03:00
Aleksei Sviridkin 1cb51c92aa test(tenant): give these template lists their real reason
Both suites here said they restrict rendering because namespace.yaml's
`lookup` errors under helm-unittest. It does not. The lookup returns empty
offline and the template renders, which namespace.yaml's own header states
and which tests/namespace_ancestor_labels_test.yaml depends on.

Measured what each list actually does by removing it.

In cleanup_role_watch_test.yaml the list is load-bearing, for a template
the comment never mentioned: the suite sets no values and
keycloakgroups.yaml indexes `.Values._cluster` on its first line, so the
whole-chart render fails with "index of untyped nil". Stated
conditionally, because a guard on that index is already in flight and
would end it.

In vmo_webhook_dependson_test.yaml the list is not load-bearing at all,
because that suite sets `_cluster` and the chart renders without it. It
stays only to scope the suite.

Each comment now states what holds for its own file and nothing wider.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 04:49:23 +03:00
Aleksei Sviridkin 548dfa9520 docs(ci): say what an empty selection actually means
Four places on this branch describe an empty selection as "classified
inert", against a list of three outcomes that every path is said to land
in. There is a fourth: an edit under examples/backups/<app>/ where the
app has no Chainsaw suite selects nothing, decided inline where backup
examples are matched rather than through the inert list. The rule is
deliberate and explained where it lives; it just is not one of the three
the summary enumerates.

The property the two lanes actually depend on is weaker and true: an
empty selection means some rule decided the change cannot affect a
suite, never that nothing looked at the path. State that instead, in the
header that defines the classes and in the privileged lane's comment
that consumes the result.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 04:47:41 +03:00
Aleksei Sviridkin cc522e7def fix(ci): classify the last path when the list has no trailing newline
The classifier this branch introduces is only total if every line
reaches the loop that applies it. POSIX read assigns the final line and
then returns non-zero when the input does not end in a newline, so a
plain `while read` drops it. The fall-through that escalates an
unrecognised path lives inside the loop body, so the dropped line is
never classified at all and the selection comes back empty. Both lanes
read empty as "skip Chainsaw" and post the required status green.

The shape that matters is a list whose only unclassified path is last:
`docs/x.md` followed by an unrecognised path with no terminator selects
nothing, where the same list with a terminator escalates to all 21
suites. The safety net this branch is built around is bypassed by
exactly the input it exists to catch.

No caller triggers it today, because `git diff --name-only` terminates
its output and the fork lane appends a newline explicitly. That makes it
caller discipline documented in one of two callers, which the next
caller will not read. Guard it where the assumption lives.

Both new cases are red without the guard and the twenty-five existing
ones stay green, so they pin this behaviour rather than the loop's
general shape. They clean up inline instead of through an EXIT trap:
that trap replaces the one the bats binary installs for its own
bookkeeping, and a test failing under it can print no TAP line at all,
which is the opposite of what a regression pin is for.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 04:47:27 +03:00
Aleksei Sviridkin a1496727a1 docs(ci): describe the classification the fork lane now gets
The privileged resolve step explains why the TIA inputs come from the
base repo, and closed with a caveat about the selector: that it escalates
only for an unrecognised path inside packages/ and silently selects
nothing for a path it does not recognise at all, naming hack/*.mk as the
example. That was true of the selector this branch replaces.

Measured against the selector as it now stands, hack/common-envs.mk
escalates to the full suite, and so does the fork workflow file itself.
An empty selection no longer means the path went unrecognised; it means
the path was classified as unable to affect what e2e exercises. The
difference is the whole point of the change, and the caveat now
contradicts it in the one comment a reader consults before touching the
privileged lane.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 04:33:18 +03:00
Myasnikov Daniil ee4a48278f refactor(ci): drop the unreachable empty-selection branch in both lanes
Both lanes ran `if [ "$FULL_E2E" = "true" ] || [ -z "$SELECTED_APPS" ]`
under a step gated on `steps.select.outputs.skip != 'true'`. When
`select` runs, an empty result sets `skip=true` and the step never
starts, so the only way to arrive with no selection is the `full-e2e`
label — which sets `FULL_E2E=true`. The second test could never decide
anything the first had not, while the comment above it advertised
"full suite when the selection is empty" in the file whose whole point
is that a green status means tests ran.

Testing `FULL_E2E` alone keeps the real invariant visible. The comments
now say what an empty selection means after the preceding commit: every
changed path was explicitly classified inert, not that the selector had
no idea.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-08-08 04:29:43 +03:00
Myasnikov Daniil ae4e9e55cb fix(ci): classify every TIA path and escalate the unclassified
`select-e2e.sh` silently selected nothing for a changed path it did not
recognise outside `packages/`. Both e2e lanes read an empty selection as
"skip Chainsaw" and then post the required "E2E Tests" status green, so
an unrecognised path produced a green merge gate with no suite run. The
worked example is `hack/common-envs.mk`, which supplies the tag, push
and output flags of every image in the tree and matched nothing:
`full_suite_pattern` covered `hack/*.sh` and `hack/*.bats` but not
`hack/*.mk`. `pkg/`, `tools/`, `go.mod`, `hack/lib/` and
`hack/buildkitd.toml` were in the same position, as was
`.github/workflows/e2e-fork.yaml` while `pull-requests.yaml` was
already escalated.

A blanket "empty means full suite" fallback would be the wrong fix: it
escalates the inert paths the design deliberately skips. Instead every
path now lands in one of three classes by an explicit rule — the
full-suite list for shared build inputs and the e2e harness, an inert
list for paths that cannot affect what e2e exercises, and the
PackageSource graph for `packages/(apps|system|extra)/`. A path
matching none of them escalates and logs why, so the next path someone
adds fails safe instead of failing open.

The two lists are checked in that order, so a specific escalation beats
a broad inert directory: `.github/` is inert, the workflows that run
the suite are not. `*.md` and `docs/`/`dashboards/` stay ahead of both,
so a README under `packages/core/` does not inherit that tree's
escalation.

Ten tests cover both sides of the classification. Mutation-checked:
reverting the fall-through fails the unclassified-path test, dropping
`.github/` from the inert list fails the non-e2e-workflow test, and
swapping the two list checks fails the existing pull-requests.yaml
test. Note the per-entry escalations are belt-and-braces — removing
`hack/*.mk` from the pattern still escalates via the fall-through,
which is the point of having it.

Closes #3392

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-08-08 04:28:27 +03:00
myasnikovdaniil 7dca642894 fix(ci): restore e2e for fork PRs and close the merge-gate bypass (#3262)
## What this PR does

Fixes #3257 — fork PRs never ran e2e, and the skipped required check let
them merge anyway.

Fork PRs get no secrets on `pull_request`, so `make image` pushed
anonymously and died with `denied`. That failed every build, collapsed
the `build → finalize → e2e` chain, and left the required **E2E Tests**
job *skipped* — which branch protection treats as satisfied, so an
approved fork PR could merge with e2e never having run.

This implements **Option A** from the issue (privileged `workflow_run`
split), pushing fork e2e images to the existing CI registry (OCIR):

**Build primitive (`hack/common-envs.mk`)**
- New `OCI_EXPORT_DIR` mode: build each image to a per-image OCI archive
instead of pushing, and force `PUSH`/`LOAD` off. The digest is captured
via `--metadata-file` regardless of output type, so the refs baked into
`pr.patch` match what the privileged run later pushes. Threaded through
the `image-tags` macro every package already uses — no per-package
Makefile change.

**`pull-requests.yaml` (unprivileged, fork branch)**
- `build` / `build-talos` / `finalize` export images to OCI archives and
upload them as artifacts instead of pushing → the red `denied` build
wall disappears and the chain no longer collapses.
- The in-tree e2e job is renamed off `"E2E Tests"` and guarded to
same-repo PRs. A new `e2e-report` job concludes the required `"E2E
Tests"` context as an **explicit check-run** (success on a docs-only PR
or when e2e passed, failure otherwise). A skipped job can no longer
satisfy the required check — this also closes the same hole for
same-repo PRs whose build fails.

**`e2e-fork.yaml` (new, privileged `workflow_run` from the default
branch)**
- Resolves the fork PR, opens a pending `"E2E Tests"` check-run, and
fails-closed if the fork build failed.
- Pushes the pre-built image archives to OCIR **by digest** and
flux-pushes the digest-pinned packages tree. The OCIR token is live only
for trusted binaries (`git`/`skopeo`/`flux`/`yq`) over data — never for
`make` or any fork-authored script.
- Runs the e2e suite against the pushed closure with **no push token**
and concludes the check-run on the PR head SHA.

Fork build code never receives registry credentials; the fork's images
and test scripts run only in the credential-less, ephemeral e2e job — a
stronger posture than a `pull_request_target` run that would hand fork
code the secrets directly.

### Validation

Local: buildx OCI-archive output + digest capture, macro expansion for
single- and multi-image packages, non-fork push path unchanged,
`actionlint` clean.

Needs live validation (why this is a **draft**): `workflow_run` artifact
download across runs; that the `"E2E Tests"` commit status satisfies the
required context and keeps a fork PR non-mergeable until the privileged
run concludes it; `refs/pull/<N>/merge` fetch + `git apply --3way`;
`skopeo --preserve-digests` end-to-end. `workflow_run` only runs from
the default branch, so the privileged half is exercised by mirroring the
workflow onto a personal fork's default branch and opening a throwaway
fork PR into it — not by merging first.

_Since review_: the fork `e2e` job ran the pre-#2826 BATS app loop that
the merged Chainsaw migration deleted (already broken); it now runs the
same Chainsaw + TIA path as the in-tree job, with suite selection and
the `full-e2e` override sourced from `resolve`'s trusted base-repo file
list / labels (not a merge-ref diff). Publish job hardened per the
non-blocking follow-ups: base-tree image allowlist, `skopeo copy --all`,
pinned flux CLI. The required `E2E Tests` gate is now published as a
commit **status** (default token + `statuses:write`), not a COZYSTACK_CI
app check-run — the app lacked `checks:write` (`checks.create` 403'd)
and a default-token check-run floated under a labeler suite; a commit
status has neither problem, and forks can't post one (their
`pull_request` token is read-only). Two review-bot findings fixed too:
fork `OCI_EXPORT_DIR` is now absolute (a relative path misplaced
archives under `make -C <pkg>` and every fork build would fail at
upload), and the "≥1 archive" guard no longer no-ops under `nullglob`.

### Repo-admin prerequisites

One repository setting lives outside this diff:

1. **Branch protection: keep `E2E Tests` required.** The gate is now a
commit **status** named `E2E Tests` (posted with the default
`GITHUB_TOKEN` — see `docs/agents/e2e-testing.md` §10), not a job or an
app check-run. Branch protection already requires `E2E Tests` (currently
the e2e job's name, renamed here to `E2E (in-tree)`), so the required
context now resolves to the status — **no GitHub App, no `checks:write`
grant, no app-id pin needed**. Just don't pin the context to a specific
app.
2. **Actions → "Require approval for all outside collaborators".** Each
fork run must be a deliberate maintainer click before it executes, since
the fork's unprivileged run produces the artifacts the privileged
`e2e-fork.yaml` consumes.

### Downstream repositories

Walked the trigger map in `docs/agents/contributing.md` against the diff
(7 files: two CI workflows, `docs/agents/e2e-testing.md`,
`hack/common-envs.mk` + its bats suite, and two package `Makefile`s).

The one trigger worth naming is "change developer tooling
(`cozyvalues-gen`, `cozypkg`, the package `Makefile`s) →
`development.md`" on the website. It does not fire here:
`OCI_EXPORT_DIR` is a CI-only build mode that defaults off, and the
local developer workflow is unchanged — `make image` still builds and
pushes exactly as before, which the new default-path case in
`hack/common-envs_test.bats` now pins. No chart values, no API surface,
no app list, no platform variant, no Talos version bump and no release
asset rename are touched.

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

### Release note

```release-note
fix(ci): fork pull requests now run e2e via a privileged workflow, and a fork PR can no longer merge on a skipped (never-run) required E2E Tests check
```

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

- **New Features**
- Added a dedicated fork E2E workflow that runs after the base PR and
reports results via the required **E2E Tests** commit status.
- Fork PRs now export build images as OCI archive artifacts for later
publishing.
- Added a same-repo E2E reporting job and renamed the in-tree E2E flow
to **E2E (in-tree)**.
- **Bug Fixes**
- Added tighter, fail-closed gating to ensure merge blocking when **E2E
Tests** isn’t correctly finalized.
- **Documentation**
- Documented fork vs same-repo E2E split, OCI export behavior, and
required fork security settings.
- **Tests**
- Added Bats coverage validating `OCI_EXPORT_DIR` and preventing
unintended registry pushes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 04:26:25 +03:00
Aleksei Sviridkin bc6d5e67b7 fix(e2e): keep failure snapshots complete and gate tenant patch on aggregated-API auth (#3444)
## What this PR does

Two unrelated defects made the sandbox install job's failure handling
unreliable. This fixes both.

The first hit every red run. When a test fails, the diagnostic snapshot
runs `crust-gather collect`, and its host-log leg spawns a privileged
debug pod per node. The sandbox runs baseline PodSecurity, which rejects
each of those pods with a 403, so crust-gather keeps retrying until it
burns its whole collection budget and exits 1. The snapshot verdict then
printed `FAILED` on every failed run even though the namespaced object
state was already captured, so the line that says whether the uploaded
snapshot is trustworthy was always wrong. The host-log leg collects
nothing usable here anyway, so this passes `--disable-additional-logs`
at all three collect sites: the host snapshot from the BATS runner, the
host snapshot from the Chainsaw error handler, and the nested tenant
snapshot. Object collection now finishes and the verdict again separates
a real collection failure from a complete snapshot.

The second is a race in the install test. The "Configure Tenant" step
issues its first `kubectl patch` of the root tenant as soon as an
earlier test sees the aggregated APIService go `Available`. That
condition only tracks endpoint availability, not the serving pod's auth
state. A freshly rolled cozystack-api pod can start answering before it
has loaded the requestheader client-CA, and until it does it drops the
front-proxy identity and answers as `system:anonymous`, so the patch is
rejected with a 403 on the test's very first command. This adds a short
authenticated GET against the same resource before the patch, so the
step proceeds only once the aggregated API both serves and authenticates
the request. If the aggregation layer is actually wedged past two
minutes the gate fails loud instead of hiding a real regression.

Both were traced from a recent sandbox failure (run 30004030331, during
PR #3441's CI).

Out of scope: the broader convergence flakiness of the install step is
not touched here. That covers the long reconcile windows for the storage
and platform releases and the app-level races in the Chainsaw suite. The
durable product-side fix for the second defect, where cozystack-api
should not report ready before the requestheader client-CA is loaded, is
a separate follow-up.

This partially addresses #3368, its crust-gather diagnostics item, and
does not close it.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

No UI changes.

### 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.
-->

The diff touches only the e2e test harness (`hack/cozytest.sh`,
`hack/e2e-chainsaw/.chainsaw.yaml`,
`hack/e2e-chainsaw/_lib/run-kubernetes.sh`,
`hack/e2e-install-cozystack.bats`). The trigger map's `hack/` couplings
are `hack/e2e-prepare-cluster.bats` (node prerequisites for
ansible-cozystack and talm) and `hack/package.mk`,
`hack/common-envs.mk`, `hack/update-crd.sh` plus the package layout
(ccp, external-apps-example, cozyhr). None of those are moved, renamed,
or edited here, and nothing else in the map matches.

- [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:

### 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(e2e): keep failure snapshots complete when PodSecurity blocks crust-gather's host-log leg, and wait for the aggregated API to authenticate before patching the root tenant so the install step no longer flakes on a system:anonymous 403
```


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

## Summary by CodeRabbit

- **Bug Fixes**
- Improved failure diagnostics during automated Kubernetes and tenant
tests by avoiding unsupported privileged log collection steps.
- Ensured failure snapshots complete more reliably instead of timing out
or returning incomplete results.
- Added a readiness check before configuring the root tenant, preventing
intermittent authorization failures during installation tests.

- **Tests**
- Increased reliability of installation and end-to-end test workflows
under baseline security policies.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-08 04:24:48 +03:00
Aleksei Sviridkin 85fd592cc7 fix(cozy-lib): make the cozyconfig helpers survive uninjected values
`_cluster` and `_namespace` reach a chart through valuesFrom on the
cozystack-values Secret, so they are absent whenever a chart is rendered
on its own: `helm lint`, a plain `helm template`, and any helm-unittest
suite that does not set them. Twelve of the helpers in _cozyconfig.tpl
read straight through the missing map and abort the render rather than
returning their documented empty default.

Two spellings, one cause. `index .Values._cluster "root-host"` raises
`index of untyped nil`, and `.Values._namespace.host` raises
`nil pointer evaluating interface {}.host`. In both cases the trailing
`| default ""` never runs: it applies to the result, and there is no
result, only an error. That default covered a key missing from a map
that exists, never a missing map.

Guard the map instead, which is what `cozy-lib.images-registry` in this
same file already did:

    (index (.Values._cluster | default dict) "root-host") | default ""

`index` on an empty map returns nil harmlessly, so the trailing default
applies as it always read. With the map present, `| default dict`
returns it untouched.

This changes no rendered output. Every one of the 34 charts that symlink
cozy-lib was rendered before and after, both with the injected values
populated and with them set to empty maps, and every result is
byte-identical. Eight charts differ between any two runs because they
generate passwords; rendering the unchanged tree twice reproduces
exactly those eight, which is how they are excluded.

What this does not do is fix a chart that fails today. The charts that
cannot render standalone read the injected values directly in their own
templates rather than through these helpers, so they are unaffected by a
change to the library and are being addressed where they live.

Twelve of the thirteen helpers here have no callers anywhere in
packages/ at present; only `cozy-lib.images-registry` is reached, from
`cozy-lib.image`, and it was already guarded. The guard is still worth
having on the rest: they are the library's public surface, they are what
a chart author copies when reaching for cluster configuration, and the
broken spelling propagates by being read. `cozy-lib.image` is called
from four templates, so these helpers do get adopted once they exist.

Add a suite in cozy-lib-tests, which is where library behaviour is
pinned because a library chart renders nothing of its own. One case
covers the uninjected state, which needs no mocking since neither key is
in values.yaml, and one covers the injected state so the guard cannot
quietly change what a populated map returns. The injected case includes
`scheduling` and `branding` specifically: those two are the helpers
whose body was restructured rather than wrapped, so they are the ones
whose present-map behaviour most needs pinning. Against the unguarded
helpers the first case fails with `index of untyped nil`.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:51:16 +03:00
Aleksei Sviridkin 8d5ab4cfee fix(hack): arm cozytest cluster captures for e2e suites only
The runner's on-failure EXIT trap gates each cluster capture on a binary
being installed rather than on there being a cluster to read. A unit
runner has kubectl and no cluster, so a red unit test walked into those
legs on its way out.

What that cost depends on how kubectl fails. Against an endpoint that
refuses immediately it is a second; against one that hangs, measured
with a stub that never returns, it is ~28s for the previous-logs leg,
which self-bounds its pod list and so never reaches the 300s ceiling
above it, plus the full 600s for the data-plane leg, whose first pod
list carries no bound of its own. Ten minutes of dead waiting for a
failure that should cost seconds. Where crust-gather is installed too
the bill stops being empty and starts being wrong: that leg needs no
reachable cluster to be pointless, only an ambient KUBECONFIG, and it
will spend up to 360s snapshotting whatever cluster the current context
names.

Gate the three legs on the e2e- prefix instead, read from the suite's
own name or from the directory holding it. At the top level that prefix
is what the project already sorts on: the Makefile builds
BATS_UNIT_FILES as $(filter-out hack/e2e-%.bats,$(wildcard hack/*.bats)),
so a top-level e2e-* suite is exactly the one `make unit-tests` refuses
to run and packages/core/testing/Makefile runs against a live cluster.
That wildcard is not recursive, and hack/e2e-apps/ holds live-cluster
suites whose own filenames carry no prefix, so the directory is checked
too: matching the basename alone would take the captures away from
suites that need them, which is the opposite of this change. The e2e
path is otherwise unchanged, every leg, bound and message as it was.

Not a reachability probe, though that would also tell a cluster from a
bare binary. A failing e2e run is frequently one whose apiserver is
degraded, and a probe answering "no" there would skip the snapshot in
precisely the case the snapshot exists for.

The test drives the runner as a subprocess over generated fixtures that
differ only in where they sit, with stub kubectl and crust-gather on
PATH. Stubbing both matters: the runner calls crust-gather directly
rather than through a script that needs kubectl output first, so a
kubectl-only stub would leave that leg reaching for the real binary
against the ambient KUBECONFIG. Each leg is pinned by the artefact it
leaves in the report tree, not only by the one leg that echoes, so no
single leg can be ungated without a test going red; each was checked by
ungating it alone. All three directions are asserted, and the two armed
cases are the positive control for the disarmed one, whose assertion is
negative and would otherwise pass just as well if the harness ran
nothing; that case also pins that the fixture did fail.

Two shell traps are handled rather than left to be rediscovered. The awk
transform rewrites any line beginning `@test "` and any line that is
exactly `}`, and knows nothing of heredocs, so a fixture written as a
heredoc has its own `@test` line harvested into the outer suite as a
phantom test that fails first and ends the run before the real tests;
the fixtures are built with printf instead. And `set -e` ignores a
command whose status is inverted with `!`, so the negative assertion is
spelled as an `if`. Both were checked by building the broken variant,
not reasoned about.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:43:29 +03:00
Aleksei Sviridkin 3af1ad381b fix(ci): count only the pull requests at the head, not near it
The guard requiring exactly one open pull request was written for PRs
that share a head commit, but the endpoint it reads is broader than
that. Per the API reference it lists pull requests ASSOCIATED with a
commit, which for a commit outside the default branch means any open PR
whose branch contains it. Stacking one branch on another and opening
both therefore counts as two, and the lower one fails closed on a state
its author cannot clear by pushing, because pushing does not remove the
PR above it.

That is the same unfixable red this workflow refuses to inflict
elsewhere, and it costs a contributor the one thing fail-closed is
supposed to protect: a way forward.

Filter the associations by head SHA as well as state. Two pull requests
genuinely pointing at the same commit still fail closed, which is the
case the guard exists for and the case where a verdict computed for one
PR would otherwise satisfy the other. Checked against all four shapes:
stacked branches now resolve, a real collision still refuses, a lone PR
resolves, and an association with no open PR at the head still refuses.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:38:42 +03:00
Aleksei Sviridkin 806b66f25c docs(ci): stop describing a one-workflow E2E CI
Two sentences predate the second workflow and were left describing the
world before it.

The scope line at the top is the pointer AGENTS.md routes agents through
before they touch the E2E CI, and it named only the in-tree workflow. A
reader sent there to change the fork lane would not learn that the
section holding its invariants exists, which is the one thing that line
has to do. It now names both workflows and says which pull requests each
serves.

The TIA escalation list said edits to "the E2E workflows" escalate to
the full suite. That was true while there was one. The selector's list
names `pull-requests.yaml` and not `e2e-fork.yaml`, so a change touching
only the fork workflow selects nothing: the Chainsaw step is skipped,
while the platform install and the OpenAPI tests still run. Say that
plainly, and say why it is tolerable rather than pretending it is
designed. A `workflow_run` always executes the default-branch copy of
the file, so running the suite against a change to it would not exercise
that change anyway. It is the same shape as the fail-open the section
already cites.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:22:08 +03:00
Aleksei Sviridkin 9d1d6808db test(seaweedfs,api): bound both Roles against a respelled rule
Each suite forbade a rule by naming it in full, and helm-unittest compares
every field it is handed by value equality, so one respelling walks past.

For seaweedfs that is not theoretical. Ungating the cnpg block and adding a
verb to the rule (get,list,delete rather than get,delete) is the same grant
and more, and the guard stayed green while a Client instance held delete on
another app's Postgres cluster. Bound the Role by shape: a Client renders
exactly two rules, both in the core group, and the cnpg grant is gated
between them, so ungating it adds a third whatever its spelling. The rules
themselves stay unpinned because the secret list is long and changes on its
own schedule.

For cozystack-api, add a count bound beside the existing spelling assert
rather than in place of it. The two catch different things and neither
covers the other: templates/rbac.yaml carries no template directives, so
the list is pinned at exactly sixteen rules, while a grant swapped in for
an existing one leaves the count where it was and only the named-rule
assert sees it. The count is pinned from both sides — a dropped grant is
the failure this suite is about, since the apiserver then returns forbidden
and List silently drops the dropdown. Measured on all three: append,
delete, and swap.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:09:47 +03:00
Aleksei Sviridkin a3c1408d08 test(harbor): select hook and release documents by kind, not by index
Both suites addressed their subject by document index. An index is a
position, so a template that gains a document ahead of the one under test
moves every later assertion onto a different object, and what happens then
depends on the assertion: measured by inserting one document at the head of
each template, the cleanup suite reddens outright, while two of the four
jobservice tests keep passing because a `notExists` against an object that
never had the path is satisfied for the wrong reason.

Indices are also easy to get wrong by hand. helm-unittest numbers documents
in template source order, while `helm template` prints them sorted by kind,
so the rendered output is not the order being counted.

Every document these suites assert on is unique by kind within its template,
so each test now names its subject. A selector that matches nothing fails,
including on the `notExists` tests, so the anchor holds in the direction that
was silent before.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:09:46 +03:00
Aleksei Sviridkin eac7e1ea15 test(tenant): anchor the upgrade.force guard to its HelmRelease
Each test asserted only `notExists: spec.upgrade.force`, which passes on a
template that renders no document at all. Renaming the value that gates one
of these releases makes the template render nothing and leaves its test
green: measured on etcd.yaml, where the guard stayed green while a sibling
suite's `equal` was the only thing that noticed the release had vanished.
Pin each HelmRelease name alongside the absence so the assertion has a
document to be about.

Cover computeplane.yaml. Seven templates in this chart emit a HelmRelease
and the list held six; the module arrived after the suite was written. The
invariant holds there today, so this closes an unguarded template rather
than a live regression — which is the same gap that produced this suite,
whose first pass missed gateway.yaml.

Correct two claims in the header. The template restriction was said to be
there because namespace.yaml's `lookup` errors under helm-unittest; it does
not, the lookup returns empty offline and the template renders, which is
what its own header says and what namespace_ancestor_labels_test.yaml
relies on. The restriction stays, for the reason that actually applies: it
keeps an unrelated template breaking out of this suite's result.

helm-controller also does not deprecate upgrade.force. The CRD vendored at
internal/fluxinstall/manifests/fluxcd.yaml, which is what the pinned Flux
2.8.x ships, describes it as a replacement strategy and carries no
deprecation marker; `recreate` is the neighbouring field that does. Later
upstream documentation adds that the field is ignored under server-side
apply, and that sentence is not in the pinned CRD, so the comment says
which is which. The reason to keep the flag off is client-side apply,
where it replaces resources instead of patching them.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 03:09:46 +03:00
Aleksei Sviridkin cc05c92e47 test(ci): require the discarded-label guard per job, not just agreement
The check collected every comparison against the label name and required
them to agree on one string. Agreement is not presence: deleting
`e2e-report`'s guard outright leaves the other two agreeing with each
other, so the assertion stayed green while a discarded label event began
publishing again and clobbering the verdict of the run that had actually
tested the SHA. That clobber is the defect two earlier commits were
written to stop.

Require the guard inside each of the two jobs that must carry it, and
keep the agreement check for the rename it does catch. Deleting either
copy now fails.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 02:26:19 +03:00
Aleksei Sviridkin e2e591ac6a fix(ci): claim the fork status before the lookups that can throw
`resolve` posted its `pending` last, after four paginated API calls. Any
of them returning 5xx fails the job, `should_run` is never set, `report`
skips on it, and nothing writes the context at all. A head SHA that was
already green then stays green with no suite behind it, for good rather
than for the length of a build, and the privileged run's own red is
invisible on the pull request because a `workflow_run` check suite hangs
off the default branch. That is the exact state the opener exists to
prevent, and the same-repo lane already treats this failure mode twice,
with `continue-on-error` on its opener and a try/catch around its
terminal post.

Post the pending as soon as the run is known to own the SHA, which is
immediately after the two silent returns. Three of the four lookups now
happen with the context already claimed, so a failure in any of them
leaves `pending` standing, and pending blocks.

The jobs lookup keeps its place ahead of the opener because the
discarded-label decision reads it, and posting before that decision is
exactly what lets a run stamp a status it does not own. A failure there
still leaves the SHA untouched; that half is a deliberate trade between
two ways of being wrong, and it is now written down next to the code
rather than implied by the ordering.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 02:25:39 +03:00
Aleksei Sviridkin cc8b01d5cd docs(ci): say when each lane's status opener actually lands
The reference section claimed both lanes open the required context when
their run starts, and enumerated the triggers it protects against: a
re-run, a reopen, a `full-e2e` label. Every one of those starts a Pull
Request run, and on a fork that run cannot open the status at all. The
opener is guarded same-repo only, because a fork's `pull_request` token
is read-only, and the fork's opener lives in the privileged workflow,
which starts only once the unprivileged build has finished. So on the
lane the sentence was most likely to be read about, an already-green
head SHA keeps reading green for the whole build.

The gap is structural. A read-only token cannot post a status at any
point in the run, and that same read-only token is what makes publishing
the gate with the default token safe in the first place. It is bounded
by the build rather than open-ended. What was wrong was only the claim
that it does not exist.

Say so in all three places that carried the overstatement: the section
itself, the opener's own comment, and the contract test, which pins that
each lane has an opener and cannot express when it lands.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 02:10:35 +03:00
Aleksei Sviridkin c45179a9d0 docs(ci): correct the reason the build matrix keeps the rename-detected list
The comment justified the split by saying a path that no longer exists
has nothing to build. That is only half the picture: on a rename between
packages the source package is still there and still needs rebuilding,
and rename detection drops it from the list the matrix reads, so the
matrix under-builds. The behaviour predates this change and is out of
scope for a docs-only decision, but the comment presented it as
deliberate and correct rather than as inherited and left alone.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 01:26:28 +03:00
Aleksei Sviridkin 9c68b1f83e fix(ci): stop a cancelled fork run from publishing a verdict
The invariant that a cancelled run publishes nothing was written down in
three places and held in two of them.

The fork lane's terminal reporter ran under `always()`, which includes
cancellation. When a second privileged run supersedes the first, the
dying one still posted `failure` on a head SHA it no longer owned, for a
suite that never finished. It self-corrected once the superseding run
reported, but that can be two hours later, and until then a legitimate
pull request reads red. The same-repo lane was moved off `always()` for
exactly this reason. Move this one too: a failed publish or e2e still
reports, since neither is a cancellation, and a cancelled run now leaves
the `pending` it opened standing until its successor concludes.

The same-repo guard's rationale cited a scenario that no longer exists.
It described a label event cancelling the run that opened the pull
request, which the concurrency split made impossible for every label the
workflow discards. The guard is still needed, for a push, for a
`full-e2e` label superseding the head run, and for a manual cancel, so
the comment now names those instead of an extinct one.

The reference section listed the pieces this invariant rests on and
omitted the reporters, which is the half a reader is most likely to
touch, and counted them, which was already wrong and would go wrong
again on the next addition. It now enumerates without counting.

The contract pins both reporters, because both lanes have had this
defect at different times in the same shape.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 01:26:09 +03:00
Aleksei Sviridkin 8a21085324 fix(ci): decide docs-only from both sides of a rename
The two lanes answered "is this docs-only" from different sources and
could disagree. `plan` reads `git diff --name-only`, which applies rename
detection and reports a rename as its new path alone, so moving a code
file into docs/ read as docs-only. The fork lane reads the PR file list,
where a rename carries `previous_filename`, and reads the same change as
code.

Disagreement is not a near-miss here. The same-repo lane builds nothing
and uploads no patch artifact; the fork lane concludes e2e is required
and then fails closed because that artifact is missing, reporting that
the fork build did not complete. It completed — it correctly built
nothing. There is no push the contributor can make to clear it and no
label that overrides it, so a legitimate contribution is permanently
red.

Point the docs-only decision at the rename-blind list, which the job
already computes for the downstream-trigger-map flag and whose comment
names this exact trap. Both lanes now agree, and the same-repo side
stops shipping a removed code path untested — until now such a change
skipped every build and reported e2e as not required. The build matrix
keeps reading the rename-detected list: it picks packages to rebuild,
and a path that no longer exists has nothing to build.

The contract file gains the agreement as a pin, and grows a header to
match what it now covers. The fork half is pinned on the docs-only
expression rather than on `previous_filename`, which also appears in the
file list a few lines down and would have kept the assertion satisfied
with the check deleted.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 01:11:30 +03:00
Aleksei Sviridkin 8e097488c2 docs(ci): name every writer of the "E2E Tests" status, and why it opens
The section that tells the next editor to keep exactly one run per head
SHA responsible for the status listed only two of the three jobs that
write it, and described the same-repo lane as posting once at the end.
Both stopped being true when the same-repo lane gained an opener. The
section also spent a paragraph on the concurrency invariants without
saying why an opener has to exist at all, which is the part a reader
would otherwise remove as redundant.

Say that each lane opens with `pending` and concludes, that a status
never expires so the opener is what stops a re-run inheriting the
previous run's green, and that `plan`'s opener is `continue-on-error`
because every build and test job hangs off that job.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:55:02 +03:00
Aleksei Sviridkin d3515509c8 fix(ci): let a status-API blip skip the opener instead of failing the run
The step that claims the required context is the first in `plan`, and
`checks`, `build`, `build-talos`, `finalize` and `e2e` all hang off that
job. A transient failure from the status API would therefore discard a
full build and suite for a call that is an optimisation, not a gate.

Mark it `continue-on-error`. Losing the opener degrades exactly to the
behaviour that preceded it — the head SHA keeps whatever status it had
until `e2e-report` concludes — while a run that dies before reporting
leaves the merge blocked either way. The terminal post keeps its own
error handling, because that one failing silently is what would let a
run finish with no verdict at all.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:54:29 +03:00
Aleksei Sviridkin 7fc568db4f test(ci): pin each status publisher by its call, not by a shared string
The case asserting that both lanes open the required status before they
conclude it counted occurrences of the context string per file and
required two. That measured nothing on the fork lane: the two
occurrences there are the shared `setStatus` helper and the terminal
report, while the opener calls the helper and contributes no occurrence
of its own. Deleting the opener outright left all six tests green, under
a comment promising that dropping either half would be caught — and the
window it guards is the wider of the two, since a privileged run holds
the head SHA for up to three hours.

Scope every assertion to the job that owns it and pin the call rather
than a string that happens to appear twice: `plan` opens with `pending`
under a same-repo guard, `e2e-report` concludes, `resolve` opens through
the helper, `report` concludes. Removing any one of the four now fails
this test.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:54:08 +03:00
Aleksei Sviridkin ee844c98a5 fix(ci): open the "E2E Tests" status when the same-repo run starts
Moving the gate from a job to a commit status lost a property the job
had for free. A commit status is latest-wins per (sha, context) and
never expires, so a head SHA that already went green keeps reading green
from the moment a new run starts until `e2e-report` posts at the end.
Re-running all jobs, reopening, or adding full-e2e therefore leaves the
pull request mergeable for the whole of a suite that may be about to
fail. While the gate was a job, GitHub created a fresh non-success check
run each time and the window did not exist.

Claim the context at the start of `plan`, the way the fork lane already
claims it in `resolve`, and let `e2e-report` conclude it as before. The
call is same-repo only: a fork's `pull_request` token is read-only, so
posting from there would 403 and fail the job on every fork PR — and
that read-only token is what makes the whole commit-status design safe.
Fork PRs get their opening status from the privileged workflow instead.

The contract test grows a case pinning that each lane writes the context
from two points, one of them pending. Both assertions on the same-repo
opener are scoped to the `plan` job: the bare fork guard occurs on
several steps in other jobs, so a whole-file "at least one" passed with
the opener's guard deleted, pinning unrelated guards and reporting that
as coverage.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:40:49 +03:00
Aleksei Sviridkin 8068619732 test(ci): state the scope note's reasoning as transitive, not direct
The header claims the third silent path is unreachable because every job
either carries the discarded-label guard or needs `plan`'s outputs. The
`e2e` job does neither literally: it gates on the results of `finalize`,
`build-talos` and `resolve_assets`, which are the jobs that read those
outputs. The conclusion survives — a skipped `plan` still skips it, one
hop further along — but transitivity is the part the claim rests on, and
a reader checking the stated reason against `e2e` finds it false and has
no way to tell whether the conclusion failed with it.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:23:18 +03:00
Aleksei Sviridkin 3722b97fb5 fix(ci): terminate the fork TIA file list so its last path is read
The privileged lane builds the changed-file list in JavaScript and hands
it to `hack/select-e2e.sh` through base64. `Array.join` puts a separator
between entries and none at the end, so the decoded file had no trailing
newline — and the selector reads it with `while IFS= read -r file`,
which POSIX defines as returning non-zero at EOF without a newline.
The loop body never runs for that last path, so it is silently dropped.

For a single-file pull request, the modal fork contribution, that is the
whole list: the selection comes back empty, the Chainsaw step is skipped
on it, the job concludes success and the required status is published
green with no suite executed. With more files the last one is dropped
instead, which under-selects — and it can also suppress an escalation,
since a path the selector does not recognise inside packages/ is exactly
what would have widened the run to the full suite.

Nothing in the log gave it away: the step prints the list with `sed`,
which renders an unterminated last line the same as a terminated one, so
the log showed a path the selector had never seen.

Terminate the list where it is built. The in-tree lane is unaffected and
always was — it feeds `git diff --name-only`, whose output ends with a
newline — which is why this is the first caller to meet the behaviour.
The read loop itself still drops an unterminated last line for any
future caller; fixing that belongs with the selector and its own test
surface, not here.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:23:01 +03:00
Aleksei Sviridkin c20d47a245 feat(kubernetes): let a node group log the guest serial console
A worker VM that stalls during boot before Talos apid answers is
invisible to every diagnostic the platform has: no Node registers,
no certificate request arrives, and talosctl needs the apid that
never came up. The guest serial console does show that window, but
KubeVirt attaches its guest-console-log container only when the VMI
asks for it, and the platform turns console logging off cluster-wide.

Add an opt-in per-node-group logSerialConsole that sets the KubeVirt
field, which takes precedence over the cluster-wide setting. The key
renders nothing unless true -- including when set to false -- because
the KubevirtMachineTemplate is content-hash named, so a byte emitted
for a group that did not ask for it renames the template and rolls
every live worker VM.

The description carries the two costs the field name does not show.
Changing the value rolls the group it is set on, so the stuck VM
whose console was wanted is replaced along with the rest and the
setting buys visibility into the next boot rather than the current
one. And the cluster-wide default it overrides exists because that
same container has been seen holding virt-launcher in
PodInitializing, which would take a group down rather than explain
it.

Worker VMs are emitted by two charts. Both carry the field, and the
golden-parity check gains a case that keeps their output identical.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:20:22 +03:00
Aleksei Sviridkin 7696d2b98c test(ci): record which silent path the concurrency pin does not cover
The contract asserts that the conclusions routed into a per-run group
match the ones `resolve` posts nothing for, which reads as covering
every case where a run stays silent. It does not. `resolve` also
returns silently when the triggering run succeeded but its `Plan build`
job skipped, and such a run sits in the shared group where it can still
cancel the live publisher.

That path is unreachable today, because every job in pull-requests.yaml
either carries the discarded-label guard or needs `plan`'s outputs, so a
skipped `plan` yields a skipped run and the conclusion check catches it
first. Adding a job that runs independently of `plan` would reopen it,
and the extraction here matches on the conclusion constant and would not
notice. Say so in the header rather than leaving the next reader to
infer coverage the pin does not provide.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:04:39 +03:00
Aleksei Sviridkin fd06fb32e0 docs(ci): say what fork TIA hardening buys, and what it does not
Four comments and a documentation bullet claimed that routing the
changed-file list through the base-repo API stops a fork from making
test selection pick nothing. It does not. The `e2e` job runs from the
fork's own tree, so `hack/select-e2e.sh` is the fork's script: a PR that
edits it to print nothing gets `skip=true`, the suite step never runs,
the job succeeds and the required status goes green — the exact outcome
the paragraph presented as prevented. The same holds for the suites
under `hack/e2e-chainsaw/`, the install bats and the testing package's
recipes, all of which the fork authors and this job executes.

Hardening the inputs is still worth doing, since a merge-ref diff would
be a second bypass on top of the one that exists, so the code does not
change. What changes is the claim: the inputs are trusted, the decision
is not, and the fork lane is a correctness gate against an honest
contributor's regression rather than a boundary against a hostile fork.
State plainly what does contain a hostile fork — the maintainer
approval required before any fork run executes, the absence of any
credential in that job, and a diff editing the harness being visible in
review.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:04:15 +03:00
Aleksei Sviridkin 6309eff784 fix(ci): paginate the fork PR association lookup
The check that the head SHA carries exactly one open pull request is a
check on list completeness, and it was reading a single unpaginated
page. GitHub returns 30 associations by default, so the one shape the
check could not see was the shape it exists to catch: a second open PR
sitting past the page boundary. Route the call through `github.paginate`
like the three other lookups in the same script, and read the result as
the array paginate returns rather than as a response envelope.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-08 00:03:51 +03:00
Aleksei Sviridkin 999aee5638 test(ci): count occurrences in the gate contract, not matching lines
Six assertions ran `grep -c` against a variable holding a single line,
so `-eq 1` only asked whether the pattern appeared at all. A key naming
the same field twice — two run-id interpolations, two branch
components — satisfied the pin exactly as the correct key does.
Demonstrated on the run-id assertion: duplicating the interpolation
leaves `grep -c` reporting 1 and passing, while counting occurrences
reports 2 and fails.

Use `grep -o … | wc -l` for the single-line pins. The two whole-file
`cancel-in-progress` counts stay on `grep -c`, where counting matching
lines is the intended question. This is the same correction made once
already in the sibling contract suite; the form did not survive being
copied.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 23:29:52 +03:00
Aleksei Sviridkin 9022d352c9 fix(ci): derive the fork image allowlist before loading the push token
Two comments described the privileged push job as running nothing but
fixed binaries over data, never `make`. The allowlist derivation added
later runs `make -n` once per image-building package — 32 on the current
tree — and it sat after the OCIR login, so the credential was live for
all of them. The property still held in substance, since those Makefiles
come from the base tree rather than the fork, but it was a narrower
property than the one written down, and the written one is what a future
reader would try to preserve.

Order the derivation ahead of the login. It needs nothing the login
provides, and above it the make expansion cannot see the push token at
all, which makes the stronger claim true rather than merely arguable.
Restate both comments to say what now holds: no fork-authored script in
the job at all, one base-tree `make -n` before the credential loads,
fixed binaries only once it has. Drop `yq` from the list of binaries the
push job runs — yq is used in the e2e job, not this one.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 23:29:01 +03:00
Aleksei Sviridkin 260ce359ac fix(ci): resolve the fork PR only when the head SHA has exactly one
A branch may have several open pull requests at once, against different
base branches, and they all sit on the same head commit. `resolve` took
the first open association and derived everything from it — the merge
ref it checks out, the docs-only verdict, the file list TIA is scoped
from, the full-e2e label — while writing the `E2E Tests` status to the
head SHA every one of those PRs shares. A verdict computed for one of
them therefore satisfies the others, and a PR whose own base sees code
can be greened by a sibling whose diff is entirely under docs/, with no
suite run. The `HEAD^2 == head_sha` assert further down cannot notice:
it holds for every PR on that commit by construction.

Require exactly one open association and fail closed otherwise, naming
the count in the status description so the state is diagnosable without
opening the run. The zero case, previously reported as "no open PR",
folds into the same check.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 23:28:01 +03:00