From d73c102945aa5f891616a601bc8e6bfb3d805550 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Fri, 7 Aug 2026 15:22:17 +0300 Subject: [PATCH] test(system): anchor index-addressed negative asserts to what they name notContains passes whenever its path does not resolve, and also whenever it resolves to something that simply lacks the forbidden content. A different container at the same index qualifies, so these guards go green the moment a sidecar is placed ahead of the workload they were written for, while the flag or env var they forbid comes back on the real container. Assert the identity at that index first, in each affected case: the container name for the Deployment-shaped ones, and for the Cilium policy its selector, since a deny rule has no name. The selector identifies that rule only while there is one rule to identify, so it is paired with a length assertion, and with a second one on the rule's toPorts, because that path carries two indexes and anchoring the outer one alone leaves the inner free. The anchor goes only where nothing else holds the index. A positive contains on the same path fails on its own once a sidecar arrives ahead of the workload, and an equal on the whole argument list fails the same way, so neither carries a separate anchor. A negative does, including the per-element form below: a wildcard resolving to the sidecar's own arguments matches nothing and passes. Several of the guards could not fail for the reason they name, and the anchor alone would have left that hidden behind a comment saying they were covered. contains and notContains compare elements for equality, so a mapping is compared whole: `{name: GF_...}` never matched an entry carrying a value beside the name, and `{port: 7473}` would not match that port re-added with a protocol beside it, which is how the neighbouring entry in the same list is written. Those get `any: true`. Equality against a rendered argument is the same failure worn differently, and it arrives in three disguises. A prefix that never equals an element: `--pinned-tenants=` never equalled `--pinned-tenants=tenant-a=shard0`, and `--target-host=` never equalled a rendered `--target-host=`. One value out of many under a name that claims the flag: `--compatibility=cilium` left the kilo default render free to carry `--compatibility=flannel` off the same values key, and the two `--proxy-fqdn` negatives forbade only the FQDN the default cluster-domain produces. And a form that holds by luck: `--gateway-api` still fails today only because that template renders it bare, so an upstream switch to `--gateway-api=true` would retire the guard in silence. All of those become notMatchRegex over args[*], which matches the flag name per element and fails on any value and on the bare form alike. A length assertion would not have covered the prefix cases, since an upstream bump that adds the flag while consolidating another argument keeps the count. A wildcard that resolves to nothing still passes, so the index anchors stay. The whole argument list is a different instrument, answering what the default invocation is rather than whether one flag is absent. It fails on an argument added, removed or swapped instead of only on the ones someone thought to enumerate, which is worth having where the template is first-party and a failure is a deliberate change asking to be read. flux-shard-operator gets it in `renders the default Deployment shape`, replacing eleven individual contains that listed exactly the same eleven elements; `--shard-cpu-limit=` was dead for the prefix reason and is dropped rather than repaired, because that list now covers it. The vendored ouroboros templates get no such pin: upstream churn arrives there on its own schedule, and a whole-list assertion would fail a test named for one flag for a reason that has nothing to do with that flag. Anchor every index on a path, not the outermost one. The Cilium assertion reads spec.ingressDeny[0].toPorts[0].ports, and pinning the rule list alone would still let the port return on a second toPorts entry of the same rule. Each guard was checked by rendering what it forbids onto the element it names, and each anchor by rendering the shift it exists to catch. The form is already used in this tree, in opensearch-operator and dashboard. Assertions of the same shape remain in files this commit does not touch, including one in a package it does, and stay out of scope here. Assisted-By: Claude Signed-off-by: Aleksei Sviridkin Signed-off-by: Aleksei Sviridkin <3811295@gmail.com> --- .../tests/networkpolicy_test.yaml | 28 ++++++++ .../tests/workload_test.yaml | 72 ++++++++----------- .../keycloak/tests/encryption_test.yaml | 7 ++ packages/system/kilo/tests/kilo_test.yaml | 19 ++++- .../system/monitoring/tests/oidc_test.yaml | 13 ++++ .../ouroboros/tests/controller_test.yaml | 40 +++++++---- .../tests/cozystack_overrides_test.yaml | 12 +++- 7 files changed, 132 insertions(+), 59 deletions(-) diff --git a/packages/system/cilium-networkpolicy/tests/networkpolicy_test.yaml b/packages/system/cilium-networkpolicy/tests/networkpolicy_test.yaml index b16f6fa70..e21003e04 100644 --- a/packages/system/cilium-networkpolicy/tests/networkpolicy_test.yaml +++ b/packages/system/cilium-networkpolicy/tests/networkpolicy_test.yaml @@ -81,7 +81,35 @@ tests: # bound by anything cozystack ships, so listing it in the deny rule is # misleading. asserts: + # Anchor the rule before asserting about it. notContains passes whenever + # its path resolves to something that lacks the content, and a different + # deny rule at [0] qualifies, so reordering spec.ingressDeny would leave + # this green while 7473 came back on the rule it names. A deny rule has no + # name, so the anchor is its selector, which is what identifies it rather + # than one of the ports it happens to list. + # + # The selector identifies it only while there is one rule to identify: a + # second world-scoped deny inserted ahead of this one would satisfy the + # selector and shift the real rule to [1], which is the reordering above. + # lengthEqual is what makes the selector sufficient. + # Both indexes in the path, not just the outer one. A second toPorts entry + # on the same rule carries its own ports list, so 7473 could come back + # there with the rule count and the selector both still satisfied. + - lengthEqual: + path: spec.ingressDeny + count: 1 + - lengthEqual: + path: spec.ingressDeny[0].toPorts + count: 1 + - contains: + path: spec.ingressDeny[0].fromEntities + content: world + # any: true matches on the port alone. Without it the comparison is a + # deep-equal against the whole entry, so 7473 re-added with a protocol + # beside it, which is how the neighbouring 7946 entry is written, would + # slip past a guard that looks like it forbids the port outright. - notContains: path: spec.ingressDeny[0].toPorts[0].ports + any: true content: port: "7473" diff --git a/packages/system/flux-shard-operator/tests/workload_test.yaml b/packages/system/flux-shard-operator/tests/workload_test.yaml index a7c3166fc..6d2ddd26c 100644 --- a/packages/system/flux-shard-operator/tests/workload_test.yaml +++ b/packages/system/flux-shard-operator/tests/workload_test.yaml @@ -17,48 +17,31 @@ tests: - equal: path: spec.template.spec.serviceAccountName value: flux-shard-operator - - contains: + # The whole list, so the pin fails on an argument added, removed or + # swapped rather than only on the ones someone thought to enumerate. + # This template is first-party, so a failure here is a deliberate change + # asking to be read, not upstream churn arriving on its own schedule. + # + # --metrics-bind-address and --metrics-secure are load-bearing among + # these: the README-advertised cozy_flux_shard_* gauges, autosizing + # recommendation included, are the operator's observability surface. + - equal: path: spec.template.spec.containers[0].args - content: --leader-elect - - contains: - path: spec.template.spec.containers[0].args - content: --flux-namespace=cozy-fluxcd - - contains: - path: spec.template.spec.containers[0].args - content: --shard-count=auto - - contains: - path: spec.template.spec.containers[0].args - content: --shard-concurrent=5 - - contains: - path: spec.template.spec.containers[0].args - content: --rebalance-threshold=0.25 - - contains: - path: spec.template.spec.containers[0].args - content: --shard-cpu-request=100m - - contains: - path: spec.template.spec.containers[0].args - content: --shard-memory-request=64Mi - - contains: - path: spec.template.spec.containers[0].args - content: --shard-memory-limit=1Gi - - notContains: - path: spec.template.spec.containers[0].args - content: --shard-cpu-limit= - - contains: - path: spec.template.spec.containers[0].args - content: --zap-log-level=info + value: + - --leader-elect + - --metrics-bind-address=:8080 + - --metrics-secure=false + - --flux-namespace=cozy-fluxcd + - --shard-count=auto + - --shard-concurrent=5 + - --rebalance-threshold=0.25 + - --shard-cpu-request=100m + - --shard-memory-request=64Mi + - --shard-memory-limit=1Gi + - --zap-log-level=info - equal: path: spec.template.spec.volumes[0].secret.secretName value: flux-shard-operator-cert - # The metrics endpoint must stay reachable: the README-advertised - # cozy_flux_shard_* gauges (incl. the autosizing recommendation) are the - # operator's observability surface. - - contains: - path: spec.template.spec.containers[0].args - content: --metrics-bind-address=:8080 - - contains: - path: spec.template.spec.containers[0].args - content: --metrics-secure=false - equal: path: spec.template.metadata.annotations["prometheus.io/scrape"] value: "true" @@ -72,9 +55,16 @@ tests: - it: does not render --pinned-tenants by default template: templates/workload.yaml asserts: - - notContains: - path: spec.template.spec.containers[0].args - content: --pinned-tenants= + - equal: + path: spec.template.spec.containers[0].name + value: flux-shard-operator + # Match the flag name per element, not one whole element against one + # value: contains and notContains compare elements for equality, so + # `--pinned-tenants=` never equals a rendered + # `--pinned-tenants=tenant-a=shard0` and the negative could not fail. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--pinned-tenants" - it: renders shardCount and pinned tenants from values template: templates/workload.yaml diff --git a/packages/system/keycloak/tests/encryption_test.yaml b/packages/system/keycloak/tests/encryption_test.yaml index f2b400503..94d4a0b8e 100644 --- a/packages/system/keycloak/tests/encryption_test.yaml +++ b/packages/system/keycloak/tests/encryption_test.yaml @@ -445,6 +445,13 @@ tests: root-host: example.org cluster-domain: cozy.local asserts: + # Anchor the index before asserting on it. notContains passes whenever its + # path resolves to something that lacks the content, and a different + # container at [0] qualifies, so without this the guard goes green if a + # sidecar is ever placed ahead of the proxy. + - equal: + path: spec.template.spec.containers[0].name + value: proxy - notContains: path: spec.template.spec.containers[0].env any: true diff --git a/packages/system/kilo/tests/kilo_test.yaml b/packages/system/kilo/tests/kilo_test.yaml index d12e3a4ef..d9d311b49 100644 --- a/packages/system/kilo/tests/kilo_test.yaml +++ b/packages/system/kilo/tests/kilo_test.yaml @@ -61,6 +61,19 @@ tests: - it: "no --compatibility arg in the default render" asserts: - - notContains: - path: spec.template.spec.containers[0].args - content: "--compatibility=cilium" + # Anchor the index before asserting on it. A negative assert passes + # whenever its path resolves to something that does not match, and a + # different container at [0] qualifies — as does an index resolving to + # nothing at all — so without this the guard goes green if a sidecar is + # ever placed ahead of kilo. + - equal: + path: spec.template.spec.containers[0].name + value: kilo + # Match the flag name per element, not one whole element against one + # value: contains and notContains compare elements for equality, and the + # chart renders --compatibility= off a single values key, so an + # equality check against the cilium value leaves every other value + # unforbidden. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--compatibility" diff --git a/packages/system/monitoring/tests/oidc_test.yaml b/packages/system/monitoring/tests/oidc_test.yaml index 3f6d25817..7c694d990 100644 --- a/packages/system/monitoring/tests/oidc_test.yaml +++ b/packages/system/monitoring/tests/oidc_test.yaml @@ -79,12 +79,25 @@ tests: asserts: - notExists: path: spec.config["auth.generic_oauth"] + # Anchor the index before asserting on it. notContains passes whenever its + # path resolves to something that lacks the content, and a different + # container at [0] qualifies, so without this both guards go green if a + # sidecar is ever placed ahead of grafana in the CR's deployment spec. + - equal: + path: spec.deployment.spec.template.spec.containers[0].name + value: grafana + # any: true matches on the element name alone. Without it the comparison + # is a deep-equal against the whole entry, and the real ones carry a + # valueFrom or a value alongside the name, so {name: ...} matches nothing + # and the guard passes whether or not the variable is rendered. - notContains: path: spec.deployment.spec.template.spec.containers[0].env + any: true content: name: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET - notContains: path: spec.deployment.spec.template.spec.containers[0].env + any: true content: name: GF_PATHS_CUSTOM_INI diff --git a/packages/system/ouroboros/tests/controller_test.yaml b/packages/system/ouroboros/tests/controller_test.yaml index 273effe61..3329cb66e 100644 --- a/packages/system/ouroboros/tests/controller_test.yaml +++ b/packages/system/ouroboros/tests/controller_test.yaml @@ -22,16 +22,32 @@ tests: - contains: path: spec.template.spec.containers[0].args content: --proxy-service-namespace=cozy-ouroboros - - notContains: - path: spec.template.spec.containers[0].args - content: --proxy-fqdn=ouroboros-proxy.cozy-ouroboros.svc.cluster.local. + # Match the flag name per element, not one whole element against one + # value: contains and notContains compare elements for equality, and the + # baked FQDN carries whatever controller.clusterDomain is set to, so an + # equality check against the form the default cluster-domain produces + # leaves every other value unforbidden. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--proxy-fqdn" - it: leaves Gateway-API watching off at the parent default (consumer enables) template: charts/ouroboros/templates/controller-deployment.yaml asserts: - - notContains: - path: spec.template.spec.containers[0].args - content: --gateway-api + # Anchor the index before asserting on it. A negative assert passes + # whenever its path resolves to something that does not match, and a + # different container at [0] qualifies — as does an index resolving to + # nothing at all — so without this the guard goes green if a sidecar is + # ever placed ahead of the controller. + - equal: + path: spec.template.spec.containers[0].name + value: controller + # Match the flag name per element. An equality check against the bare + # form the chart renders today would go on passing if it ever rendered + # --gateway-api=true, with watching on. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--gateway-api" - it: emits chart-default --resync=30s — host scenario inherits the upstream chart default (changed from 10m in upstream v0.7.x). The reduction is the controller's only recovery path when an Ingress watch silently dies, and 30s caps worst-case event-to-reconcile latency under broken-watch conditions. Pinning here catches a future upstream bump back to the longer value before it lands as a CI surprise. template: charts/ouroboros/templates/controller-deployment.yaml @@ -151,15 +167,15 @@ tests: # too — this is the mode tenants actually run, so a future refactor # in the upstream controller-deployment template that drops the # composition block from this branch only would break tenants - # silently. The negative-assert pins that --proxy-fqdn does NOT - # bake the cluster-domain at render time when no clusterDomain is - # configured (the platform-agnostic default). + # silently. The negative assert pins that no --proxy-fqdn is baked at + # render time when no clusterDomain is configured (the + # platform-agnostic default). - contains: path: spec.template.spec.containers[0].args content: --proxy-service-name=ouroboros-proxy - contains: path: spec.template.spec.containers[0].args content: --proxy-service-namespace=cozy-ouroboros - - notContains: - path: spec.template.spec.containers[0].args - content: --proxy-fqdn=ouroboros-proxy.cozy-ouroboros.svc.cluster.local. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--proxy-fqdn" diff --git a/packages/system/ouroboros/tests/cozystack_overrides_test.yaml b/packages/system/ouroboros/tests/cozystack_overrides_test.yaml index cfafd95e8..f90f23858 100644 --- a/packages/system/ouroboros/tests/cozystack_overrides_test.yaml +++ b/packages/system/ouroboros/tests/cozystack_overrides_test.yaml @@ -27,9 +27,15 @@ tests: - contains: path: spec.template.spec.containers[0].args content: --target-service-namespace=cozy-ingress-nginx - - notContains: - path: spec.template.spec.containers[0].args - content: --target-host= + # Match the flag name per element, not one whole element against one + # value: contains and notContains compare elements for equality, so + # `--target-host=` never equals a rendered `--target-host=` and + # that negative could not fail. A length assertion would not do either, + # since an upstream bump that adds the flag while consolidating another + # argument keeps the count. + - notMatchRegex: + path: spec.template.spec.containers[0].args[*] + pattern: "^--target-host" - it: controller image is pinned to the digest the values.yaml ships template: charts/ouroboros/templates/controller-deployment.yaml