Skip to content

feat(site-router): add routed site-to-site IPsec gateway app (Phase 1) - #3426

Open
myasnikovdaniil wants to merge 14 commits into
mainfrom
feat/site-router
Open

feat(site-router): add routed site-to-site IPsec gateway app (Phase 1)#3426
myasnikovdaniil wants to merge 14 commits into
mainfrom
feat/site-router

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds site-router, a catalog app for routed (L3), source-IP-preserving tenant site-to-site connectivity over an IKEv2 IPsec tunnel terminated in a VyOS KubeVirt gateway VM. The chart materializes the gateway (VM + boot disk + tunnel LoadBalancer Service + credential Secrets + Cilium guards); a new site-router-controller mediates the pieces the chart cannot express — deny-set validation of the tunnel's remote networks, the kube-ovn return-route annotation, gateway-port port_security relaxation gated on the guest source filter, the live VyOS configuration push over the management API, tunnel/BGP observability, and status. No new tenant CRD: the apps.cozystack.io/SiteRouter app instance is the whole contract.

This is a port + productization of the reference implementation's VyOS router into the open-source monorepo, subset to the routed feature set and aligned to the catalog-app model. NAT/DNAT (a future site-gateway) and HA/VRRP are deliberately out of scope.

What's included

  • App chart packages/apps/site-router — gateway VirtualMachine (512/4096 blockSize for DRBD), boot DataVolume, tunnel Service type: LoadBalancer (UDP 500/4500, native loadBalancerClass), PSK + RBAC-isolated api-key Secrets, first-boot cloud-init, WorkloadMonitor, and two net-new Cilium policies (gateway egressDeny + gateway ingress).
  • site-router-controller (internal/controller/siterouter, cmd/site-router-controller) wired into the platform — watches SiteRouter HelmReleases + gateway pods, runs the ordered mediation pipeline, finalizer-restores state on delete.
  • VyOS core library internal/vyos (client/parse/observation) + routed render internal/vyos/render (interfaces, management firewall, IPsec forced-UDP, static routes, BGP, MSS clamp, tunnel-ingress source filter, forward default-deny, Boundary-A drop).
  • Shared pure internal/siterouter/denyset validator + a SiteRouter-scoped admission check (pkg/registry/apps/application) that reject a cluster-overlapping remoteCIDR identically at apply time and reconcile time.
  • Docs: README.md (prose + generated params), docs/security-model.md, docs/image-lifecycle.md, docs/followups.md.
  • Tests: helm-unittest chart render (9 suites) + Go unit tests (render subset/security/net-new, deny-set, CNI mediation, status mapping, VyOS push, admission).

Phase-1 acceptance checklist (honest status)

Status legend: done = implemented and unit-tested in this PR; deferred-to-empirical = implemented but its live proof needs a booted gateway (blocked on the published golden image + the e2e run); follow-up = tracked, out of Phase-1 scope (docs/followups.md).

# Acceptance item Status
1 Tenant deploys from the catalog; VyOS gateway VM boots (512-native on DRBD), dual-homed on the tenant pod network done (chart render + blockSize, unit-tested) / deferred-to-empirical (live boot needs the published image)
2 IKEv2 IPsec tunnel over forced ESP-in-UDP, MSS clamp by default done (render + unit tests: forced encapsulation unconditional, clamp 1320→1280) / deferred-to-empirical (live establishment)
3 Decrypted traffic L3-forwarded, source IP preserved; whole-subnet + ICMP for TCP/UDP/ICMP/SCTP done (local selector 0.0.0.0/0 + forward filter) / deferred-to-empirical (live source-preservation proof)
4 Tunnel endpoint via native Service type: LoadBalancer (UDP) with cluster loadBalancerClass done (unit-tested) / deferred-to-empirical (live LB assignment)
5 Controller mediation: deny-set, namespace routes, gateway-only port_security relax, source filter, status, delete-restore done (unit-tested; the kube-ovn v1.15.10 live-toggle no-op was confirmed, so the relaxation is baked at pod creation and D8 is a status/progression gate — documented in security-model.md)
6 Security guards: source allow-list, Cilium egressDeny (169.254 + mgmt), forward default-deny, two-boundary API isolation, api-key not tenant-readable done (unit-tested; VyOS 1.5 firewall syntax validated live) / follow-up (Boundary-B additive-ingress residual)
7 Tunnel-state observability (SA up/down, rekey, counters) done (up/down + BGP gauges) / follow-up (byte + rekey counters need a guest-command + parser change)
8 Negative-security acceptance suite passes (undeclared source / other tenant / node / API / metadata / mgmt-API / world dropped; declared source → tenant dest passes, source preserved) deferred-to-empirical (the e2e gate; blocked on the published golden image for a live run)
9 helm-unittest + Go unit + Chainsaw e2e (two-VM) green in CI done (helm-unittest + Go unit) / deferred-to-empirical (Chainsaw e2e suite committed but deferred from CI until the VyOS golden image ships; live two-VM run)

The negative-security suite (item 8) is the Phase-1 acceptance gate and is authored as a Chainsaw e2e; its live run against a real two-VM topology is blocked on the published cozystack-owned VyOS golden image (see follow-ups). The VyOS-version-specific firewall leaf syntax has been validated live against the shipped image (the firewall ipv4 … family and the ipsec match-ipsec-in/match-none-in matchers) and is kept behind single-point helpers so a future image whose syntax differs is a one-place change; what the e2e still adds is the runtime proof that the guarded packets are actually dropped.

E2E suite: un-parking is queued behind #3817

The image blocker behind items 8 and 9 above is gone. Build VyOS runs on this PR, publishes the appliance and stamps the real ref@digest into packages/apps/site-router/images/vyos-router-disk.tag through a patch fragment that Finalize merges before the E2E job, so the committed v0.0.0 placeholder is never what the sandbox pulls, and _probe-lib.sh is the live implementation rather than a set of seams. Renaming chainsaw-test.yaml.disabled back is the whole of the un-park, since the selector enumerates a suite by its chainsaw-test.yaml.

It is held out of this PR deliberately. Un-parking makes site-router the first suite in the tree to sit downstream of cozystack.cozystack-basics, which reaches no suite today and therefore escalates to the full run. The selector counts reaching one suite as covered, so on the un-parked tree the platform's namespace-and-policy package drops from 22 suites to site-router alone, and hack/select-e2e_test.bats goes red on the guard that pins exactly that. #3817 fixes it by excluding basics from the reverse-dependency walk as an install-ordering hub, the way cozystack.cozystack-engine already is. The un-park lands here once that merges; merging this one first would put the coverage regression on main.

Follow-ups (drafts — to be filed by the maintainer)

Full detail in packages/apps/site-router/docs/followups.md. Consolidated list:

  • Publish the cozystack-owned VyOS golden image (unblocks the default install + the e2e/empirical run).
  • Reproducible in-repo VyOS build (pin snapshot, vyos-build, publish like the Talos image; then pin URL + @sha256).
  • Negative-security e2e runtime proof (the VyOS 1.5 syntax is validated live; the e2e still proves at runtime that an undeclared-source packet and a valid-source/world-destination packet are both dropped).
  • Scoped port_security (pending upstream kube-ovn CIDR-AAP support).
  • Tenant-baseline Cilium exclusion for the gateway (Boundary-B hardening).
  • Controller-namespace API key + post-boot rotation.
  • Tunnel byte / rekey counter metrics (guest-command + parser change).
  • _cluster.pod-cidr derivation for managementCIDR (custom-pod-CIDR clusters without manual config).
  • IPsec local-address / LB tunnel-address wiring.
  • Portal / dashboard image + cloud-init lock-step (external-repo hand-off).
  • Phase 2 site-gateway (NAT) / Phase 3 WireGuard backend / Phase 4 HA + per-tenant egress IP + initiator model.

Repo hygiene (pre-merge)

  • The feat(site-router): title auto-applies kind/feature. To auto-apply area/networking (rather than falling to area/uncategorized), add 'site-router': 'area/networking' to the scope mapping in .github/workflows/pr-labeler.yaml (alongside the existing vpn/gateway/kube-ovn entries) — area/networking already exists in .github/labels.yml.
  • Add a changelog entry for the release that ships site-router.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added the Site Router application for routed site-to-site connectivity over IPsec (static routes + optional BGP) with a VM-based VyOS gateway.
    • Introduced a full controller mediation flow: fail-closed management access, namespace route programming, pending-route event surfacing, and per-peer tunnel/BGP state metrics.
    • Enforced safe remoteCIDR configuration via deny-set validation during application admission.
    • Added a new SiteRouter configuration CRD schema and supporting types.
  • Documentation
    • Added Site Router README plus security model, image lifecycle, and phase/follow-up docs.
  • Tests
    • Added admission, controller, metrics, VyOS render/parse, chart/template, and deferred e2e suite scaffolding.
  • Chores
    • Updated CI/build gating and the VyOS dedicated image build pipeline; extended build matrix selection.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files labels Jul 22, 2026
@dosubot dosubot Bot added the area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) label Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds the SiteRouter API, Helm charts, VyOS image pipeline and renderer, controller reconciliation with metrics and security mediation, deny-set admission validation, platform packaging, and a deferred live Chainsaw acceptance suite.

Changes

SiteRouter contracts and packaging

Layer / File(s) Summary
API and application packaging
api/apps/v1alpha1/siterouter/*, packages/apps/site-router/*, packages/system/site-router-rd/*
Defines SiteRouter configuration types, Helm values and templates, tenant resources, application metadata, and generated deepcopy methods.
Deny-set admission validation
internal/siterouter/denyset/*, pkg/registry/apps/application/*, pkg/apis/apps/validation/validation.go
Rejects malformed, unsupported, reserved, or cluster-overlapping remoteCIDRs during admission and reconciliation.
Platform package wiring
packages/core/platform/*, packages/system/cozystack-api/templates/rbac.yaml
Adds SiteRouter package sources and conditionally emits them with the IaaS-enabled NaaS bundle.
VyOS image pipeline
packages/system/vyos-router-image/*, packages/system/vm-default-images/*, hack/build-matrix*, Makefile
Builds a pinned VyOS qcow2/containerDisk image, publishes its digest tag, registers the golden image, and adds the controller image build.
CI and deferred E2E selection
.github/workflows/pull-requests.yaml, hack/select-e2e*
Adds VyOS-specific CI gating and excludes the deferred site-router suite from automatic E2E selection.

Controller and VyOS runtime

Layer / File(s) Summary
VyOS client and rendering
internal/vyos/*
Adds the VyOS HTTP client, observation parsers, deterministic configuration renderer, firewall rules, IPsec/BGP/static-route operations, and renderer tests.
Controller reconciliation and state
internal/controller/siterouter/*
Adds SiteRouter reconciliation, route annotation mediation, configuration hashing and confirmation, source-filter gating, metrics, status events, cleanup, and controller tests.
Controller deployment
cmd/site-router-controller/main.go, packages/system/site-router-controller/*
Wires controller-runtime setup, management CIDR validation, metrics and health endpoints, RBAC, deployment security settings, image packaging, and scraping.

Deferred acceptance coverage

Layer / File(s) Summary
Live SiteRouter Chainsaw suite
hack/e2e-chainsaw/site-router/*
Defines the two-site topology, route propagation checks, tunnel and MSS validation, negative-security attribution stubs, and teardown assertions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: kind/api-change, area/api, area/virtualization

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding the routed site-to-site IPsec gateway application in Phase 1.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/site-router

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (5)
internal/controller/siterouter/status.go (1)

65-68: 🚀 Performance & Scalability | 🔵 Trivial

Uncached full-namespace pod List on every 30s poll can pressure the apiserver at scale.

surfacePendingRoutePods runs on every reconcile (runtimePollInterval), and this List goes straight to the apiserver (uncached). In a busy tenant namespace with many pods and/or several SiteRouter instances, this is a recurring unindexed list against the API server. Consider gating it (e.g. only re-list when the programmed route set actually changed, or back off the surfacing cadence relative to the runtime poll) to bound the load.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/siterouter/status.go` around lines 65 - 68, Reduce
repeated API-server load from the pod List in surfacePendingRoutePods by gating
or backing off the namespace relist instead of executing it on every
runtimePollInterval reconcile. Prefer relisting only when the programmed route
set changes, or otherwise enforce a slower bounded surfacing cadence, while
preserving pending-route detection and existing error handling.
packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile (1)

10-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer COPY over ADD for local files.

ADD is only needed for its URL-fetch/archive-auto-extraction behavior; a local qcow2 copy doesn't use either. COPY is the idiomatic, unambiguous choice here.

🐛 Proposed fix
-ADD _out/assets/vyos-router-amd64.qcow2 /disk/vyos-router.qcow2
+COPY _out/assets/vyos-router-amd64.qcow2 /disk/vyos-router.qcow2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile` around
lines 10 - 11, Replace the ADD instruction in the Dockerfile with COPY for the
local vyos-router-amd64.qcow2 file, preserving the existing source and
destination paths.

Source: Linters/SAST tools

hack/e2e-chainsaw/site-router/chainsaw-test.yaml (1)

557-566: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use bracket notation for the annotation key in this JSONPath filter. It’s the safer form for dotted annotation keys in kubectl JSONPath predicates, and this final check should avoid escaping quirks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml` around lines 557 - 566,
Update the JSONPath predicate in the gateway pod query to access the
ovn.kubernetes.io/port_security annotation using bracket notation instead of
escaped dotted-key notation. Keep the existing filtering for annotations equal
to "false" and the surrounding validation unchanged.
internal/vyos/client.go (1)

97-113: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

WithInsecureSkipVerify clones the wrong base transport and skips MinVersion.

Two issues in this option:

  1. It clones http.DefaultTransport unconditionally instead of the client's current c.http.Transport. If a caller combines WithHTTPClient (supplying a custom *http.Client/Transport, e.g. for a proxy) with WithInsecureSkipVerify, this silently discards that custom transport's settings and mutates the caller-supplied http.Client object's Transport field in place — a surprising side effect for API consumers who don't expect option ordering to matter this much.
  2. Both tls.Config{InsecureSkipVerify: true} literals omit MinVersion. Even given the documented in-band-token rationale for skipping cert verification, pinning MinVersion: tls.VersionTLS12 (or higher) is still cheap defense-in-depth against protocol downgrade.
🔧 Suggested fix
 func WithInsecureSkipVerify() Option {
 	return func(c *Client) {
-		if base, ok := http.DefaultTransport.(*http.Transport); ok {
+		base, ok := c.http.Transport.(*http.Transport)
+		if !ok {
+			base, ok = http.DefaultTransport.(*http.Transport)
+		}
+		if ok {
 			clone := base.Clone()
-			clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
+			clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
 			c.http.Transport = clone

 			return
 		}

-		// Fallback when callers have replaced http.DefaultTransport with
-		// something that is not an *http.Transport.
 		c.http.Transport = &http.Transport{
-			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/vyos/client.go` around lines 97 - 113, Update WithInsecureSkipVerify
to derive the transport from the client’s current c.http.Transport, preserving
custom transport settings supplied through WithHTTPClient and avoiding mutation
of the caller’s client transport. Apply TLS configuration with
InsecureSkipVerify and MinVersion set to tls.VersionTLS12 in both the cloned and
fallback transport paths.

Source: Linters/SAST tools

packages/system/cozystack-api/templates/rbac.yaml (1)

9-17: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

ClusterRole grant is not namespace-scoped.

resourceNames: ["cozystack"] restricts by name but this is a ClusterRole, so the grant applies to any configmaps/cozystack in any namespace, not only cozy-system/cozystack (the only one rest_siterouter.go reads). Low risk given a colliding ConfigMap name is unlikely, but worth noting as a least-privilege gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/cozystack-api/templates/rbac.yaml` around lines 9 - 17,
Restrict the RBAC permission for the ConfigMap named cozystack to the
cozy-system namespace instead of granting it cluster-wide through the
ClusterRole. Update the surrounding rbac.yaml binding or role structure while
preserving the existing get-only, name-scoped access required by SiteRouter
admission.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/site-router-controller/main.go`:
- Around line 66-70: Update the shared ValidateManagementCIDR validator to
reject parsed networks whose IP does not have To4() != nil, while preserving
valid IPv4 CIDR handling and existing validation errors. Ensure the
managementCIDR flag path uses this validator so IPv6 values cannot start the
controller.

In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml`:
- Around line 425-494: Validate that GW_IP, BACKEND_IP, NODE_IP, API_CLUSTERIP,
and OTHER_POD_IP are all non-empty immediately after resolution and before any
assert_dropped or probe_from_b_stdout calls. Fail the negative-security step
with a clear error identifying every missing target, while preserving the
existing probe and attribution checks when all targets resolve.

In `@hack/select-e2e.sh`:
- Around line 37-40: Update the TODO comment in the site-router enablement
instructions to reference the active VyOS entry in
packages/system/vm-default-images/values.yaml and instruct CI to stamp
packages/system/vm-default-images/images/vyos-router-disk.tag, removing the
incorrect packages/system/vm-images and uncomment guidance.

In `@internal/vyos/render/render.go`:
- Around line 824-875: Update renderIPSec and the peer-name generation around
sanitisePeerName so names are tracked for the current tunnel batch and duplicate
sanitized descriptions receive a deterministic unique suffix, such as their
tunnel index. Use the disambiguated name consistently for both the site-to-site
peer and authentication PSK paths, while preserving existing names for
non-colliding descriptions and fallback behavior for empty descriptions.

In `@packages/apps/site-router/docs/PR-BODY.md`:
- Around line 45-47: Update the follow-up list in
packages/apps/site-router/docs/PR-BODY.md around lines 45-47 by removing the
landed reproducible VyOS build item and retaining only still-pending publishing,
digest stamping, or empirical validation work. Update
packages/apps/site-router/docs/image-lifecycle.md around line 3 to describe the
VyOS image entry as enabled and digest-pinned, replacing the outdated
“commented-out” wording.

In `@packages/apps/site-router/docs/security-model.md`:
- Around line 33-40: Update the Boundary A description to state that the
IPsec-decrypted local-input drop is emitted only when managementCIDR enforcement
is active, and explicitly qualify the open-management test path where the rule
is absent. Keep the existing explanation of tunnel coverage and the separate
Boundary B behavior unchanged.

In `@packages/apps/site-router/templates/_helpers.tpl`:
- Around line 109-120: Update the managementCIDR validation in
site-router.assertSafeVyOSInputs to perform semantic IPv4 CIDR and prefix-range
validation, not only regex shape checking. Reuse an available real IPv4-prefix
parser during template admission/rendering, and fail with the existing refusal
behavior when parsing fails before managementCIDR reaches the VyOS
configuration.

In `@packages/apps/site-router/tests/secret_cloudinit_test.yaml`:
- Around line 208-263: Update the Boundary A security-model documentation to
state that the guest guard seeds are applied only when managementCIDR
enforcement is enabled, not regardless of managementCIDR. Align the description
with the T08 tests and preserve the documented exception that
allowOpenManagement=true seeds no guest firewall guards.

In `@packages/apps/site-router/values.yaml`:
- Around line 98-100: Update the managementCIDR validation in values.yaml and
its admission/render validation to reject out-of-range IPv4 octets and prefixes
outside /0–/32, not merely unsafe characters. Preserve support for an empty
value when allowOpenManagement=true and the documented default CIDR, then add
regression cases covering malformed octets and prefix lengths.
- Around line 74-81: Enforce a minimum of one CPU core for resources.cpu in the
values schema/API definition, rejecting zero and negative values while retaining
whole-core validation. Regenerate the derived schema and API types, then extend
the validation tests to cover both zero and negative CPU values. Anchor the
changes to the resources.cpu definition and its existing validation tests.

In `@packages/system/site-router-controller/templates/rbac.yaml`:
- Around line 27-39: Restrict the RBAC permissions represented by the
ClusterRole rather than granting cluster-wide Secret and ConfigMap list/watch
access. Replace broad resources under the unnamed core API rules with
namespace-scoped bindings or narrowly targeted named-object access for the
specific per-instance Secrets and the cozy-system/cozystack ConfigMap required
by the controller, while preserving only the necessary read operations.
- Around line 16-24: Constrain the RBAC rules in rbac.yaml so the controller
does not receive cluster-wide patch access to arbitrary Pods or Namespaces.
Replace the broad patch permissions for the resources listed in the rules with
the narrowest supported authorization scope, and preserve only the read verbs
needed for discovery; if Kubernetes RBAC cannot express the required
single-resource or label restriction, remove patch from these cluster-wide rules
and handle the targeted updates through an appropriately scoped mechanism.

In `@packages/system/site-router-controller/values.yaml`:
- Around line 7-16: The values configuration currently hard-codes the kube-ovn
default in managementCidr. Update managementCidr to derive from the cluster’s
networking.podCIDR, or make it unset and require an explicit override for
non-default Pod CIDRs, while preserving the required match with the controller
--management-cidr and app chart managementCIDR values.

---

Nitpick comments:
In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml`:
- Around line 557-566: Update the JSONPath predicate in the gateway pod query to
access the ovn.kubernetes.io/port_security annotation using bracket notation
instead of escaped dotted-key notation. Keep the existing filtering for
annotations equal to "false" and the surrounding validation unchanged.

In `@internal/controller/siterouter/status.go`:
- Around line 65-68: Reduce repeated API-server load from the pod List in
surfacePendingRoutePods by gating or backing off the namespace relist instead of
executing it on every runtimePollInterval reconcile. Prefer relisting only when
the programmed route set changes, or otherwise enforce a slower bounded
surfacing cadence, while preserving pending-route detection and existing error
handling.

In `@internal/vyos/client.go`:
- Around line 97-113: Update WithInsecureSkipVerify to derive the transport from
the client’s current c.http.Transport, preserving custom transport settings
supplied through WithHTTPClient and avoiding mutation of the caller’s client
transport. Apply TLS configuration with InsecureSkipVerify and MinVersion set to
tls.VersionTLS12 in both the cloned and fallback transport paths.

In `@packages/system/cozystack-api/templates/rbac.yaml`:
- Around line 9-17: Restrict the RBAC permission for the ConfigMap named
cozystack to the cozy-system namespace instead of granting it cluster-wide
through the ClusterRole. Update the surrounding rbac.yaml binding or role
structure while preserving the existing get-only, name-scoped access required by
SiteRouter admission.

In `@packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile`:
- Around line 10-11: Replace the ADD instruction in the Dockerfile with COPY for
the local vyos-router-amd64.qcow2 file, preserving the existing source and
destination paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8c8c7016-ab40-47eb-bbaa-21e2ba4539b7

📥 Commits

Reviewing files that changed from the base of the PR and between 706c3f0 and d5375fb.

⛔ Files ignored due to path filters (1)
  • packages/apps/site-router/logos/site-router.svg is excluded by !**/*.svg
📒 Files selected for processing (97)
  • .github/workflows/pull-requests.yaml
  • Makefile
  • api/apps/v1alpha1/siterouter/types.go
  • api/apps/v1alpha1/siterouter/zz_generated.deepcopy.go
  • cmd/site-router-controller/main.go
  • hack/build-matrix.sh
  • hack/build-matrix_test.bats
  • hack/e2e-chainsaw/site-router/_probe-lib.sh
  • hack/e2e-chainsaw/site-router/backend.yaml
  • hack/e2e-chainsaw/site-router/chainsaw-test.yaml
  • hack/e2e-chainsaw/site-router/fresh-route-pod.yaml
  • hack/e2e-chainsaw/site-router/pending-route-pod.yaml
  • hack/e2e-chainsaw/site-router/remote-site-b.yaml
  • hack/e2e-chainsaw/site-router/site-router-a.yaml
  • hack/select-e2e.sh
  • hack/select-e2e_test.bats
  • internal/controller/siterouter/cnimediation.go
  • internal/controller/siterouter/cnimediation_test.go
  • internal/controller/siterouter/metrics.go
  • internal/controller/siterouter/metrics_test.go
  • internal/controller/siterouter/reconciler.go
  • internal/controller/siterouter/reconciler_test.go
  • internal/controller/siterouter/status.go
  • internal/controller/siterouter/status_test.go
  • internal/controller/siterouter/vyospush.go
  • internal/controller/siterouter/vyospush_test.go
  • internal/siterouter/denyset/denyset.go
  • internal/siterouter/denyset/denyset_test.go
  • internal/vyos/client.go
  • internal/vyos/client_test.go
  • internal/vyos/observation.go
  • internal/vyos/parse.go
  • internal/vyos/parse_test.go
  • internal/vyos/render/render.go
  • internal/vyos/render/render_netnew_test.go
  • internal/vyos/render/render_security_test.go
  • internal/vyos/render/render_test.go
  • packages/apps/site-router/.helmignore
  • packages/apps/site-router/Chart.yaml
  • packages/apps/site-router/Makefile
  • packages/apps/site-router/README.md
  • packages/apps/site-router/charts/cozy-lib
  • packages/apps/site-router/docs/PR-BODY.md
  • packages/apps/site-router/docs/followups.md
  • packages/apps/site-router/docs/image-lifecycle.md
  • packages/apps/site-router/docs/security-model.md
  • packages/apps/site-router/templates/_helpers.tpl
  • packages/apps/site-router/templates/dashboard-resourcemap.yaml
  • packages/apps/site-router/templates/dv.yaml
  • packages/apps/site-router/templates/networkpolicy.yaml
  • packages/apps/site-router/templates/secret-cloudinit.yaml
  • packages/apps/site-router/templates/secret-psk.yaml
  • packages/apps/site-router/templates/service.yaml
  • packages/apps/site-router/templates/vm.yaml
  • packages/apps/site-router/templates/workloadmonitor.yaml
  • packages/apps/site-router/tests/dv_test.yaml
  • packages/apps/site-router/tests/networkpolicy_test.yaml
  • packages/apps/site-router/tests/policy_workloadmonitor_todo_test.yaml
  • packages/apps/site-router/tests/secret_cloudinit_test.yaml
  • packages/apps/site-router/tests/secret_psk_test.yaml
  • packages/apps/site-router/tests/service_test.yaml
  • packages/apps/site-router/tests/values_matrix_test.yaml
  • packages/apps/site-router/tests/vm_test.yaml
  • packages/apps/site-router/tests/workloadmonitor_test.yaml
  • packages/apps/site-router/values.schema.json
  • packages/apps/site-router/values.yaml
  • packages/core/platform/sources/site-router-application.yaml
  • packages/core/platform/sources/site-router-controller.yaml
  • packages/core/platform/templates/bundles/naas.yaml
  • packages/core/platform/tests/bundles_site_router_naas_test.yaml
  • packages/system/cozystack-api/templates/rbac.yaml
  • packages/system/site-router-controller/Chart.yaml
  • packages/system/site-router-controller/Makefile
  • packages/system/site-router-controller/images/site-router-controller/Dockerfile
  • packages/system/site-router-controller/templates/deployment.yaml
  • packages/system/site-router-controller/templates/podscrape.yaml
  • packages/system/site-router-controller/templates/rbac-bind.yaml
  • packages/system/site-router-controller/templates/rbac.yaml
  • packages/system/site-router-controller/templates/sa.yaml
  • packages/system/site-router-controller/values.yaml
  • packages/system/site-router-rd/Chart.yaml
  • packages/system/site-router-rd/Makefile
  • packages/system/site-router-rd/cozyrds/site-router.yaml
  • packages/system/site-router-rd/templates/cozyrd.yaml
  • packages/system/site-router-rd/values.yaml
  • packages/system/vm-default-images/images/vyos-router-disk.tag
  • packages/system/vm-default-images/templates/dv.yaml
  • packages/system/vm-default-images/values.yaml
  • packages/system/vyos-router-image/Chart.yaml
  • packages/system/vyos-router-image/Makefile
  • packages/system/vyos-router-image/flavors/vyos-router.toml
  • packages/system/vyos-router-image/hack/build-qcow2.sh
  • packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile
  • pkg/apis/apps/validation/validation.go
  • pkg/registry/apps/application/rest.go
  • pkg/registry/apps/application/rest_siterouter.go
  • pkg/registry/apps/application/rest_siterouter_admission_test.go

Comment thread cmd/site-router-controller/main.go
Comment thread hack/e2e-chainsaw/site-router/chainsaw-test.yaml
Comment thread hack/select-e2e.sh Outdated
Comment thread internal/vyos/render/render.go
Comment thread packages/apps/site-router/docs/PR-BODY.md Outdated
Comment thread packages/apps/site-router/values.yaml
Comment thread packages/apps/site-router/values.yaml
Comment thread packages/system/site-router-controller/templates/rbac.yaml Outdated
Comment thread packages/system/site-router-controller/templates/rbac.yaml Outdated
Comment thread packages/system/site-router-controller/values.yaml

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: NOT LGTM

Large, well-executed Phase-1 feature, but it is enabled in the default presets (isp-full / isp-full-generic) and registered in the NaaS catalog, while its only supported load path cannot succeed on any cluster today (the golden VyOS image is not published and the digest is a zero placeholder). The failure surfaces as a silently stuck DataVolume with no legible status. The key negative-security acceptance suite that would prove the isolation model is deferred and never runs in CI.

What the PR does

Adds a catalog app site-router: routed (L3) site-to-site connectivity between tenants over an IKEv2 IPsec tunnel terminated in a VyOS gateway (KubeVirt VM). The chart materializes the VM + boot disk + tunnel Service type: LoadBalancer + Secrets (PSK, api-key) + Cilium policies. A new site-router-controller does what the chart cannot express: deny-set validation of remote networks, kube-ovn return-route annotation, relaxation of port_security on the gateway port, live config push to VyOS over its management API, tunnel/BGP observability and status. Scope: +14239/-17, 100 files.

Blocking findings

[MAJOR] Feature is enabled by default without a working default load path
packages/core/platform/values-isp-full.yaml:11, values-isp-full-generic.yaml:11, templates/bundles/naas.yaml:17-20, packages/apps/site-router/templates/dv.yaml:21-23. On a standard install the AppDefinition is registered in the NaaS catalog and the controller starts, but the default instance (image.enabled=false) clones a non-existent PVC cozy-public/vm-default-images-vyos-router (the golden image is an unpublished follow-up). A catalog tenant gets a DataVolume stuck in Pending, a VM that never boots, and no Ready=False / Event. Suggested fix: do not include site-router in the presets and do not register it in the catalog until the golden image is published and vyos-router-disk.tag is stamped with a real digest; also emit a legible status on the missing-source path.

[MAJOR] Non-resolvable image reference (zero-placeholder digest)
packages/system/vm-default-images/images/vyos-router-disk.tag:1 = ...@sha256:0000...0000. dv.yaml is an unconditional {{- range .Values.images }}, so the added vyos-router entry renders a DataVolume importing docker://…@sha256:0000…, which never resolves. The package is opt-in, but on every cluster that enables it an upgrade adds a permanently failing CDI importer and a stuck 12Gi PVC. Suggested fix: skip entries whose digest is the placeholder until a real one is stamped.

Minor findings

[MINOR] internal/controller/siterouter/reconciler.go:398-421,662-669 — a deny-set failure on the reconcile path is invisible, and the docstring over-promises. validateRemoteCIDRs returns a hard error and classify does return ctrl.Result{}, err (669) before updateStatus (332): no Event, no condition, although the docstring (400-402) claims "status surfaces it machine-readably". On a CIDR reconfigure (or if admission is bypassed) the HR stays Ready=True while routes are silently not programmed. Minor because the primary tenant path is synchronous fail-closed admission. Fix: emit a Warning Event (as vyospush.go:329 does) and correct the docstring.

[MINOR] packages/apps/site-router/values.schema.json (bgp.required=["enabled"]) + internal/controller/siterouter/vyospush_test.go:786bgp.enabled=true without localASN is a silent no-op. The schema only requires enabled, though the description says localASN is required. The controller silently skips BGP with no Event/condition. Fix: make localASN required-when-enabled, or surface Ready=False / Event.

Discrepancies with the PR claims

  • [PARTIAL] PR body item 8 "Negative-security acceptance suite passes": the suite is committed, but hack/select-e2e.sh:42 sets DEFERRED_SUITES="site-router" and strip_deferred removes it from all CI paths, so it never runs. The security guards (Cilium egressDeny, forward default-deny, source filter, two-boundary API isolation) have zero executed evidence of a packet drop, only render-shape unit tests.
  • [OK, verified] "api-key not tenant-readable": confirmed. The api-key Secret is absent from both the AppDefinition secrets.include and the dashboard-resourcemap.yaml Role; only the PSK (tenant's own key) and the tunnel Service are exposed to use.

Main operational risk

The isolation security model is not proven on the data plane. templates/vm.yaml:28 permanently bakes ovn.kubernetes.io/port_security: "false" onto the gateway pod (the controller only verifies, it does not re-enforce: reconciler.go:502), with an acknowledged boot window where the port is relaxed before the VyOS firewall is stamped. The only compensating control is the in-guest VyOS firewall, whose drop behavior is exactly what the deferred negative-security e2e would prove, and that suite does not run in CI. Cross-tenant reach is bounded by Cilium identity (hence not Critical), but the headline isolation claim rests on render-shape unit tests rather than executed evidence.

Verified, non-blocking notes

  • charts-direct-edit on packages/apps/site-router/charts/cozy-lib is a false positive: it is a symlink (git mode 120000), same as apps/vpn / apps/kafka. Not a vendored edit.
  • The 3 chart_lint.render_errors are harness artifacts (site-router renders cleanly under tenant-*; platform OCIRepository is a lookup dependency; the cozystack-api _cluster nil is a runtime cozystack-values injection).
  • The deny-set (denyset.go:84-89) intentionally leaves NodeCIDRs / LBPools empty, so overlap with the node subnet / LB pool is not rejected, but the effect is self-inflicted (tenant's own routing), not cross-tenant.
  • VyOS management uses InsecureSkipVerify (internal/vyos/client.go:97), bounded by --management-cidr + a per-instance token.
  • The upgrade adds a cluster-wide read-only grant (secrets / configmaps / services) to the controller SA, judged acceptable least-privilege (read-only, no exec / secret writes).
  • Watch / pod-discovery wiring is correct (the lineage webhook stamps apps.cozystack.io/application.* on the virt-launcher pod), admission is fail-closed on Create+Update, leader election is on (--leader-elect, replicas:1), securityContext is hardened, cozyrds resourceNames match the Role, no reconcile amplifier, go build/vet/test green, deny-set / admission tests are non-vacuous (checked by mutation).

Recommended follow-up

Run an end-to-end test on a disposable dev cluster: (1) whether the zero-digest DataVolume wedges the vm-default-images HelmRelease on an opt-in upgrade or just retries in the background; (2) whether the default site-router instance without the golden image gives any legible status; (3) wire up and run the deferred negative-security Chainsaw suite to get real evidence of a packet drop.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters Both MAJORs were real and are fixed, though the first one's root cause turned out to be deeper than the gating change you suggested. Range f0619b4a3..10cb5f302.

The default load path could not work whether or not the image was published. vm-default-images is registered through cozystack.platform.package.optional.default, so the golden PVC was never guaranteed on any install, and f0619b4a3 had already dropped the dependsOn to escape an install-time deadlock — leaving nothing guaranteeing the clone source. Not registering the app until publication would have hidden that rather than fixed it.

The boot disk now imports the digest-pinned appliance containerDisk straight from the registry, per instance, with the reference living in the app chart it belongs to. Worth being straight about the path there, since the history shows it: I first moved the golden into a dedicated package shipped with the app, and reviewing that found three defects in it. CDI populates a DataVolume only at creation, so a shared golden could never be advanced — a new digest would either fail the upgrade on an immutable field or be silently ignored, leaving later gateways booting the previous appliance while its cloud-init contract had moved on. Its hard dependsOn reintroduced exactly the deadlock class f0619b4a3 escaped, because bundles.disabledPackages is per-name and an operator could strand the app by disabling only the golden. And the namespaced Role it needed in cozy-public had no ordering guarantee, since site-router-controller's dependency closure never reaches cozystack-basics. Importing per instance removes all three: nothing shared to go stale, no package to disable, no cross-chart name contract. The cost is one 12Gi import per gateway instead of a shared clone, which is the right trade for an appliance a tenant runs one or two of.

On the placeholder digest — a real one is a merge prerequisite, and that is now mechanical rather than remembered: hack/image-refs-no-placeholder.bats fails the build on any all-zero digest anywhere in packages/, and is red right now on purpose. Worth recording why the shape is dangerous: it passes required and every schema check, so the failure moves from render time to a stuck import at runtime, and nightly-mirror.sh skips it silently because its ownership filter matches the build-registry host while a not-yet-published ref still carries the public one.

Both MINORs are fixed. The deny-set rejection records a Warning Event and the docstring no longer claims status surfaces it, since it returns a hard error and classify stops the pipeline before updateStatus runs. bgp.enabled without localASN records a Warning too — localASN is optional in the schema, so that document is valid and silently rendered no BGP at all.

Your "legible status on the missing-source path" is addressed, but not the way I first tried. I added a controller-side BootImageUnavailable Event and then found it unreachable in the only scenario it existed for: no image means no gateway pod, so pushVyOSConfig returns GatewayPending and classify requeues before updateStatus — which is what called it. It also never set Ready=False as I had written, since this package makes no condition write at all (D9 keeps status off the app CR). Rather than ship dead code I removed it: with a per-instance import there is no missing-source state, and an unresolvable digest now surfaces as ImportFailed on the tenant's own boot DataVolume, in their namespace, where they can actually see it.

On item 8 — the status column did read deferred-to-empirical and item 9 disclosed the CI exclusion, but the item title said "suite passes", which is what invited the reading. It now says authored-not-executed and states the zero executed drop evidence explicitly.

On the port_security operational risk: that trade-off was raised and accepted in the design proposal, cozystack/community#30. §Design records that kube-ovn v1.15.10 cannot express a CIDR in its allowed-address-pair path, so Phase 1 ships the gateway port fully relaxed with scoped port security as follow-up hardening; §Security states the guest source allow-list is therefore mandatory and platform-owned rather than defence in depth, and that Cilium bounds destinations rather than claimed source identity. Worth re-reading there — if that conclusion should change, the proposal is the place to change it.

Also narrowed the RBAC you judged acceptable, since two verbs were dead: list/watch are gone from Secrets, Services, ConfigMaps and Namespaces.

Your recommended dev-cluster run is still outstanding: build-vyos has not produced a digest yet. Its first run failed on an unrelated stale-branch test failure — this branch is 113 commits behind main, and main's naas.yaml gained a bundles.system.enabled guard that the suite's set: blocks did not satisfy, which is fixed in 10cb5f302.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM. The engineering quality is high and most Phase-1 residuals are documented honestly, but on several tenant-reachable paths the security model's stated isolation properties do not hold as written, and the guards' runtime efficacy is deferred from CI. Reviewed at c2abcf19 against merge-base 2f8c953c.

Findings

[MAJOR] internal/siterouter/denyset/denyset.go:90-106, internal/controller/siterouter/reconciler.go:407-431, internal/vyos/render/render.go:649-709 — node/host network is not in the deny-set → node source-impersonation into tenant pods.

ClusterNetworksFromConfigMap only ever populates pod/service/join (plus the always-reserved link-local/loopback/default-route); NodeCIDRs/LBPools stay empty. A tenant may therefore legally declare the node subnet as a remoteCIDR — admission and reconcile both pass — and the controller then emits a TUNNEL-INGRESS accept source ∈ <node-subnet> AND dest ∈ pod/svc. The authenticated tunnel peer (the tenant's remote site) can then send decrypted packets sourced as a cluster node IP to that tenant's workloads, defeating any node-IP-based trust those workloads apply. Separately this is a doc/comment contradiction: reconciler.go:407-408 and the security-model "Deny-set validation" section state node/LB-pool are rejected, while residual #6 admits they are not. Fix: reject any remoteCIDR overlapping the node/host network, and correct the overclaiming comment + doc.

[MAJOR] internal/controller/siterouter/vyospush.go:648-661internal/vyos/render/render.go:686-695 — the guest source filter is not a tenant-isolation control, contrary to the doc.

tenantNetworkCIDRs returns the whole cluster pod+service CIDR, so each TUNNEL-INGRESS accept is destination-constrained to all tenants, not this instance's networks. A decrypted packet with a valid remote source and a destination that is another tenant's pod IP is accepted and forwarded by the gateway. docs/security-model.md presents this filter as enforcing "must never let tunnel traffic reach ... unrelated tenants" — it does not; cross-tenant containment rests entirely on Cilium identity at the destination pod (which a static review cannot verify). Either stop claiming the guest filter provides tenant isolation, or scope the destination set per-tenant.

[MAJOR] values.schema.json (image), templates/dv.yaml:35-37, templates/vm.yaml:28, docs/security-model.md — tenant-settable boot image nullifies every guest-side guard while port_security is off from boot.

image.enabled/url is exposed in the cozyrd openAPISchema to tenant use (dashboard-resourcemap binds use) with no operator gating, letting a tenant boot an arbitrary image as the gateway VM, which carries ovn.kubernetes.io/port_security: "false" from pod creation. Every guest-side guard (source filter, forward default-deny, Boundary-A management drop, management firewall) lives inside the image the tenant just replaced. The threat model assumes the shipped VyOS guest enforces them and does not cover the custom-image case. The remaining controls are the two namespaced Cilium policies (egressDeny covers only 169.254.0.0/16 by default; gateway-ingress is additively re-opened per the documented Boundary-B residual) plus the unverified baseline Cilium identity. Net: a tenant obtains an anti-spoof-disabled pod running arbitrary code on the tenant OVN subnet, egress-denied only to link-local. Fix: gate image.* behind an operator/admin tier (not tenant use), or document the trust boundary and default security.egressDenyCIDRs to include node/management ranges.

[MINOR] internal/controller/siterouter/cnimediation.go:143-160removeRoutes deletes a co-tenant's live route on delete. When the deleting instance's gateway pod is already gone (gatewayIP==""), ownership falls back to dst-matching and drops every entry whose dst ∈ this instance's remoteCIDRs, including a co-tenant site-router's entry for the same dst (gw = the co-tenant's IP). Transient blackhole of the co-tenant's return path until its next 30s reconcile — the exact co-tenant clobber the gateway-IP-known branch was written to avoid.

[MINOR] internal/controller/siterouter/cnimediation.go:85-118 — route flap when two same-namespace SiteRouters declare the same remoteCIDR. Each reconcile upserts the shared dst's gw to its own gateway IP, so the two instances flip the route back and forth every 30s, churning the namespace annotation and disrupting that dst's return path. No conflict detection.

[MINOR] internal/controller/siterouter/reconciler.go:312-317 — deny-set hard error short-circuits before route withdrawal. A remoteCIDR valid when routes were programmed but later overlapping a cluster network (admin re-scopes pod/svc CIDR) makes validateRemoteCIDRs return before programNamespaceRoutes/removeNamespaceRoutes, so stale kube-ovn return routes persist and blackhole the now-cluster-owned range. Silent (Event only; HR stays Ready).

[MINOR] mediation health has no durable status signal. The SiteRouter app-instance has no status subresource (D9); every mediation failure (InvalidRemoteCIDR, ConfigureFailed, SourceFilterPending, PortSecurityRelaxationPending, PSK/APIKey/TunnelAddress pending) surfaces only as an ephemeral Warning Event on the HelmRelease, which stays Ready=True (chart applied) while the tunnel is down. Events age out (~1h); reconciler.go:420 acknowledges this. Recommend a durable condition or a documented "check Events + WorkloadMonitor" runbook.

[MINOR] values.schema.json — input-validation asymmetry. peer.address, staticRoutes.destination/nextHop, bgp.neighbors.address are unconstrained free strings and are not deny-set-checked, while managementCIDR has a strict IPv4 pattern and remoteCIDRs is deny-set-validated. Not an injection hole (the API push uses structured JSON Operation values; the cloud-init path is guarded by assertSafeVyOSInputs), but a hostile value fails late as a ConfigureFailed→Degraded requeue loop rather than a clean admission rejection, and a staticRoute toward a cluster CIDR is pushed to the gateway. Add CIDR/IP/hostname patterns for admission-time rejection + defense-in-depth.

[MINOR] cmd/site-router-controller/main.go:67--management-cidr help says it governs "HTTPS 443 and SSH (22)", but the render (render.go:517-521) and cloud-init never open 22 (the appliance has no SSH). Remove the SSH mention so operators don't assume 22 is reachable/controlled by this flag.

Claim mismatches

  • [PARTIAL] security-model.md "Deny-set validation ... rejects ... node / LB-pool networks" — node/LB-pool are not enforced (Finding 1).
  • [PARTIAL] security-model.md threat model "must never let tunnel traffic reach ... unrelated tenants" as a property of the guest source filter — the destination set is cluster-wide pod+svc; cross-tenant containment is Cilium-identity-only (Finding 2).
  • [PARTIAL] security-model.md guest-guard guarantees are silently void when image.enabled=true (Finding 3).

Caveats

  • Upgrade path: N/A — all-new packages (apps/site-router, system/site-router-controller, system/site-router-rd, system/vyos-router-image), new SiteRouter kind, new cozy-site-router namespace. No pre-existing customer objects; no upgrade-regression surface (verified: no removed/renamed in-tree identifiers).
  • Fresh-install: sound — PackageSource dependsOn (networking/kubevirt/cdi/engine/controller), naas gated on bundles.iaas.enabled (avoids the isp-hosted #3376 deadlock), controller dependsOn victoria-metrics-operator for its unconditional VMPodScrape, cozystack-api ConfigMap grant name-scoped. go build + all Go unit tests + all 63 helm-unittests green; make generate not stale.
  • Render-blindness / live-only claims: the kube-ovn v1.15.10 "port_security reconciled only at pod create" behavior and the VyOS 1.5 firewall leaf syntax are author-claimed "validated live" — not executed by this static review (needs a live cluster / booted gateway).
  • Cross-tenant/node containment (Findings 1-3) cannot be verified statically (live Cilium/kube-ovn required).
  • Negative-security e2e (the Phase-1 acceptance gate proving the guards actually drop spoofed / world-destined packets) is committed but deferred from CI (hack/select-e2e.sh DEFERRED_SUITES="site-router"), so the guards' runtime efficacy is unproven in CI.
  • Management-API isolation: managementCIDR defaults to the whole pod CIDR (every pod), and Boundary B is additively re-opened, so the api-key Secret's namespace RBAC isolation is effectively the sole control on the API. That isolation holds cross-tenant (api-key excluded from the dashboard Role + AppDef secrets.include — verified), but a tenant admin can read its own gateway's token and reconfigure the router (within the tenant's own blast radius).

Recommended follow-ups

  • cozystack-pr-test negative-isolation run: (a) a spoofing custom-image gateway, (b) a tunnel-peer packet sourced as a node IP and destined to another tenant's pod IP — verify no cross-tenant/node reach; plus run the deferred negative-security chainsaw suite once the golden image ships.

Checked and sound (recorded so they need not be re-litigated): deny-set admission wired into both Create and Update; controller RBAC is strictly least-privilege (get-only on secrets/services/configmaps); secrets are redacted before truncation (correct order — a secret straddling the 256-byte cut cannot leak); deny-set masks host bits, rejects /0 and non-IPv4 (incl. IPv4-mapped-IPv6), fails closed on a ConfigMap read error, and matches admission↔reconcile via the shared helper; the cozyrd hides the api-key and exposes PSK + tunnel Service with the correct release.prefix; push failure is fail-closed (hash not recorded, Degraded + requeue, no more-open end state); reconcile is single-writer under leader election; the cloud-init config.boot path is guarded by assertSafeVyOSInputs.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters Addressed the actionable code and documentation gaps in f1ded230b and 869bafbea.

  • Node and LoadBalancer protection: admission and reconciliation now share live discovery of Node Internal/External IPs and allocated Service external/LoadBalancer IPs, represented as /32s. A topology change that makes an existing remote CIDR unsafe withdraws its programmed route.
  • Tenant isolation: the guest filter now admits the pod CIDR plus same-namespace Service ClusterIPs as /32s, not the entire service CIDR. The documentation explicitly assigns cross-tenant pod isolation to Cilium identity policy; the guest filter blocks world/non-cluster forwarding but does not claim to distinguish tenant pod identities.
  • Boot-image trust: the tenant-settable image override is removed. Gateways always use the platform’s digest-pinned appliance because the relaxed OVN port relies on guest-enforced controls.
  • Route cleanup: route ownership is persisted on the HelmRelease. Cleanup uses persisted/current gateway IPs and never falls back to destination matching, so it cannot remove a sibling’s same-destination route after the pod disappears.
  • Route conflicts: sibling instances declaring the same destination now produce RouteConflict, preserve the first route, and stop before guest configuration instead of flapping the namespace annotation.
  • Deny-set failures: a newly unsafe CIDR withdraws existing owned routes before returning the hard error, and mediation failures remove stale tunnel/BGP gauges.
  • Observability: the controller still has no durable per-instance condition, and the documentation no longer claims one. Typed pending states now emit Warning Events, while a durable status design remains an explicit follow-up.
  • Input validation: peer, static-route, and BGP-neighbor fields now receive admission-time hostname/IP/CIDR validation. The deny-set remains specific to remoteCIDRs, which are the values that program namespace return routes.
  • Flag documentation: the incorrect SSH references were removed from --management-cidr help and warnings.

The tenant-admin RBAC omission for siterouters was also corrected, and the controller/API RBAC now covers the live Node and Service reads used by deny-set discovery.

Validation passed with the full Go suite, the standalone application API module, all affected Helm unit suites, package generation, and make manifests. The runtime negative-security e2e and durable controller-owned status remain documented follow-ups rather than being claimed as completed here.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: request changes

One hard merge blocker (the PR's own new CI gate fails on the committed tree), and one cross-tenant reachability question on the tunnel-ingress filter that the stated Cilium mitigation does not actually close. The security spine (deny-set, admission fail-closed, api-key isolation, RBAC) is otherwise solid and was verified against the code.

Blockers

1. Placeholder image digest breaks this PR's own CI gate and makes every gateway non-bootable

packages/apps/site-router/images/vyos-router-disk.tag carries an all-zeros digest (...@sha256:0000…0000).

  • This PR adds hack/image-refs-no-placeholder.bats, which make unit-tests runs in CI. Reproduced: grep -rIlE '@sha256:0{64}' packages/ --exclude-dir=charts returns exactly this file, so the gate exit 1s. The PR ships a guard its own tree violates — the unit-tests job is red and it cannot merge as-is.
  • Functionally: the VyOS golden image is not published, so a tenant's SiteRouter boot DataVolume resolves nowhere → boot PVC stays Pending forever and the VM never boots. dv.yaml's required guard only rejects an empty .tag; a well-formed placeholder digest passes render. Since the app is wired into the naas bundle, an install/upgrade exposes the SiteRouter kind in the tenant catalog while no gateway can boot.

Fix: publish the cozystack-owned VyOS image and stamp the real digest before merge; do not activate the catalog entry until the image exists.

Security (needs resolution before merge)

2. Decrypted tunnel ingress can reach any tenant's pods, and the documented Cilium backstop does not enforce cross-tenant isolation for it

  • internal/controller/siterouter/vyospush.go:665 (tenantNetworkCIDRs) builds the decrypted-traffic destination allow-list as PodCIDR (the whole cluster pod CIDR, e.g. 10.244.0.0/16) plus this-namespace Service ClusterIPs. Services are tenant-scoped; pods are not.
  • internal/vyos/render/render.go:686 (renderTunnelIngressFilter) turns that into accepts of source ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is 0.0.0.0/0 and no NAT is rendered, so the decrypted packet keeps the remote peer's real source IP.
  • docs/security-model.md:27 states this pod-CIDR breadth is safe because "a destination inside the cluster pod CIDR proceeds to Cilium, whose identity policy enforces cross-tenant isolation." The tenant baseline allow-external-communication (packages/apps/tenant/templates/networkpolicy.yaml) unconditionally admits fromEntities: [world, cluster]. A decrypted packet with an external source IP is classified as identity world, so a victim pod in another tenant governed by that baseline will accept it. The stated mitigation therefore does not hold for this traffic.

Failure scenario: tenant A sets remoteCIDRs=[203.0.113.0/24]; a host on that subnet sends a packet to tenant B's pod IP; it is decrypted, matches source∈203.0.113.0/24 AND dest∈podCIDR, is forwarded, and tenant B's pod accepts it as world ingress.

What is proven from code: the filter's destination breadth (whole pod CIDR), source-IP preservation, and that the baseline admits world. What is not empirically confirmed (needs a live cluster, blocked on the golden image): the actual cross-tenant delivery through kube-ovn. Please either scope the destination to the owning tenant's own pods, or empirically prove the delivery is denied and correct the security-model wording.

Non-blocking findings

3. BGP is a half-wired feature

The public schema api/apps/v1alpha1/siterouter/types.go (BGP) exposes only enabled / localASN / neighbors. resolveInputs (vyospush.go:591) constructs render.BGPPeer{PeerAddress, PeerAsn} only. The render fields AdvertisedNetworks, RouterID, Timers, and BGPPasswords (render.go:140) are unreachable from the controller — dead code. A tenant setting bgp.enabled=true gets a session that originates no routes; the instance stays Ready with no warning event (unlike the missing-ASN path, which does warn). Either document this as receive-only for Phase 1, or wire advertisedNetworks into the schema.

4. No runtime detection of a managementCIDR lockout

The chart and controller defaults are both the whole pod CIDR 10.244.0.0/16. ValidateManagementCIDR only checks the flag is a valid CIDR, not that it covers the controller's source. On a cluster with a non-default pod CIDR, or if the chart managementCIDR and the controller --management-cidr drift, the controller silently firewalls itself out of every gateway, surfacing only as an opaque ConfigureFailed. Consider deriving the CIDR from the same ConfigMap the deny-set already reads, or emitting a distinct diagnostic when pushes time out.

5. Checklist item 9 ("unit green in CI") is partial

helm-unittest (60 tests) and the Go unit tests pass locally, but make unit-tests (the CI job) also runs the new placeholder-digest bats gate, which is red on the committed tag (see Blocker 1).

6. Stale scaffolding comments

internal/controller/siterouter/reconciler.go:15,258,414 describe fully-implemented methods (validateRemoteCIDRs, programNamespaceRoutes, verifyGatewayPortSecurityRelaxed, removeNamespaceRoutes, …) as "ordered stubs" / "no-op stub" / "placeholder returning nil". Update the comments so they don't misrepresent what is implemented.

7. Repo hygiene

No ```release-note ``` fenced block in the PR body (CONTRIBUTING requires one). Add'site-router': 'area/networking'to.github/workflows/pr-labeler.yaml` (already noted in the description).

Verified sound

  • Deny-set validator (denyset.Validate): masking, IPv4-only rejection (incl. 4-in-6), /0 rejection, unconditional link-local/loopback, and bidirectional Overlaps are all correct; admission and controller share the same helper, so they cannot diverge.
  • Admission fails closed and is wired on both Create and Update (pkg/registry/apps/application/rest.go:208,561); deny-set discovery errors (ConfigMap non-NotFound / Node / Service list) propagate.
  • The api-key Secret is genuinely not tenant-readable: not in the cozyrd secrets.include, not in dashboard-resourcemap.yaml, and the tenant ClusterRoles grant no blanket get secrets; the guest login is locked (encrypted-password "*", no service ssh).
  • Controller RBAC is least-privilege (secrets: get only, no cluster-wide list); secret redaction happens before message truncation; route merge/withdraw keying does not hijack a co-tenant's same-destination route; --leader-elect is hardcoded, so the in-memory hash cache is safe.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters rebased this onto current main (branch was 276 commits behind) and fixed what CI actually fails on now, old logs were useless. Your two blockers are untouched, details at the end.

Result is one squashed commit 8fbe38938 on 09dc7f47f. Only two textual conflicts, both in hack/select-e2e.sh, and resolving them turned up something worse than the deferred-suite gap you flagged.

DEFERRED_SUITES="site-router" stripped the name from selector's final output. Main has since grown per-path coverage escalation (#3330). Together they give empty selection, not reduced one: site-router-application depends on kubevirt-cdi, so reverse walk gave kubevirt-cdi and cozystack-basics exactly one suite, and stripping turned that one into zero. Both e2e lanes read empty as "skip chainsaw" and post E2E Tests green.

packages/system/cozystack-basics        21 suites -> EMPTY
packages/system/kubevirt-cdi            21 suites -> EMPTY
packages/system/site-router-controller  21 suites -> EMPTY

Fixed by parking the suite as chainsaw-test.yaml.disabled, which is main's own convention already (hack/e2e-chainsaw/backup/). That drops it out of selector universe and out of select-install.sh round-trip at once, so hack/select-e2e.sh is byte-identical to main now and un-parking is just renaming the file back. One deliberate behaviour change, a packages/apps/site-router change escalates to full suite instead of selecting nothing, which is what #3330 prescribes for a package with no runnable suite.

Three real unit test failures behind your item 5:

  1. cozystack-api rbac_test pinned rule count at 16, branch adds name-scoped cozystack ConfigMap read for deny-set. Bumped to 17 and added coverage pinning resourceNames and verbs, so a widening cannot pass under it.
  2. Main now errors when a package declares test: and runs zero suites, and site-router-controller chart shipped none. Added rbac and deployment tests, 18 of them, pinning what the RBAC comments claim: Secret get only, Namespace get and patch only, HelmRelease never update or delete, and fail-closed --allow-open-management.
  3. EXIT traps in the 4 new bats files. Per docs/agents/e2e-testing.md that count is a ratchet review keeps from growing, so i removed the traps instead of raising it.

Security surface is byte-identical to pre-rebase, checked file by file: denyset.go, discovery.go, rest_siterouter.go, render.go, cnimediation.go and the whole chart including both Cilium policies. rest.go was auto-merged so i verified admission separately rather than trusting the merge, still invoked fail-closed on create and update.

Both your blockers stay open. Placeholder digest is deliberate, hack/image-refs-no-placeholder.bats is red until the appliance image is published and that is what its commit message says it is for, so make unit-tests cannot go green before that ships. Tunnel-ingress destination breadth needs the scoping call, i did not touch it.

Please take another look at the delta.

Adds `site-router`, a catalog app for routed (L3), source-IP-preserving
tenant site-to-site connectivity over an IKEv2 IPsec tunnel terminated
in a VyOS KubeVirt gateway VM.

The chart materializes the gateway: VM + boot DataVolume, tunnel Service
type LoadBalancer (UDP 500/4500, native loadBalancerClass), PSK and
RBAC-isolated api-key Secrets, first-boot cloud-init, WorkloadMonitor,
and two net-new Cilium policies (gateway egressDeny + gateway ingress).

`site-router-controller` mediates what the chart cannot express:
deny-set validation of the tunnel's remote networks, the kube-ovn
return-route annotation, gateway-port `port_security` relaxation gated
on the guest source filter, the live VyOS configuration push over the
management API, tunnel/BGP observability, and status. There is no new
tenant CRD — the `apps.cozystack.io/SiteRouter` app instance is the
whole contract.

A shared pure `internal/siterouter/denyset` validator backs both a
SiteRouter-scoped admission check in `pkg/registry/apps/application`
and the reconcile-time check, so a cluster-overlapping `remoteCIDR` is
rejected identically at apply time and reconcile time. The admission
runs fail-closed on create and on update.

NAT/DNAT (a future `site-gateway`) and HA/VRRP are out of scope for
Phase 1. The negative-security acceptance suite is authored but parked:
its live run needs the published appliance image.

Contents:

  - app chart packages/apps/site-router (10 helm-unittest suites)
  - site-router-controller: internal/controller/siterouter,
    cmd/site-router-controller, packages/system/site-router-controller
  - VyOS core library internal/vyos (client/parse/observation) and
    routed render internal/vyos/render
  - internal/siterouter/denyset + the admission check
  - the appliance build package packages/system/vyos-router-image
  - platform wiring: PackageSources for the application and the
    controller, both emitted by the naas bundle behind
    bundles.iaas.enabled (the gateway needs KubeVirt + CDI, which
    isp-hosted does not have)
  - docs: README, security-model, image-lifecycle, followups

The e2e suite is committed parked as chainsaw-test.yaml.disabled, the
way hack/e2e-chainsaw/backup/ is parked, because its appliance image is
not published yet. Parking it as a file rather than as a name filtered
out of hack/select-e2e.sh's output is deliberate: the selector decides
coverage from the same enumeration, so a name-based filter leaves the
graph walk still reaching the suite. site-router-application depends on
cozystack.kubevirt-cdi, which would give kubevirt-cdi — and
cozystack-basics, which reaches it — exactly one suite, stripped to an
empty selection that both e2e lanes read as "skip Chainsaw" before
posting the required "E2E Tests" status green (#3392). Parking the file
keeps the suite out of the selector's universe and out of
select-install.sh's round-trip validation at once, so those packages
keep escalating to the full run as they do on main, and un-parking is
renaming the file back.

The appliance boot reference is committed digest-less
(`…/vyos-router-disk:v0.0.0`). A first-party image that has never been
built has no digest to commit, and nothing in the PR lane can create
one: build-vyos ends at `git diff --binary HEAD > vyos.patch` and
uploads it, so the stamp reaches e2e through pr.patch and never reaches
git; only tags.yaml's post-merge `Prepare release` commits a digest.
site-router-controller ships :v0.0.0 the same way in this PR, and
securitygroup-controller sat at :v0.0.0 on main until a release build
stamped it. An all-zero placeholder is strictly worse than no digest —
it passes `required`, looks like a valid pin and resolves nowhere — so
hack/image-refs-no-placeholder.bats keeps rejecting that shape
untouched. While the ref is digest-less it is invisible to
hack/lib/image-refs.sh, so promote/retag/mirror skip the appliance;
tests/dv_test.yaml accepts either form and carries a TODO to re-tighten
after the first release build.

VYOS_BUILD_REF is pinned to e14a4895 (upstream `rolling` tip,
2026-08-11), whose kernel_version 6.18.44 is the only kernel the rolling
mirror serves; the ref this started from asked for 6.18.41, which is
gone, and live-build fails hard on that rather than building something
stale. VYOS_VERSION tracks the ref's commit date so the label stays a
function of the pin. The pin comment previously claimed Renovate kept
these from aging silently — both custom managers do match the file, but
Renovate only evaluates the default branch and neither the Makefile nor
the managers exist there until this merges, so the pins were unwatched
by construction; even after merge they raise a PR rather than holding
the build up, while the breakage is on the mirror's kernel-retention
clock. The comment and docs/image-lifecycle.md now say that.

Two shared checks move with the app: cozystack-api's ClusterRole gains
a name-scoped `get` on the cozystack ConfigMap that the deny-set
admission reads (its pinned rule count goes 16 -> 17), and the
site-router-controller chart ships the RBAC and deployment suites the
sibling controller packages ship.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
myasnikovdaniil added a commit that referenced this pull request Aug 16, 2026
Both are cases of the walk reaching the wrong set of suites for a
package, and both were found by enabling the site-router suite on #3426,
which is the first suite in the tree to land downstream of kubevirt-cdi.

`vm-disk-application` now maps to the `vminstance` suite. It mapped to a
`vm-disk` suite that does not exist, which intersect_suites() drops, so
the source reached nothing runnable and neither did kubevirt-cdi above
it: every CDI change ran all 21 suites. The vminstance suite creates a
VMDisk and asserts the DataVolume behind it
(hack/e2e-chainsaw/vminstance/vmdisk.yaml, vmdisk-vmi.yaml), so it is the
suite that covers both. A CDI change now selects `vminstance` alone.

`cozystack.cozystack-basics` joins `cozystack.cozystack-engine` as a
propagation hub excluded from the reverse-dependency walk. Every edge
into it exists so a namespace or a platform-wide policy is in place
before the dependent installs -- kubevirt-cdi says exactly that in its
own source -- which is install ordering, not behaviour. The damage runs
opposite to the engine's: the engine fans one change out to every app,
while basics narrows down instead. It reaches no suite today so it
escalates, but it sits upstream of kubevirt-cdi, so the first suite to
land under CDI silently converts the platform's namespace-and-policy
package from the full run to that one suite. On #3426 that is 22 suites
down to `site-router` alone, with nothing in the output to say coverage
was lost. Dropping the reverse edges keeps the package reachable, stops
it propagating, and leaves a change to it running everything through the
per-path escalation.

Both rules have a test, each verified to redden with its rule removed:
the mapping test falls back to the full-suite escalation, and the hub
test -- which seeds the hazard by giving redis-application an edge onto
basics, since no suite sits downstream of it in this tree yet -- selects
`redis vminstance` instead of everything.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: decrypted tunnel-ingress is not scoped to the tenant — deviation from the agreed design

This is the same point I raised on 2026-08-04 and 2026-08-05, still unresolved on ce01379. I'm re-raising it against the agreed design of the reference Router this PR productizes, because the disagreement is a design-conformance question, not a matter of taste.

What the agreed design mandates

In the reference design, tenant/project isolation of routed traffic is structural, not policy-based:

  • The router attaches only to its own isolated network segments (interfaces[].networkRef); each is a distinct per-tenant OVN subnet with its own CIDR.
  • An isolated network never routes outside its CIDR; a routed network routes only via its attached router. The router has no interface into another tenant's network, so cross-tenant reach is impossible by construction.
  • Per-VM filtering (SecurityGroup on the NIC) is an intra-tenant control. It is explicitly not the cross-tenant boundary.

There is no full port_security relaxation and no "rely on the CNI identity layer for cross-tenant" anywhere in that model, because cross-tenant isolation is a property of the topology.

What this implementation does instead

  • internal/controller/siterouter/vyospush.go:665-669 (tenantNetworkCIDRs) builds the decrypted-traffic destination allow-list as the whole cluster pod CIDR (nets.PodCIDR, e.g. 10.244.0.0/16), not this instance's own networks.
  • internal/vyos/render/render.go:686 turns that into TUNNEL-INGRESS accepts of source ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is 0.0.0.0/0 and no NAT is rendered, so the remote peer's source IP is preserved.
  • port_security is fully relaxed on the gateway port, so the gateway forwards packets with a foreign source IP onto the shared pod network.
  • Cross-tenant containment is then delegated to Cilium identity at the destination pod (docs/security-model.md).

This forwards decrypted traffic to every tenant's pods on the flat pod network. That is a deviation from the design's per-network scoping, and it reintroduces a cross-tenant exposure the design structurally does not have.

Why the documented Cilium backstop does not hold

docs/security-model.md states the destination-side Cilium identity policy enforces cross-tenant isolation. It does not, for this traffic class:

  • The tenant baseline allow-external-communication (packages/apps/tenant/templates/networkpolicy.yaml) selects every pod (endpointSelector: {}) and admits ingress.fromEntities: [world, cluster].
  • Deny-set validation forces every remoteCIDR to be disjoint from all cluster networks, so the decrypted packet's preserved source is always an external IP, which Cilium classifies as identity world.
  • world is admitted by the baseline, so a decrypted packet destined to another tenant's pod IP is accepted at the destination. The stated mitigation therefore does not enforce cross-tenant isolation.

The negative-security e2e that would otherwise "prove" a drop (hack/e2e-chainsaw/site-router/chainsaw-test.yaml.disabled, case 2) is committed disabled with stubbed probes, and its own comment concedes the guest filter admits the cross-tenant packet and defers the drop to Cilium.

Resolution (either of)

  1. Design-faithful: give the gateway its own per-tenant isolated subnet and scope the tunnel-ingress destination to it, restoring the topology-level isolation the design specifies.
  2. If that substrate isn't in Phase 1: empirically prove the cross-tenant delivery is actually denied (against the default tenant baseline, not an assumed one), correct the security-model.md wording, and keep the app out of the default presets until then.

Additionally, the normative IPsec forwarding/isolation mapping the design requires to be pinned before implementation was never written; this is the gap that let the destination breadth land undocumented.

Until one of the above lands, this is request changes on the isolation model.

tenantNetworkCIDRs seeded the decrypted-tunnel destination set with the
whole cluster pod CIDR, so a remote peer was permitted to reach every
tenant's pods on the flat pod network, not only the pods of the tenant
that owns the tunnel. Cilium is not the backstop the security document
claimed: the tenant baseline admits ingress fromEntities [world,
cluster] on an empty endpoint selector, and the deny-set forces every
remoteCIDR to be non-cluster, so the decrypted packet's preserved
source always lands as identity `world`, which that baseline permits.

Enumerate the instance namespace's own pods instead and contribute one
/32 per pod IP, mirroring the Service-ClusterIP loop that already sits
in the same function. The List goes through the uncached reader for the
same reason surfacePendingRoutePods does — the Pod cache is
label-scoped to gateway pods, so a cached List would return none and
the tunnel would reach nothing. Gateway pods, terminating pods,
completed Job pods and hostNetwork pods (whose pod IP is a node
address) are skipped; the doc comment records the two accepted costs,
a one-reconcile reachability lag for a new pod and a config hash that
now moves whenever tenant pods churn.

`nets` stops being read, so it leaves the signature along with the
instance.clusterNetworks snapshot that existed only to feed it.

The new cases are producer-side on purpose: the render tests set
TenantNetworkCIDRs themselves and assert the render honours it, so they
pass identically with the whole pod CIDR in the field. Restoring the
old seed turns TestTenantNetworkCIDRs_ScopedToOwnNamespacePods red on
the cross-tenant assertion — 10.244.2.20 in 10.244.0.0/16 — which is
the reason the seed was a defect. It proves the gateway no longer
permits a foreign-tenant destination, not that cross-tenant delivery is
denied on the wire; that stays the negative-security e2e's job.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The security model asserted twice that Cilium's identity policy denies
cross-tenant traffic arriving through the tunnel. It does not: the
tenant baseline admits ingress fromEntities [world, cluster] on an
empty endpoint selector, the deny-set forces every remoteCIDR to be
non-cluster, so the decrypted packet's preserved source classifies as
`world` — which that baseline permits. pkg/apis/sdn/DESIGN.md says the
same thing from the other side: the baseline blanket-allows and Cilium
allow rules can only widen.

Replace both with the property the destination scoping actually
creates. In the tunnel-ingress section, containment is structural in
the guest: a pod IP belonging to another namespace is in no accept
rule, so the default action drops it. In the egressDeny section, Cilium
bounds the gateway's OWN egress destinations by its endpoint identity —
it contains destinations, not identities asserted inside tunnel
traffic, which is what the merged design proposal claims and no more.

The threat-model line is left as written: it promises tunnel traffic
never reaches unrelated tenants, and that is now true.

Also record the two open follow-ups this review round surfaced: Phase-1
BGP is receive-only (the render can originate routes, no API field
feeds it), and the managementCIDR drift still has no diagnostic, so the
operator meets it as an opaque ConfigureFailed timeout.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Four comment blocks described code that has since been written. The
package doc and Reconcile's doc called the mediation pipeline a set of
ordered stubs "filled in by T06/T07/T09"; the section header called
every step a placeholder returning nil; the cache note said the
mediation steps "will additionally read" Secrets, the tunnel Service
and the Namespace. All four now describe what the code does, and the
cache and RBAC notes pick up the tenant-pod List the destination set
added.

render.Inputs.TenantNetworkCIDRs described its own value as the cluster
pod and service CIDRs read from the cozystack ConfigMap, which stopped
being true when the controller started enumerating the tenant
namespace's own pod IPs. Its empty-set note is also sharpened: an empty
set leaves the accepts source-only, which is the fail-open shape the
destination constraint exists to prevent, and the reason the controller
cannot produce one on a path that pushes is now written down rather
than assumed.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The scope had no entry, so a site-router PR fell through to
area/uncategorized and the real area had to be applied by hand.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Pushed ecae4172. Destination set is now one /32 per pod ip and per service clusterip in the instance own namespace, it was seeded with whole cluster pod cidr before. Added fourth pod filter for spec.hostNetwork while doing it, such pod reports node address as its pod ip so enumeration would put a node into accept set, and the old pod-cidr seed could not do that because node ip is outside pod cidr.

You were right about cross-tenant reach, and the cilium claim in the doc was wrong in two places. packages/apps/tenant/templates/networkpolicy.yaml admits fromEntities: [world, cluster] on empty endpoint selector unconditionally, pkg/apis/sdn/DESIGN.md Non-Goals says baseline blanket-allows and cilium allow-rules can only widen, and denyset forces every remoteCIDR to be non-cluster, so decrypted packet source always lands as identity world which that baseline permits. Rewrote :27 and also :44, you cited only the first one and the second repeats the same claim 17 lines later.

About non-conformance, i cant find interfaces[].networkRef anywhere. Not in community#30, grep networkRef over the tree returns nothing, and pkg/apis/sdn/v1alpha1 holds only securitygroup_types.go. community#30 rejects the per-VPC alternative as not viable, accepts full port_security relaxation for phase 1, and says cilium contains destinations rather than identities claimed in packets. So resolution option 1 needs substrate that does not exist yet and i implemented destination scoping instead. If that model is somewhere i did not look, point me at it and i'll read it.

Two costs i accepted, both written in the code. New pod is not a permitted destination until next successful push, so tenant scaling up sees new replica reachable up to one reconcile interval later, it self-heals because rendered ops feed config hash. Second, config hash now moves whenever tenant pods churn, so namespace with rolling deployments re-pushes roughly every reconcile where one stable cidr only re-pushed on spec change.

Test seeds a pod in the instance namespace and one in another tenant, asserts foreign ip is absent, and i checked red phase by hand (restoring old seed fails it on destination 10.244.0.0/16 covers another tenant's pod IP 10.244.2.20). It proves gateway does not permit a foreign tenant destination, it does not prove delivery is denied through kube-ovn, that stays for negative-security e2e which still waits for published appliance. render_security_test.go:141 and :206-246 set TenantNetworkCIDRs themselves so they pass identically before and after the fix, which is why this survived them.

renderTunnelIngressFilter still turns an empty TenantNetworkCIDRs into source-only accept, which is fail-open. Unreachable from the controller because tunnel service is LoadBalancer and always gets a clusterip and no push happens before it has ingress ip, and pinned now by TestTenantNetworkCIDRs_NeverEmptyAtPushTime. managementCIDR diagnostic i deferred, followups.md:23 already tracks the derivation so i added a clause naming the symptom operator actually sees (opaque ConfigureFailed with nothing pointing at cidr).

Require API owner review needs Timofei Larkin (@lllamnyp) or Andrei Kvapil (@kvaps). Timofei Larkin (@lllamnyp) please take a look.

IvanHunters
IvanHunters previously approved these changes Aug 19, 2026

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after ecae417 — isolation blocker resolved, and I'm withdrawing my design-conformance claim

Isolation blocker (tunnel-ingress destination breadth): resolved. 0601c59c replaces the whole-pod-CIDR seed in tenantNetworkCIDRs with one /32 per Pod IP and per Service ClusterIP owned by the instance's own namespace, listed through the uncached reader and skipping gateway, terminating, completed-Job and hostNetwork pods. render.renderTunnelIngressFilter now constrains every accept to source ∈ remoteCIDR AND destination ∈ that set, so a decrypted packet aimed at another tenant's pod IP matches no accept and hits the default drop in the guest, independent of Cilium. TestTenantNetworkCIDRs_ScopedToOwnNamespacePods pins the cross-tenant case (10.244.2.20 rejected both exactly and by any covering prefix) and the revert-the-seed check fails red for the right reason. 28620fdb restates containment as structural-in-the-guest and corrects the two spots that claimed the destination baseline enforces it. This is the closure I asked for.

Withdrawing my design-conformance objection. I framed the whole-pod-CIDR reach as a deviation from an agreed design of structural per-Network isolation (interfaces[].networkRef, a per-tenant isolated OVN subnet). You were right that this substrate does not exist here: there is no such type in pkg/apis/sdn/v1alpha1 (only securitygroup_types.go), and the model I quoted lives in a different layer, not in this repo's agreed proposal. The agreed design is community#30 (design-proposals/tenant-site-connectivity/README.md), signed off by the API owners: it explicitly accepts full port_security relaxation for Phase 1, records scoped port_security as follow-up hardening blocked on kube-ovn AAP CIDR support, and names containment as Cilium sender-side egress plus the platform-owned guest source allow-list, not a per-Network topology. My "resolution option 1" asked for substrate the proposal deliberately deferred, so that part of my earlier review was wrong and I retract it. The destination scoping you shipped is the correct closure within the agreed model, and it goes a step past what Phase 1 required.

Two non-blocking items:

  • [MINOR] renderTunnelIngressFilter degrades an empty TenantNetworkCIDRs to a source-only accept, which is fail-open, and the never-empty-at-push-time invariant it leans on is IPv4-specific: tenantNetworkCIDRs only adds addr.Is4() addresses, so on an IPv6-only cluster every pod IP and the tunnel ClusterIP are skipped and the set is empty. The feature is IPv4-only today (deny-set rejects non-IPv4 remoteCIDR, the render is IPv4), so this is not reachable in a supported config, but the degrade direction is the wrong one for a security control. Emitting a drop on an empty destination set fails closed and removes the load-bearing non-emptiness invariant entirely.
  • [NIT] Several chart templates carry multi-paragraph # narrative comment blocks (_helpers.tpl, dv.yaml, networkpolicy.yaml, secret-cloudinit.yaml, service.yaml, vm.yaml). A # YAML comment is not stripped at render, so the prose is copied into every rendered manifest and drifts out of date. Keep a one-line pointer and move the explanation to the docs.

My earlier request-changes on the isolation model is resolved. The remaining Require API owner review gate is for Timofei Larkin (@lllamnyp) / Andrei Kvapil (@kvaps), and the red E2E (in-tree) needs attributing to the actual failing suite (the site-router suite is parked as chainsaw-test.yaml.disabled, so it isn't what runs there) before it's called flake or regression.

_a_counter closed its `sh -c` string before the pipe, so only the curl
ran in the probe-driver pod and the response was parsed back in the
Chainsaw script shell -- the e2e sandbox container, which is
ubuntu:24.04 plus the apt list in
packages/core/testing/images/e2e-sandbox/Dockerfile. That list carries
jq and no python3, and the pinned base has neither python3 nor python,
so the interpreter resolved to nothing, the trailing `2>/dev/null`
swallowed the "not found", and every counter read came back empty.

Six of the eight assert_dropped guards in the negative-security gate are
guest_counter_*, so this failed the gate rather than weakening it: the
counters were unreadable, not permissive, and guest_counter_flat's
could-not-read check turns an unreadable counter into a loud failure.
Re-express the extraction in jq, which keeps the parsing in the sandbox
next to the rest of the function's logic and alongside the jq the
bucket, openbao, securitygroup and kuberture suites already run there.

Verified against captured /show responses rather than by swapping one
binary for another: the jq filter and the python it replaces return
byte-identical output across 30 rule/response combinations, degenerate
bodies included -- absent `data` key, short row, empty body, unparseable
body. With python3 shimmed out of PATH the old function returns empty
for every rule while the new one returns the real counts, and an
unparseable body still yields no output rather than a zero, which is
what lets guest_counter_flat tell a missing reading from a flat one.

The readinessProbe notes went with it. Gating "Ready" on python3 was
true of the pod, irrelevant to the only python3 call in the suite, and
misleading once that call is jq; sshpass and curl are the whole in-pod
toolchain, so the probe gates on those two and says why.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
site-router-a.yaml asked for chartRef OCIRepository/site-router-chart.
Nothing anywhere creates that source -- not the suite, not
hack/e2e-chainsaw/_lib/, not the platform -- and its three occurrences
in this file were the only ones in the repository, so the HelmRelease
could only ever sit unreconciled waiting for a source that never
appears.

Name the ExternalArtifact the platform installs for this app instead:
cozystack-site-router-application-default-site-router in cozy-system,
the same source packages/system/site-router-rd/cozyrds/site-router.yaml
resolves for every real SiteRouter release. The e2e install runs variant
isp-full, which enables the naas bundle carrying
site-router-application, so the artifact exists before the suite starts
and the suite provisions nothing.

An ExternalArtifact supplies the chart and not the values, so the
fixture's spec.values stays authoritative -- packages/extra/external-dns
consumes its own the same way -- and the establish-tunnel step still
patches `helmrelease site-router-a` by kind and name, so the swap needs
no other edit. Left as a raw HelmRelease rather than promoted to
`kind: SiteRouter`: the projection is what the controller consumes, and
a CR would move that patch target for no gain in what is proven.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 20, 2026
## Why

I replayed the 150 most recently merged pull requests through
`hack/select-e2e.sh` with every escalation branch instrumented. 118 of
them (78.7%) ran the full 21-suite Chainsaw run, and the causes, counted
once per escalating PR with the count where that cause was the only one
in brackets, were `FULL_PATTERN` 88 [68], `NO_SUITE_FOR_GROUP` 24 [24],
`CHAINSAW_SHARED` 18 [1] and `UNCLASSIFIED` 7 [4]. `YQ_BROKEN`,
`NO_GRAPH_OWNER` and `BACKSTOP_EMPTY` never fired.

Two problems come out of that. Most of the escalation is real but
unnecessary. Whole classes of path that provably cannot affect a
Chainsaw suite were escalating because no rule claimed them. And the
commonest cause of a full run was absent from the log, so "why did this
pull request run everything" could only be answered by re-deriving the
selection by hand.

## What changes

**Every escalation now names its cause on stderr.** Seven branches could
reach the full suite and four did it in silence, `full_suite_pattern`
among them, the commonest cause by a wide margin, so the usual answer
was the one the log never carried. The lines go to stderr and must stay
there: stdout is the suite list and both e2e lanes parse it, so a reason
line on stdout would be read as a suite name. A unit test asserts every
message, verified by muting each of the eleven in turn and confirming
the file goes red for all of them. A reason that regresses to silence
changes no selection and is otherwise invisible.

**`cozystack.etcd-application` gains its missing `dependsOn:
cozystack.etcd-operator`.** `packages/extra/etcd` renders `kind:
EtcdCluster` from `etcd-operator.cozystack.io/v1alpha2`, and this was
the only operator-backed application source in the tree with no edge to
its operator. It is a latent install bug as much as a selection gap:
without the edge `etcd-rd` registers the ApplicationDefinition as soon
as the engine is up, so a tenant can create an `Etcd` before the
operator exists and its HelmRelease fails on `no matches for kind
"EtcdCluster"`, and `hack/select-install.sh`'s forward closure for the
`etcd` suite omitted the operator for the same reason. On the selection
side `cozystack.etcd-operator` reached no runnable suite, so every
change to the operator ran all 21.

**`vm-disk-application` maps to the `vminstance` suite.** It resolved to
a `vm-disk` suite that does not exist, `intersect_suites()` dropped the
name, and the source reached nothing runnable. Neither did
`cozystack.kubevirt-cdi` above it, whose only other dependents are
`vm-default-images` and `vm-disk-application` itself, so every CDI
change ran all 21. The coverage was there and the table did not know it:
the `vminstance` suite creates a `VMDisk` and asserts the `DataVolume`
behind it (`hack/e2e-chainsaw/vminstance/vmdisk.yaml`,
`vmdisk-vmi.yaml`). Same defect class as the etcd edge above, on the
mapping side rather than the graph side, and with no production blast
radius of its own, since `src_to_suites` is read by the selector and by
nothing else.

**`cozystack.cozystack-basics` joins `cozystack.cozystack-engine` as a
propagation hub excluded from the reverse-dependency walk.** Every edge
into it exists so a namespace or a platform-wide policy is in place
before the dependent installs, which `kubevirt-cdi` states in its own
source ("Depend on cozystack-basics so the target namespace exists
first"), and that is install ordering rather than behaviour. The damage
runs opposite to the engine's: the engine fans one change out to every
app, basics narrows instead. It reaches no suite today so it escalates,
but it sits upstream of `kubevirt-cdi`, so the first suite to land under
CDI silently converts the platform's namespace-and-policy package from
the full run to that one suite. Not hypothetical — on #3426 enabling the
site-router suite takes `cozystack-basics` from 22 suites to
`site-router` alone, and nothing in the output says coverage was lost.
Dropping the reverse edges keeps the package reachable, stops it
propagating, and leaves a change to it running everything through the
per-path escalation.

**`hack/e2e-apps/<name>.bats` maps to the `<name>` suite**, off the
basename, exactly as the per-suite rule takes the name off a
`hack/e2e-chainsaw/<app>/` directory. Deliberately mapped rather than
marked inert: what remains there is wired to nothing after the Chainsaw
migration, and inert would bake that orphan status into the rule and go
quietly wrong the day a lane runs those files again. The basename is
membership-tested against the suite list on the spot, so an unmatched
one escalates there and then rather than having the verdict deferred to
the final intersection, where the rest of the diff would decide it.

**`packages/tests/` and three `.gitattributes` join
`inert_config_pattern`,** which is what the script's own header says to
do with a genuinely inert path instead of widening the fall-through.
`packages/tests/` is a helm-unittest fixture chart, and changing a test
*of* `cozy-lib` does not change `cozy-lib`, no PackageSource lists those
paths as a component, and nothing installs them, while a change to the
library itself still escalates through `packages/library/`. The
`.gitattributes` entries are enumerated rather than matched by filename,
because the justification is what those files contain (only
`linguist-generated` markers) and the name does not carry it:
`.gitattributes` can also set `filter`, `eol`, `working-tree-encoding`
and `export-subst`, each of which changes what lands in the working tree
and therefore what gets built.

**`full_suite_pattern` escalates only `hack/e2e-*.bats`, not every
`hack/*.bats`.** The root `Makefile` is the authority on the split and
draws it at exactly that prefix, `BATS_UNIT_FILES := $(filter-out
hack/e2e-%.bats,$(wildcard hack/*.bats))`, so the 60 files it keeps are
the unit lane and the e2e sandbox runs none of them, while the three it
filters out are what `packages/core/testing`'s recipes execute and those
still escalate. Being inert here does not leave them untested: `make
unit-tests` is gated on the `plan` job's `code` output, which
`pull-requests.yaml` computes as "any changed path outside `docs/`" and
never from `select-e2e.sh`, so a bats-only pull request still runs the
whole unit lane, plus install and the OpenAPI tests, since the `e2e` job
reads the same output rather than the selection.

## Measured effect

The before-and-after comparison below is a second measurement over a
slightly different population, stated separately because the two are not
interchangeable: it replays the last 150 first-parent merge commits on
`main` (`git diff --name-only $sha^1 $sha`) through the base selector
with base sources versus this branch with its own, where the histogram
above walks the 150 merged pull requests as GitHub lists them. The
populations overlap heavily and the direction is the same, and the
totals differ by two commits.

| | full suite | scoped | nothing |
|---|---|---|---|
| before | 120 | 20 | 10 |
| after | 106 | 25 | 19 |

Full-suite rate 80.0% → 70.7%, verdict changed on 15 commits. Fourteen
de-escalated and I read every one of their file lists: they are
unit-lane bats files, non-e2e workflows, `docs/`, `CODEOWNERS`,
helm-unittest fixtures and linguist markers. The fifteenth gets one
suite *wider*: a `cert-manager` change now also selects `etcd`, which is
correct, since `cert-manager` is a dependency of `etcd-operator` and the
etcd app now genuinely reaches it through the new edge.

That replay predates the two reachability rules, and replaying the same
150 first-parent merges across those two on their own — the selector at
`73bc4c0ae` against the selector at `adf231513`, same sources on both
sides — changes no verdict at all. Two of the 150 touch
`cozystack-basics` and both ran the full suite before and after, which
is the hub exclusion keeping something true rather than failing to do
anything, and none of the 150 touch `kubevirt-cdi` or `vm-disk`, so the
mapping shows up only in a direct selection.

A few individual selections, before → after:

| changed files | before | after |
|---|---|---|
| `hack/select-e2e_test.bats` | 21 suites | *nothing* |
| `hack/select-e2e_test.bats` +
`packages/apps/redis/templates/redis.yaml` | 21 suites | `redis` |
| `hack/e2e-install-cozystack.bats` | 21 suites | 21 suites |
| `packages/tests/cozy-lib-tests/**` | 21 suites | *nothing* |
| `packages/system/.gitattributes` | 21 suites | *nothing* |
| `hack/e2e-apps/postgres.bats` | 21 suites | `postgres` |
| `packages/system/etcd-operator/values.yaml` | 21 suites | `etcd` |
| `packages/system/kubevirt-cdi/values.yaml` | 21 suites | `vminstance`
|
| `packages/apps/vm-disk/values.yaml` | 21 suites | `vminstance` |
| `packages/system/cozystack-basics/values.yaml` | 21 suites | 21 suites
|

## Blast radius of the `dependsOn` edge

A `dependsOn` on a PackageSource is an install-ordering edge in
production, not only a test-selection hint, so this is the part worth
reviewing hardest. Both Packages are emitted by
`packages/core/platform/templates/bundles/system.yaml` under the single
`bundles.system.enabled` guard with no intervening conditional between
them, in every variant, so no supported configuration has the app
without the operator. `hack/select-install.sh --validate` reports the
graph still has no cycle and nothing dangling. The one new exposure is
an admin who lists `cozystack.etcd-operator` in
`bundles.disabledPackages` while keeping the app, which now leaves it
`DependenciesNotReady`. That is the same exposure postgres, mariadb,
kafka and redis already carry, with `Package.spec.ignoreDependencies` as
the escape hatch. On upgrade `etcd-rd` waits for the operator's two
HelmReleases, and an unhealthy `etcd-operator` already means broken etcd
apps.

## Testing

Every rule added or changed has a test in `hack/select-e2e_test.bats`,
the install-side change has one in `hack/select-install_test.bats`, and
the new graph edge has a helm-unittest guard at
`packages/core/platform/tests/sources_etcd_application_dependson_test.yaml`.
The round-trip test that walks every suite through both mapping tables
stays green, `vminstance` included now that two sources map onto it.

The two reachability rules were each checked by removing the rule and
confirming its own test reddens: without the mapping the CDI test falls
back to the full-suite escalation, and without the hub exclusion
`cozystack-basics` selects `redis vminstance` instead of everything. The
hub test has to seed its own hazard, since no suite sits downstream of
basics in this tree yet, so it gives `redis-application` an edge onto
basics in a copied sources directory and pins that a redis change still
selects redis, or the assertion would pass against a graph that resolved
nothing.

`make bats-unit-tests` aborts on the first failing file (`for f in …; do
… || exit 1; done`), and `hack/ghcr-mirror_test.bats` fails on `main`
today and sorts before `select-e2e_test.bats`, so the aggregate target
never reaches these tests. I ran the 60 unit files individually instead:
59 pass, and the one failure is that pre-existing one, byte-identical to
the base commit and referencing nothing this branch touches. Also green:
`make helm-unit-tests`, `make rd-presets-check migrations-target-check
test-check-readiness`, `hack/select-install.sh --validate`, and
`pre-commit run --all-files` leaving a clean tree.

## Two things a reviewer should know, neither addressed here

`assert_selection` in `hack/select-e2e_test.bats` aborts the whole file
with `exit 1` rather than failing a single test. That is the only thing
that works under `hack/cozytest.sh`, which provides no real `run`,
`$status` or `skip`, and it is the idiom the existing tests in that file
already use. It becomes wrong when #3497 and #3498 flatten these onto
real `bats(1)`, where a failing assert should end one test and let the
rest report. Worth converting with that work rather than ahead of it.

`cozystack.mongodb-application` carries the identical missing operator
edge: `packages/apps/mongodb` renders `psmdb.percona.com/v1` while the
source depends only on `cozystack.networking` and
`cozystack.cozystack-engine`, so `cozystack.mongodb-operator` still
reaches no runnable suite and every change to it escalates to all 21.
Same defect class and same fix shape as the etcd edge, but it is a
second production install-ordering change and belongs in its own pull
request.


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

- **New Features**
- The default etcd application now includes its operator and required
platform dependencies, ensuring the EtcdCluster provider is available.

- **Bug Fixes**
- Improved end-to-end test selection for application-specific changes,
shared components, inert paths, and unresolved or unclassified files.
- Full-suite escalations now provide clear reasons through standard
error.

- **Tests**
- Expanded coverage for test selection, dependency reachability, and
etcd application dependency declarations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The suite was parked as chainsaw-test.yaml.disabled because its appliance
image was not published. That is no longer true, and two paths supply the
ref between them. On a PR touching a vyos path the build-vyos job builds
the golden image and stamps the real ref@digest into
packages/apps/site-router/images/vyos-router-disk.tag through a patch
fragment Finalize merges before E2E runs. On every other PR that job is
skipped, and the ref arrives from the base branch instead: the root
`build:` recipe builds packages/system/vyos-router-image before
packages/core/installer, so the cozystack-packages artifact pushed from
main already carries the stamped .tag and finalize's overlay copies it
in. The other two reasons the header gave are stale as well --
_probe-lib.sh is the live T13 implementation rather than a set of seams,
and the firewall syntax it drives is the one the chart ships.

Renaming the file is the whole of the mechanism: the selector enumerates
a suite by its chainsaw-test.yaml, so the suite joins TIA selection and
every full-suite escalation with nothing else to change.

This needed #3817, which is now merged and in this branch through the
merge below it. Un-parking makes site-router the first suite to sit
downstream of cozystack-basics, and before that PR's propagation-hub
exclusion the platform's namespace-and-policy package silently dropped
from the full run to this one suite. Three guards pinned that and went
red: the two escalation guards on cozystack-basics and the exact-set
guard on system/postgres-operator, which reached this suite through the
same hub. With the exclusion in the tree, cozystack-basics escalates
again and postgres-operator is back to `harbor postgres`.

Two CDI guards move from an exact set to membership, for one reason in
both directions. kubevirt-cdi provisions the gateway's boot DataVolume,
so it reaches this suite as well as vminstance, and its selection is now
`site-router vminstance`. #3817's guard pinned `vminstance` alone and
this branch's own guard pinned `site-router` alone; each would read the
other's correct widening as a regression. What both rules actually owe
is that their suite is IN the selection, so that is what they assert.
Neither loses its teeth: dropping vminstance from the mapping still
fails the first, and stripping site-router from the selector output --
the #3392 shape -- still fails the second.

The stale claims inside the test go with the header: the three
"TODO(T13): live -- implement ..." notes described _probe-lib.sh helpers
that are written, and the port_security step called the VyOS 1.5
firewall syntax provisional after security-model.md recorded it as
validated live. What the header now records instead is the one thing the
two supply paths do not cover: when neither fires, the committed value
is a bare v0.0.0 placeholder that no build path ever publishes, and the
bring-up guard only rejects an EMPTY ref, so the run fails as a
VM-never-ready timeout rather than naming the cause.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
CDI's docker:// transport cannot parse an image reference that carries
both a tag and a digest. Its importer rejects the reference before any
pull is attempted:

    Could not parse image: Docker references with both a tag and digest
    are currently not supported

which surfaces as a DataVolume in ImportInProgress with printableStatus
DataVolumeError and an importer restarting forever, never as a bad
reference. Every site-router boot disk hit this once the appliance was
stamped: the Chainsaw suite died at step 1 of 17 while a control
DataVolume in the same namespace, on the same CDI, reached
ImportSucceeded.

The stamp itself is not malformed. `<repo>:<tag>@sha256:<digest>` is
the shape hack/lib/image-refs.sh greps for and the promote, retag and
mirror tooling read from there, and kubelet parses it happily for every
other image in the tree — so it is not the stamp's job to change.
site-router is the only consumer in the tree that feeds a stamped
reference into a CDI source.registry, which is why nothing caught this,
and the fix therefore belongs at the point of consumption:
site-router.applianceDiskUrl drops the tag and keeps the digest, and
the e2e bring-up of remote-site B applies the same normalisation in
shell so both ends of the tunnel boot the same appliance.

The helper also rejects a reference carrying no @sha256: digest, which
the previous `required` guard passed through: the committed v0.0.0
placeholder, which no build path here publishes, rendered a DataVolume
that imported nothing for minutes and left the gateway on a VM that
never booted. That is now an immediate render failure naming the cause
and the remedy. While the placeholder is committed the chart cannot
render in-tree at all, so the rendered-URL assertions live in a bats
suite that can write the .tag before rendering, tests/dv_test.yaml
asserts the guard instead, and the values matrix names the templates it
renders.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
…way pod

discoverGatewayPod listed candidate pods by the lineage labels alone
(apps.cozystack.io/application.{kind,name}). Those labels identify the
application instance, not the gateway VM, and the lineage webhook stamps
them on every pod whose ownership graph reaches the instance's
HelmRelease. The CDI importer for the boot DataVolume is one of those:
helm-controller's origin-label post-renderer puts
helm.toolkit.fluxcd.io/name on the DataVolume, the webhook's owner walk
resolves importer pod -> prime PVC -> DataVolume -> HelmRelease -> the
SiteRouter app, and the importer pod is labelled exactly like the gateway
pod at CREATE.

The importer is Running before the virt-launcher pod is, so it won the
Running preference and became the gateway for as long as the import
lasted. The caller treats whatever it gets as the gateway:
programNamespaceRoutes installs the pod IP as the tenant's kube-ovn next
hop for every remoteCIDR and persists it as the route-ownership
annotation, and pushVyOSConfig POSTs the rendered router configuration
with the management-API token in the form body to
https://<that pod IP>/configure over a connection with
InsecureSkipVerify set. So the failure mode is tenant return traffic
pointed at an arbitrary pod plus the instance's API token offered to it,
not merely a wasted request.

Add vm.kubevirt.io/name=site-router-<instance> to the selector, using the
constant already declared for it. KubeVirt copies the VMI labels onto the
virt-launcher pod, so every real gateway pod carries it, and both pods of
a live migration carry it — the Running preference still resolves that
case unchanged.

The tests construct the threat directly: a Running pod with the lineage
labels and no VM-name label, sorting ahead of virt-launcher-* by name.
Without the new selector term it is returned as the gateway, wins over
the real gateway pod when both exist, and drives both the config push
endpoint and the namespace route next hop to its own IP.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
…uite

Nothing in the run that took the site-router suite to establish-tunnel
could say what either VyOS guest was doing. Both VMIs reported Running
with an address, both virt-launchers were CPU-saturated, and neither
served its HTTPS management API inside the step's budget -- and there is
no artifact anywhere that distinguishes "slower here than on a dev stand"
from "the API never comes up at all". A VMI reporting Running says only
that qemu started, and the suite's catch collects nothing from inside
either guest.

KubeVirt streams the guest serial console into a guest-console-log
container beside virt-launcher when the VMI asks for it, and crust-gather
already collects that container's log. So enabling it is the difference
between the next failure arriving with VyOS's own boot output attached and
repeating this one.

Rather than hardcode the flag on the gateway VM, follow the convention
packages/apps/kubernetes already established for it: an opt-in chart
value, rendered only when true, defaulting off. The flag overrides the
platform's cluster-wide virtualMachineOptions.disableSerialConsoleLog,
which packages/system/kubevirt sets because the guest-console-log
container has been seen holding virt-launcher in PodInitializing
(kubevirt/kubevirt#15989). Hardcoding it would take that override away
from every site-router gateway to buy diagnostics for CI. Rendering
nothing on an explicit false matters for the same reason: KubeVirt's
tri-state false would opt a gateway that asked for nothing back into the
container the platform switched off.

The e2e suite then sets it on both ends -- through spec.values for A (the
chart-rendered gateway) and directly on the devices list for B (the raw
KubeVirt fixture). establish-tunnel's merge patch of spec.values.peer
leaves A's value alone.

The chart tests are the kubernetes chart's three cases: absent by
default, absent when explicitly false, present when enabled. Their red
phases are distinct -- rendering the value unconditionally fails the
first two, dropping the block fails the third.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
logSerialConsole gets the guest console captured, which carries kernel and
boot output. That is necessary and not sufficient: it does not say whether
cloud-init finished, whether the seeded config committed, whether the
management API's listener ever bound, or whether the box was still working
or wedged -- and those are exactly the readings the establish-tunnel
failure could not be explained without.

The collection path must not run through the thing that is broken. The
HTTPS management API is what fails, so nothing here reaches the guest
through it: the appliance has no SSH service and a locked login, and
guest-exec is a declared Phase-1 non-goal, which leaves the serial console
as the one channel that works. files/guest-diag.sh prints a compact
[cozy-diag] block there covering cloud-init status, whole-system health
and failed units, whether the config seed reached the ACTIVE config tree,
nginx (the :443 listener on this appliance) and the listener table, the
IPsec unit and its SAs, and loadavg plus the top CPU consumers -- that
last field being what separates a slow boot from a hung service.

cron drives it, and the alternatives are all unusable rather than merely
worse. cloud_final_modules is empty on this image so runcmd, bootcmd and
scripts-per-boot never run, leaving write_files as the only delivery.
/etc/rc.local is decided by systemd-rc-local-generator in early boot,
before cloud-init writes anything. A unit plus a .wants entry needs a
daemon-reload because the multi-user.target transaction is already
computed. VyOS task-scheduler lives in config.boot, so it exists only once
the config commits, which is one of the things being diagnosed. cron
rescans /etc/cron.d every minute, needs no reload, and VyOS's own
task-scheduler is built on it. Nothing is added to config.boot: that file
was captured verbatim from VyOS `save`, and a diagnostics change must not
be able to cause the outage it exists to explain.

Off by default and gated on logSerialConsole, because the flag already
overrides the platform-wide disableSerialConsoleLog and the emitter is
pointless without the capture. Self-limiting in time: past 20 minutes of
uptime every tick exits in milliseconds, so a long-lived gateway pays
nothing and puts nothing on the console. Every probe is timeout-bounded
and every field is line-capped with truncation announced, because a
diagnostic that hangs or floods is worse than none on a box already
suspected of being wedged.

An empty or missing files/guest-diag.sh fails the render rather than
installing a cron job with no script, which would boot clean, fire every
minute and print nothing.

The tests execute the emitter against a stubbed guest rather than grepping
the template: red phases cover the emitter writing nowhere, a flooding
field burying the rest, emission past the window, the chart shipping
nothing, the chart shipping unconditionally, and an empty emitter file
rendering clean.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
The emitter from the previous commit prints into A's console because the
chart installs it. B is a raw KubeVirt manifest with no templating, so the
bring-up step injects the same file from the one copy in the tree. B needs
this more than A does, not less: the loop that actually times out
("timeout committing B IPsec") is talking to B's management API, and the
last diagnosis was credible precisely because the same symptom showed up
on both ends through two different config paths -- an argument that needs
both guests reporting in the same format.

Injection is awk, not sed. The script contains `2>&1`, and sed reads `&`
in a replacement as the whole match, so a sed-based injection installs a
corrupted script that cron will happily run. Taking the indent from the
placeholder line keeps the YAML block scalar valid, and a placeholder that
survives substitution now fails the step with a message naming the cause
instead of booting a guest whose diagnostic is the literal placeholder.

A diagnostic that is not collected has not solved anything, so the suite's
catch dumps both guest consoles. It writes the full logs as files under
COZY_REPORT_DIR -- the tree hack/cozyreport.sh folds into cozyreport.tgz --
rather than relying on crust-gather, whose collect runs under a 180s
budget it has been observed exceeding on a cluster this size. The
[cozy-diag] timeline is additionally echoed into the job log, where it is
readable without downloading an artifact, and a console with no
[cozy-diag] lines is reported as such rather than as an empty console. The
op carries its own timeout above the sum of its inner budgets, per the
rule in .chainsaw.yaml, so an inner timeout fires instead of Chainsaw
SIGKILLing the collector; it ends in exit 0 so diagnostics can never turn
a failed assertion into a failed catch. No existing timeout, poll interval
or step budget is touched.

Red phases: B injected with sed instead of awk, a surviving placeholder,
the catch entry deleted, the catch reading the pod log without naming the
guest-console-log container, and the catch writing only to the job log
instead of the report tree.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants