Skip to content

feat(frontend): complete the generatedConfig payload — namespace, environment, schemaVersion - #687

Draft
Starefossen wants to merge 4 commits into
masterfrom
feat/frontend-config-contract-v1
Draft

feat(frontend): complete the generatedConfig payload — namespace, environment, schemaVersion#687
Starefossen wants to merge 4 commits into
masterfrom
feat/frontend-config-contract-v1

Conversation

@Starefossen

@Starefossen Starefossen commented Jul 12, 2026

Copy link
Copy Markdown
Member

Phases 1–2 of nais/grafana-apm-app#134 (the frontend config contract, ADR-0001 in nais/apm).

Commit 1 — complete the payload

The spec.frontend.generatedConfig payload previously carried only telemetryCollectorURL, app.name, app.version. Adds:

  • app.namespacemetadata.namespace, the owning team (the Nais APM plugin attributes all telemetry by team). Note: docs.nais.io currently implies this field already exists — it didn't.
  • environment — the cluster name from naiserator's cluster-name config: a single image resolves its environment at runtime instead of impossibly at build time.
  • schemaVersion: 1 — consumers detect which generation of the payload they read.

Commit 2 — nais.json variant

The same payload emitted twice with identical content: the existing nais.js ES module (import) and a new nais.json (fetch), mounted as siblings — pointing mountPath into a served web root exposes both:

mountPath: /usr/share/nginx/html/nais.js   →   /nais.js  +  /nais.json served

JSON is what static SPAs consume via the @nais/apm SDK's initFromConfigUrl(); an ES module can't be fetched-and-parsed, and a plain <script src> can't read an export. The payload is now built once as a struct; JSON marshalled from it (correct escaping for free), the ES module rendered from the same values. Data only — the files never gain init behavior (ADR-0001 decision 5).

New payload (both formats):

export default {
	schemaVersion: 1,
	telemetryCollectorURL: 'https://telemetry.nav.no/collect',
	app: {
		name: 'myapplication',
		namespace: 'mynamespace',
		version: '1.2.3'
	},
	environment: 'prod-gcp'
};

Config interface gains GetClusterName() (already implemented by the generator options, same as pod/ingress/azure).

Tests

frontend_config.yaml golden file updated (cluster-name in test config, JSON data key, both subPath mounts); go test ./pkg/resourcecreator/... — 16 packages green. The consumer side (nais/apm#18) carries a contract test importing this PR's verbatim golden-file output.

Behavior note

Existing generatedConfig users get one new mounted file (<dir>/nais.json) next to their chosen mountPath. Collision guard: if mountPath itself is .../nais.json, the second mount is skipped.

Later phases of #134 (not here)

Ingress-served well-known config URL; CDN publish-pipeline config writing.

…ironment, schemaVersion

The generated frontend config (nais.js) is consumed by the @nais/apm SDK
as a versioned contract (nais/grafana-apm-app#134). The payload previously
carried only telemetryCollectorURL, app.name and app.version — missing the
two fields the APM plugin needs most:

- app.namespace (the owning team; all telemetry is attributed by team)
- environment (the cluster name, from naiserator's cluster-name config)

schemaVersion: 1 marks the completed shape so consumers can detect which
generation of the payload they read; bump it when the shape changes.
Phase 2 of nais/grafana-apm-app#134: the same payload emitted twice with
identical content — the existing nais.js ES module (import) and a new
nais.json (fetch), mounted as siblings so pointing mountPath into a served
web root exposes both. JSON is what static SPAs consume via the @nais/apm
SDK's initFromConfigUrl(); an ES module cannot be fetched-and-parsed and a
plain <script src> cannot read an export.

The payload is now built once as a struct; the JSON variant is marshalled
from it (correct escaping for free), the ES module rendered from the same
values.
Pre-existing failure, not specific to this branch (master panics
identically): govulncheck v1.1.4 panics with 'ForEachElement called on
type containing *types.TypeParam' under the current toolchain. v1.6.0
fixes the panic — and then correctly reports GO-2026-5856 (crypto/tls
Encrypted Client Hello privacy leak) in go1.26.4's stdlib, fixed in
go1.26.5. Bump both: govulncheck now reports no vulnerabilities.

go test ./pkg/resourcecreator/... green (16 packages).
@Starefossen

Copy link
Copy Markdown
Member Author

CI failure triage: the build_and_push failure was pre-existing, not from this diff — master panics identically (govulncheck v1.1.4: ForEachElement called on type containing *types.TypeParam, a generics bug under the current toolchain; master's last green run was 2026-06-26, before the drift).

Fixed here since it blocks this PR's CI: govulncheck → v1.6.0 (fixes the panic), which then correctly flagged GO-2026-5856 (crypto/tls ECH privacy leak) in go1.26.4 → Go bumped to 1.26.5. govulncheck ./... now reports no vulnerabilities; go test ./pkg/resourcecreator/... green. Happy to cherry-pick the fix commit into a standalone PR if you'd rather land it on master independently.

… findings

Two independent adversarial reviews of this PR (posted in the PR thread)
refuted the original implementation. Fixes:

- nais.js is now 'export default <marshalled JSON>;' — ONE escaped
  serialization backs both files. Previously the Sprintf template put the
  unvalidated spec.image tag (and telemetry URL) unescaped inside
  single-quoted JS literals: a tag containing a quote produced an
  unimportable module that silently disagreed with the valid nais.json.
- versionFromImage(): digest- and registry-port-aware tag extraction
  (last colon after last slash, digest suffix stripped), mirroring
  @nais/apm's resolution so both platform paths agree on the version.
  Previously strings.Split leaked sha256 hex as app.version for
  digest-pinned images and lost the version behind registry ports.
- The JSON sibling mount is narrowed: only added when the CLEANED
  mountPath ends in the conventional nais.js filename. Kills three
  concrete existing-app breakages: uncleaned paths (/dir//nais.json)
  sneaking duplicate-target mounts past Kubernetes' exact-string
  uniqueness check, trailing-slash mountPaths nesting a mount inside a
  file (CreateContainerError), and directory-ish paths dropping
  nais.json at container root.
- Unit tests for all three (hostile-value round-trip, tag extraction
  matrix, sibling-narrowing matrix); golden file updated to the new
  module format.

Accepted as-is (documented in the PR): environment carries naiserator's
cluster-name flag default verbatim (consistent with NAIS_CLUSTER_NAME);
a user filesFrom mount at <dir>/nais.json still collides at admission
(loud API-server rejection, not silent breakage).
@Starefossen

Copy link
Copy Markdown
Member Author

🔴 Adversarial review (two independent passes) — findings & resolutions

Two adversarial reviews were run against this PR (operator-semantics lens and contract/injection lens), instructed to refute its safety claims. Both succeeded. All confirmed findings are fixed in a7762b3 unless marked accepted. Posting the full record for the core-team walkthrough.

Confirmed findings

# Severity Finding Resolution
1 HIGH spec.image is entirely unvalidated (verified: no kubebuilder pattern, webhook never touches it), and its tag was Sprintf'd unescaped into single-quoted JS literals. A tag containing ' or \n (legal!) produced an unimportable nais.js — while nais.json (JSON-escaped) stayed valid: broken and divergent. import() of an attacker-influenced module is also a latent code-exec surface. ✅ Fixed structurally: nais.js is now export default <marshalled JSON>; — one escaped serialization backs both files. Round-trip unit test with hostile values.
2 MEDIUM strings.Split(image, ":") tag parsing: digest-pinned images leaked raw sha256 hex as app.version; registry-port images lost the version entirely. Worse: contradicts @nais/apm's own versionFromImage — the platform's two resolution paths disagreed on the same image. ✅ Fixed: digest- and port-aware extraction (strip @…, last colon after last slash), mirroring the SDK. Test matrix added.
3 MEDIUM Sibling nais.json mount collides with user mounts/files: (a) filesFrom at <dir>/nais.json → duplicate mountPath → API-server rejects the Deployment, app can't deploy; (b) image-shipped nais.json silently shadowed. frontend.Create runs before pod assembly, so it structurally cannot see filesFrom mounts. ⚠️ Mitigated + accepted residual: the sibling is now only mounted when the cleaned mountPath ends in the conventional nais.js filename, and the residual filesFrom collision fails loudly at admission (never silently). Documented in the nais/doc#891 reference. Full detection would need pod-assembly-time validation — worth a core-team opinion.
4 MEDIUM-LOW Collision guard compared the uncleaned mountPath: /dir//nais.json slipped a duplicate-target mount past Kubernetes' exact-string uniqueness check — JSON silently shadowed the ES module. ✅ Fixed: paths compared cleaned (path.Clean). Unit-tested.
5 LOW Trailing-slash mountPath (/webroot/ — accepted today, no validation on MountPath) would nest the JSON mount inside a fileCreateContainerError, pod never starts on first redeploy after rollout. ✅ Fixed by the same narrowing: directory-ish paths get no sibling.
6 LOW Directory-intent mountPath (/webroot) dropped nais.json at container root — the feature silently no-ops for exactly its target use case. ✅ Fixed by the same narrowing.
7 LOW environment emits naiserator's literal flag default (cluster-name-unconfigured) when unset — a garbage label reaching telemetry. 🟡 Accepted: consistent with what NAIS_CLUSTER_NAME already injects into every pod; fixing only here would make the two channels disagree. Flag it if the core team wants a sentinel-to-empty rule platform-wide.
8 INFO schemaVersion: 1 carries no enforced meaning yet — the consumer (fromNaisConfig) probes fields and ignores the version. 🟡 Accepted for now: it's forward provisioning; the consumer-side gating contract should be written down before a 2 ever ships.
9 LOW Golden-file coverage was a single happy path; the deepcomp subset matcher wouldn't even catch stray extra mounts. ✅ Partially fixed: unit-test matrices for tag parsing, hostile-value round-trip, and sibling narrowing. Golden updated to the new module format.

Attacks that failed (safety confirmed)

  • No fleet-wide restart storm on operator upgrade — the synchronizer's spec-hash gate means changes land per-app at its next natural deploy. (Corollary: mixed fleet for a while — the SDK tolerates nais.json 404 by design.)
  • ConfigMap name stable across the change; CM ops committed before Deployment (correct ordering); no checksum-annotation regression (none existed).
  • name/namespace injection impossible (DNS-1123 enforced by the API server); schemaVersion rendered via %d; empty values render valid in both formats.
  • subPath staleness is not a new regression (master already used subPath, and every content change coincides with a pod-spec change forcing a rollout).
  • go.mod bumps are tool-block or patch-level indirects; not compiled into the operator runtime.
  • Naisjob unaffected (no frontend field).

Consumer follow-up

The SDK's own versionFromImage has the same digest bug (finding 2 cuts both ways) and its contract-test fixture pins the old nais.js shape — fix incoming in nais/apm.

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.

1 participant