feat(site-router): add routed site-to-site IPsec gateway app (Phase 1) - #3426
feat(site-router): add routed site-to-site IPsec gateway app (Phase 1)#3426myasnikovdaniil wants to merge 14 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesSiteRouter contracts and packaging
Controller and VyOS runtime
Deferred acceptance coverage
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (5)
internal/controller/siterouter/status.go (1)
65-68: 🚀 Performance & Scalability | 🔵 TrivialUncached full-namespace pod
Liston every 30s poll can pressure the apiserver at scale.
surfacePendingRoutePodsruns on every reconcile (runtimePollInterval), and thisListgoes 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 winPrefer
COPYoverADDfor local files.
ADDis only needed for its URL-fetch/archive-auto-extraction behavior; a local qcow2 copy doesn't use either.COPYis 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 winUse bracket notation for the annotation key in this JSONPath filter. It’s the safer form for dotted annotation keys in
kubectlJSONPath 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
WithInsecureSkipVerifyclones the wrong base transport and skipsMinVersion.Two issues in this option:
- It clones
http.DefaultTransportunconditionally instead of the client's currentc.http.Transport. If a caller combinesWithHTTPClient(supplying a custom*http.Client/Transport, e.g. for a proxy) withWithInsecureSkipVerify, this silently discards that custom transport's settings and mutates the caller-suppliedhttp.Clientobject'sTransportfield in place — a surprising side effect for API consumers who don't expect option ordering to matter this much.- Both
tls.Config{InsecureSkipVerify: true}literals omitMinVersion. Even given the documented in-band-token rationale for skipping cert verification, pinningMinVersion: 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 valueClusterRole grant is not namespace-scoped.
resourceNames: ["cozystack"]restricts by name but this is aClusterRole, so the grant applies to anyconfigmaps/cozystackin any namespace, not onlycozy-system/cozystack(the only onerest_siterouter.goreads). 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
⛔ Files ignored due to path filters (1)
packages/apps/site-router/logos/site-router.svgis excluded by!**/*.svg
📒 Files selected for processing (97)
.github/workflows/pull-requests.yamlMakefileapi/apps/v1alpha1/siterouter/types.goapi/apps/v1alpha1/siterouter/zz_generated.deepcopy.gocmd/site-router-controller/main.gohack/build-matrix.shhack/build-matrix_test.batshack/e2e-chainsaw/site-router/_probe-lib.shhack/e2e-chainsaw/site-router/backend.yamlhack/e2e-chainsaw/site-router/chainsaw-test.yamlhack/e2e-chainsaw/site-router/fresh-route-pod.yamlhack/e2e-chainsaw/site-router/pending-route-pod.yamlhack/e2e-chainsaw/site-router/remote-site-b.yamlhack/e2e-chainsaw/site-router/site-router-a.yamlhack/select-e2e.shhack/select-e2e_test.batsinternal/controller/siterouter/cnimediation.gointernal/controller/siterouter/cnimediation_test.gointernal/controller/siterouter/metrics.gointernal/controller/siterouter/metrics_test.gointernal/controller/siterouter/reconciler.gointernal/controller/siterouter/reconciler_test.gointernal/controller/siterouter/status.gointernal/controller/siterouter/status_test.gointernal/controller/siterouter/vyospush.gointernal/controller/siterouter/vyospush_test.gointernal/siterouter/denyset/denyset.gointernal/siterouter/denyset/denyset_test.gointernal/vyos/client.gointernal/vyos/client_test.gointernal/vyos/observation.gointernal/vyos/parse.gointernal/vyos/parse_test.gointernal/vyos/render/render.gointernal/vyos/render/render_netnew_test.gointernal/vyos/render/render_security_test.gointernal/vyos/render/render_test.gopackages/apps/site-router/.helmignorepackages/apps/site-router/Chart.yamlpackages/apps/site-router/Makefilepackages/apps/site-router/README.mdpackages/apps/site-router/charts/cozy-libpackages/apps/site-router/docs/PR-BODY.mdpackages/apps/site-router/docs/followups.mdpackages/apps/site-router/docs/image-lifecycle.mdpackages/apps/site-router/docs/security-model.mdpackages/apps/site-router/templates/_helpers.tplpackages/apps/site-router/templates/dashboard-resourcemap.yamlpackages/apps/site-router/templates/dv.yamlpackages/apps/site-router/templates/networkpolicy.yamlpackages/apps/site-router/templates/secret-cloudinit.yamlpackages/apps/site-router/templates/secret-psk.yamlpackages/apps/site-router/templates/service.yamlpackages/apps/site-router/templates/vm.yamlpackages/apps/site-router/templates/workloadmonitor.yamlpackages/apps/site-router/tests/dv_test.yamlpackages/apps/site-router/tests/networkpolicy_test.yamlpackages/apps/site-router/tests/policy_workloadmonitor_todo_test.yamlpackages/apps/site-router/tests/secret_cloudinit_test.yamlpackages/apps/site-router/tests/secret_psk_test.yamlpackages/apps/site-router/tests/service_test.yamlpackages/apps/site-router/tests/values_matrix_test.yamlpackages/apps/site-router/tests/vm_test.yamlpackages/apps/site-router/tests/workloadmonitor_test.yamlpackages/apps/site-router/values.schema.jsonpackages/apps/site-router/values.yamlpackages/core/platform/sources/site-router-application.yamlpackages/core/platform/sources/site-router-controller.yamlpackages/core/platform/templates/bundles/naas.yamlpackages/core/platform/tests/bundles_site_router_naas_test.yamlpackages/system/cozystack-api/templates/rbac.yamlpackages/system/site-router-controller/Chart.yamlpackages/system/site-router-controller/Makefilepackages/system/site-router-controller/images/site-router-controller/Dockerfilepackages/system/site-router-controller/templates/deployment.yamlpackages/system/site-router-controller/templates/podscrape.yamlpackages/system/site-router-controller/templates/rbac-bind.yamlpackages/system/site-router-controller/templates/rbac.yamlpackages/system/site-router-controller/templates/sa.yamlpackages/system/site-router-controller/values.yamlpackages/system/site-router-rd/Chart.yamlpackages/system/site-router-rd/Makefilepackages/system/site-router-rd/cozyrds/site-router.yamlpackages/system/site-router-rd/templates/cozyrd.yamlpackages/system/site-router-rd/values.yamlpackages/system/vm-default-images/images/vyos-router-disk.tagpackages/system/vm-default-images/templates/dv.yamlpackages/system/vm-default-images/values.yamlpackages/system/vyos-router-image/Chart.yamlpackages/system/vyos-router-image/Makefilepackages/system/vyos-router-image/flavors/vyos-router.tomlpackages/system/vyos-router-image/hack/build-qcow2.shpackages/system/vyos-router-image/images/vyos-router-disk/Dockerfilepkg/apis/apps/validation/validation.gopkg/registry/apps/application/rest.gopkg/registry/apps/application/rest_siterouter.gopkg/registry/apps/application/rest_siterouter_admission_test.go
956b527 to
4d98441
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
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:786 — bgp.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:42setsDEFERRED_SUITES="site-router"andstrip_deferredremoves it from all CI paths, so it never runs. The security guards (CiliumegressDeny, 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.includeand thedashboard-resourcemap.yamlRole; only the PSK (tenant's own key) and the tunnel Service are exposed touse.
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-editonpackages/apps/site-router/charts/cozy-libis a false positive: it is a symlink (git mode120000), same asapps/vpn/apps/kafka. Not a vendored edit.- The 3
chart_lint.render_errorsare harness artifacts (site-router renders cleanly undertenant-*; platform OCIRepository is alookupdependency; the cozystack-api_clusternil is a runtimecozystack-valuesinjection). - The deny-set (
denyset.go:84-89) intentionally leavesNodeCIDRs/LBPoolsempty, 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, cozyrdsresourceNamesmatch the Role, no reconcile amplifier,go build/vet/testgreen, 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.
|
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 The default load path could not work whether or not the image was published. 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 On the placeholder digest — a real one is a merge prerequisite, and that is now mechanical rather than remembered: 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 Your "legible status on the missing-source path" is addressed, but not the way I first tried. I added a controller-side On item 8 — the status column did read 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: Your recommended dev-cluster run is still outstanding: |
10cb5f3 to
c2abcf1
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
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-661 → internal/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-160 — removeRoutes 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), newSiteRouterkind, newcozy-site-routernamespace. 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 onbundles.iaas.enabled(avoids the isp-hosted #3376 deadlock), controllerdependsOnvictoria-metrics-operator for its unconditional VMPodScrape, cozystack-api ConfigMap grant name-scoped.go build+ all Go unit tests + all 63 helm-unittests green;make generatenot 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.shDEFERRED_SUITES="site-router"), so the guards' runtime efficacy is unproven in CI. - Management-API isolation:
managementCIDRdefaults 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 + AppDefsecrets.include— verified), but a tenantadmincan read its own gateway's token and reconfigure the router (within the tenant's own blast radius).
Recommended follow-ups
cozystack-pr-testnegative-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.
|
IvanHunters Addressed the actionable code and documentation gaps in
The tenant-admin RBAC omission for Validation passed with the full Go suite, the standalone application API module, all affected Helm unit suites, package generation, and |
IvanHunters
left a comment
There was a problem hiding this comment.
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, whichmake unit-testsruns in CI. Reproduced:grep -rIlE '@sha256:0{64}' packages/ --exclude-dir=chartsreturns exactly this file, so the gateexit 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
DataVolumeresolves nowhere → boot PVC staysPendingforever and the VM never boots.dv.yaml'srequiredguard 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 theSiteRouterkind 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 asPodCIDR(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 ofsource ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is0.0.0.0/0and no NAT is rendered, so the decrypted packet keeps the remote peer's real source IP.docs/security-model.md:27states 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 baselineallow-external-communication(packages/apps/tenant/templates/networkpolicy.yaml) unconditionally admitsfromEntities: [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),/0rejection, unconditional link-local/loopback, and bidirectionalOverlapsare 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 indashboard-resourcemap.yaml, and the tenant ClusterRoles grant no blanketget secrets; the guest login is locked (encrypted-password "*", noservice ssh). - Controller RBAC is least-privilege (
secrets: getonly, 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-electis hardcoded, so the in-memory hash cache is safe.
869bafb to
8fbe389
Compare
|
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
Fixed by parking the suite as Three real unit test failures behind your item 5:
Security surface is byte-identical to pre-rebase, checked file by file: Both your blockers stay open. Placeholder digest is deliberate, Please take another look at the delta. |
8fbe389 to
b17e76d
Compare
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>
b17e76d to
ce01379
Compare
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
left a comment
There was a problem hiding this comment.
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:686turns that intoTUNNEL-INGRESSaccepts ofsource ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is0.0.0.0/0and no NAT is rendered, so the remote peer's source IP is preserved.port_securityis 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 admitsingress.fromEntities: [world, cluster]. - Deny-set validation forces every
remoteCIDRto be disjoint from all cluster networks, so the decrypted packet's preserved source is always an external IP, which Cilium classifies as identityworld. worldis 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)
- 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.
- 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.mdwording, 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>
|
Pushed You were right about cross-tenant reach, and the cilium claim in the doc was wrong in two places. About non-conformance, i cant find 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
|
IvanHunters
left a comment
There was a problem hiding this comment.
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]
renderTunnelIngressFilterdegrades an emptyTenantNetworkCIDRsto a source-only accept, which is fail-open, and the never-empty-at-push-time invariant it leans on is IPv4-specific:tenantNetworkCIDRsonly addsaddr.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>
## 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>
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 newsite-router-controllermediates the pieces the chart cannot express — deny-set validation of the tunnel's remote networks, the kube-ovn return-route annotation, gateway-portport_securityrelaxation gated on the guest source filter, the live VyOS configuration push over the management API, tunnel/BGP observability, and status. No new tenant CRD: theapps.cozystack.io/SiteRouterapp 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
packages/apps/site-router— gatewayVirtualMachine(512/4096 blockSize for DRBD), boot DataVolume, tunnelService type: LoadBalancer(UDP 500/4500, nativeloadBalancerClass), PSK + RBAC-isolated api-key Secrets, first-boot cloud-init, WorkloadMonitor, and two net-new Cilium policies (gatewayegressDeny+ 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.internal/vyos(client/parse/observation) + routed renderinternal/vyos/render(interfaces, management firewall, IPsec forced-UDP, static routes, BGP, MSS clamp, tunnel-ingress source filter, forward default-deny, Boundary-A drop).internal/siterouter/denysetvalidator + a SiteRouter-scoped admission check (pkg/registry/apps/application) that reject a cluster-overlappingremoteCIDRidentically at apply time and reconcile time.README.md(prose + generated params),docs/security-model.md,docs/image-lifecycle.md,docs/followups.md.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).Service type: LoadBalancer(UDP) with clusterloadBalancerClassport_securityrelax, source filter, status, delete-restoreegressDeny(169.254 + mgmt), forward default-deny, two-boundary API isolation, api-key not tenant-readableThe 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 theipsec match-ipsec-in/match-none-inmatchers) 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 VyOSruns on this PR, publishes the appliance and stamps the realref@digestintopackages/apps/site-router/images/vyos-router-disk.tagthrough a patch fragment that Finalize merges before the E2E job, so the committedv0.0.0placeholder is never what the sandbox pulls, and_probe-lib.shis the live implementation rather than a set of seams. Renamingchainsaw-test.yaml.disabledback is the whole of the un-park, since the selector enumerates a suite by itschainsaw-test.yaml.It is held out of this PR deliberately. Un-parking makes
site-routerthe first suite in the tree to sit downstream ofcozystack.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 tosite-routeralone, andhack/select-e2e_test.batsgoes 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 waycozystack.cozystack-enginealready 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:vyos-build, publish like the Talos image; then pin URL +@sha256).port_security(pending upstream kube-ovn CIDR-AAP support)._cluster.pod-cidrderivation formanagementCIDR(custom-pod-CIDR clusters without manual config).site-gateway(NAT) / Phase 3 WireGuard backend / Phase 4 HA + per-tenant egress IP + initiator model.Repo hygiene (pre-merge)
feat(site-router):title auto-applieskind/feature. To auto-applyarea/networking(rather than falling toarea/uncategorized), add'site-router': 'area/networking'to the scope mapping in.github/workflows/pr-labeler.yaml(alongside the existingvpn/gateway/kube-ovnentries) —area/networkingalready exists in.github/labels.yml.site-router.🤖 Generated with Claude Code
Summary by CodeRabbit