Skip to content

Bound every directory-source Application by what the repo-server enforces - #246

Merged
stxkxs merged 1 commit into
mainfrom
bound-directory-sources
Sep 5, 2026
Merged

Bound every directory-source Application by what the repo-server enforces#246
stxkxs merged 1 commit into
mainfrom
bound-directory-sources

Conversation

@stxkxs

@stxkxs stxkxs commented Sep 5, 2026

Copy link
Copy Markdown
Member

Closes #198.

The gap

argocd-repo-server refuses to generate manifests for a directory-type source whose combined manifest files exceed --max-combined-directory-manifests-size. Nothing here compared anything against that limit, so the first place it could be met was a sync on a cluster — and not visibly: the Application reports ComparisonError with Unknown in the sync column rather than OutOfSync, and the symptom arrives waves later as whatever workload needed the kind that never installed.

Which sources, derived rather than listed

A source with a path is not automatically a kustomize directory. ArgoCD takes an explicit helm/kustomize/plugin/directory block at its word and otherwise classifies by what the directory holds — Chart.yaml is Helm, a kustomization file is Kustomize, anything else is Directory. Running that decision over the tree is what makes the corpus a property: a kustomization deleted out of an overlay does not merely break a build, it moves that source into the set a size limit applies to.

Three sources qualify:

source size of the 20M ceiling
argo-workflows-crdscharts/argo-workflows/files/crds/full @ argo-workflows-1.0.23 11.10M 55.5% — exceeds a stock repo-server
gateway-api-crdsconfig/crd/standard @ v1.6.1 1.17M 5.8%
portal-tenants unmeasurable repo, revision and path all come from cluster annotations

portal-tenants is recorded as unmeasurable with its reason; a source that becomes unmeasurable without one is a finding.

The measurement is the runtime's

Transcribed from getPotentiallyValidManifests: manifests matched by name before they are read, include/exclude applied relative to the source root, descent only under directory.recurse, jsonnet counted as a manifest but not against the size, and each size taken from the resolved file so a symlink contributes its target's bytes. The comparison too — it accumulates and aborts when the total exceeds the limit, so a source exactly filling the ceiling still generates and this rejects at the same byte. A gate stricter by one byte would reject a tree that works.

The ceiling is gated, not derived — and why

It is a repo-server flag, set by whatever installs ArgoCD. This catalog installs none and pins no ArgoCD version, so there is nothing in this tree to read it from. contracts/repo-server.json records what the host must be configured for, and the repository that configures the repo-server is where the two are held equal. contracts/secret-store.json publishes a value this catalog declares for others to assert against; this one runs the other way, publishing what this catalog needs of its host.

What is not written down is the measurement. scripts/directory-sources.json keys each size on (repoURL, targetRevision, path), so a pin cannot move without its record going stale, and a stale record fails the blocking gate on the pull request that moves the pin. That is where headroom comes from: every byte of growth arrives as a diff a reviewer sees, rather than one warning at whatever percentage somebody picked. The first Renovate bump of the Argo Workflows tag lands red until it carries its new size.

Empty comparisons fail

No ApplicationSet parsed; no source with a path; a directory source with no record; a record with neither a size nor a stated reason it has none; a resolved path holding no manifest file. A size check that measured nothing and reported "under the limit" is the defect it exists to catch.

Split

  • Offline, blocking — in the appsets job beside the offline chart-provenance half, because it is a function of the tree.
  • Live, scheduled — re-clones each pinned source weekly. Only a clone can answer whether a tag nobody moved here was moved upstream underneath it. chart-provenance.yml now covers both kinds of upstream pin.

Proof

Red against the configuration the incident arrived under — ceiling lowered to the one ArgoCD ships with, nothing about the source changed:

argo-workflows-crds measures 11.10M at argo-workflows-1.0.23, over the 10M
ceiling contracts/repo-server.json records. The repo-server refuses to generate
it and the Application reports ComparisonError, not OutOfSync — the sync column
reads Unknown.

That case is the positive control in controls.py, the first break in the gate's own --self-test, and one of three plants in reverify-gates.sh (alongside a pin that moves away from its measurement, and a directory source nothing measured). 47 unit tests cover the source-type decision and the byte accounting clause by clause; each was checked by reverting the behaviour and confirming the test that names it fails.

task validate green, controls.py 21/21, reverify-gates.sh 55/0, empty-corpus.py 34/34, ruff + mypy clean.

…rces

argocd-repo-server refuses to generate manifests for a directory-type source
whose combined manifest files exceed --max-combined-directory-manifests-size.
Nothing in this catalog compared anything against that limit, so the first
place it could be met was a sync on a cluster — and not visibly, because the
Application reports ComparisonError with `Unknown` in the sync column rather
than OutOfSync, and the symptom arrives waves later as whatever workload
needed the kind that never installed.

─────────────────── What is measured, and which sources ───────────────────

scripts/check-directory-manifest-size.py transcribes the runtime rather than
approximating it. getPotentiallyValidManifests matches a manifest by NAME
before it reads it, applies directory.include/exclude to the path relative to
the source root, descends only when directory.recurse is set, counts a jsonnet
file as a manifest while leaving its size out of the total, and takes each
size from the resolved file so a symlink contributes its target's bytes. The
comparison is the runtime's too: it accumulates and aborts when the total
EXCEEDS the limit, so a source exactly filling the ceiling still generates and
this rejects at the same byte. A gate stricter by one byte would reject a tree
that works.

The population is derived, not listed. A source with a `path` is not
automatically a kustomize directory: ArgoCD takes an explicit helm, kustomize,
plugin or directory block at its word, and otherwise classifies by what the
directory holds — Chart.yaml is Helm, a kustomization file is Kustomize,
anything else is Directory. Running that decision over the tree is what makes
the corpus a property rather than a note: a kustomization deleted out of an
overlay does not merely break a build, it moves that source into the set a
size limit applies to. Scope is applicationsets/*.yaml non-recursive, matching
check-hardcoded-org.py and check-catalog-revision.py, because app-of-apps
sources that directory without directory.recurse.

Three sources qualify today. Two resolve: the Argo Workflows crds/full
directory and the Gateway API standard CRDs, both plain manifest directories
in someone else's repository at a pinned tag. The third, portal-tenants, takes
its repository, revision and path from cluster annotations the portal sets, so
no size is a function of this tree; it is recorded as unmeasurable with the
reason, and a source that becomes unmeasurable without one is a finding.

─────────────────── Where the ceiling comes from ───────────────────

It is not derivable here and the gate says so rather than implying otherwise.
The limit is a repo-server flag, set by whatever installs ArgoCD; this catalog
installs none and pins no ArgoCD version, so there is nothing in this tree to
read it from. It is therefore GATED, not derived: contracts/repo-server.json
records the size the host must be configured for, and the repository that
configures the repo-server is where the two are held equal.
contracts/secret-store.json publishes a value this catalog declares for others
to assert against; this one runs the other way, publishing what this catalog
needs of its host.

What is NOT written down is the measurement. scripts/directory-sources.json
keys each size on (repoURL, targetRevision, path), so a pin cannot move
without its record going stale, and a stale record fails the blocking gate on
the pull request that moves the pin. That is where headroom comes from: every
byte of growth arrives as a diff a reviewer sees, rather than one warning at
whatever percentage somebody picked. The first Renovate bump of the Argo
Workflows tag lands red until it carries its new size.

An empty comparison fails everywhere it can occur — no ApplicationSet parsed,
no source with a path, a directory source with no record, a record with
neither a size nor a stated reason it has none, and a resolved path holding no
manifest file. A size check that measured nothing and reported "under the
limit" is the defect it exists to catch.

─────────────────── Split, and proof ───────────────────

The offline half blocks in the `appsets` job beside the offline
chart-provenance half, because it is a function of the tree. The live half
re-clones each pinned source and runs on the weekly schedule, which is the
only thing that can answer whether a tag nobody moved here was moved upstream
underneath it. That workflow now covers both kinds of upstream pin.

Proven red against the configuration the incident arrived under: with the
ceiling lowered to the one argocd-repo-server ships with, crds/full at 11.10M
is rejected by name, with nothing about the source changed. That case is the
positive control in scripts/tests/controls.py, the first break in the gate's
own --self-test, and one of three plants in reverify-gates.sh alongside a pin
that moves away from its measurement and a directory source nothing measured.
47 unit tests cover the source-type decision and the byte accounting clause by
clause; each was checked by reverting the behaviour and confirming the test
that names it fails.

Co-authored-by: stxkxsbot <275011021+stxkxsbot@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

CI Results

Check Status
Zero-placeholder gate ✅ success
YAML Lint ✅ success
Dashboard gate (live grafana.com ids + AMG-saveable) ✅ success
Kyverno policy tests (+ verify-images contract) ✅ success
Fork-safety gate (no hardcoded org, blocking) ✅ success
Helm-render (every addon × every env) ✅ success
Rendered images carry no unacknowledged CRITICAL ✅ success
Policy-admission (Enforce-tier Kyverno vs the fleet) ✅ success
ApplicationSet schema + sync waves ✅ success
Appset render (Karpenter subnet selector) ✅ success
Secret scan (gitleaks) ✅ success
Render + assert + schema + misconfig (all environments) ✅ success
Renovate manager coverage ✅ success
Loki volume warns before cutoff ✅ success
Falco loads every rule set it installs ✅ success
Gate scripts lint and typecheck ✅ success
Policy validity (kustomize + kyverno) ✅ success
The gates themselves are tested ✅ success
Pods name ServiceAccounts that exist ✅ success
Catalog CRs admissible against the operator chart ✅ success
Catalog source reads its revision ✅ success

All checks passed.

@stxkxs
stxkxs merged commit 6d92d19 into main Sep 5, 2026
31 checks passed
@stxkxs
stxkxs deleted the bound-directory-sources branch September 5, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing checks a directory-source Application against the repo-server's combined-manifest ceiling

1 participant