Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e4346bd
test(chart): add render test harness and CI
stheppi Oct 7, 2026
7ac44a5
fix(chart): render kube.namespaceFilter into config.json
stheppi Oct 7, 2026
ddbc37f
fix(chart): fail render on an invalid kube.namespaceFilter
stheppi Oct 7, 2026
9351817
fix(chart): render netobs.edgeExistenceTtl into config.json
stheppi Oct 7, 2026
744e519
fix(chart): render netobs.excludeNamespaces into config.json
stheppi Oct 7, 2026
02c8b66
fix(chart): render services.address without the full services block
stheppi Oct 7, 2026
68e39ad
fix(chart): render partial services tls/compression/handshake objects
stheppi Oct 7, 2026
1f4bbce
test(chart): guard falsy services values from being dropped
stheppi Oct 7, 2026
17760d0
refactor(chart): move pickSet helper to _helpers.tpl
stheppi Oct 7, 2026
ab56e8d
docs(chart): document namespace scoping, netobs exclusions and servic…
stheppi Oct 7, 2026
46651cf
test(chart): add kind end-to-end test for the Edge ConfigMap
stheppi Oct 7, 2026
1a124b1
fix(chart): coerce services scalars to the types the Edge expects
stheppi Oct 7, 2026
f8b86a2
fix(chart): validate namespace names and config types before rendering
stheppi Oct 7, 2026
9d48aff
fix(chart): make configMapCapture/envCapture nil-safe (PR #13 code)
stheppi Oct 7, 2026
530084b
test(chart): check the new pod's logs after upgrade in the kind test
stheppi Oct 7, 2026
f0ea3d1
fix(chart): reject non-string netobs.excludeNamespaces entries
stheppi Oct 7, 2026
206d63b
fix(chart): validate netobs.edgeExistenceTtl as a Go duration
stheppi Oct 7, 2026
446f0cb
fix(chart): reject unknown keys under kube.namespaceFilter
stheppi Oct 7, 2026
f4ca8f6
test(chart): keep helm stderr out of rendered YAML; run CI on main
stheppi Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/setup-helm@v4
with:
version: v3.18.0
- name: Install helm-unittest
run: helm plugin install https://github.com/helm-unittest/helm-unittest --version v1.2.1
- name: Install yq
run: |
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64
sudo chmod +x /usr/local/bin/yq
- run: helm lint .
- run: helm unittest .
- run: tests/render/run.sh
- run: tests/render/harness_test.sh
3 changes: 3 additions & 0 deletions .helmignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ docs/
.idea/
*.tmproj
.vscode/
# Tests and CI helpers are not chart content
tests/
ci/
15 changes: 15 additions & 0 deletions ci/kind-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Values for tests/e2e/kind.sh: a local kind cluster with no backend.
image:
pullPolicy: IfNotPresent

config:
# Publishing needs a graph URL and API key; the e2e only needs the Edge to
# accept its config and start.
enablePublishing: false
kube:
namespaceFilter:
mode: allow
namespaces: [default]
netobs:
excludeNamespaces: [x]
edgeExistenceTtl: 1h
4 changes: 4 additions & 0 deletions examples/production-values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ config:
- "jobs"
- "ingresses"
- "namespaces"
# Scope the graph to specific namespaces (default: all). See values.yaml.
# namespaceFilter:
# mode: deny
# namespaces: ["kube-system"]

# Enable publishing with real endpoints
enablePublishing: false
Expand Down
59 changes: 58 additions & 1 deletion templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,61 @@ Edge Proxy: configmap name
*/}}
{{- define "nofire-edge.edgeProxy.configName" -}}
{{- printf "%s-config" (include "nofire-edge.edgeProxy.fullname" .) }}
{{- end }}
{{- end }}

{{- /*
nofire-edge.typedPick copies the keys named in .spec from .src into the dict
.out, coercing each to the type the Edge expects. Values given as strings (for
example via --set-string) would otherwise crash the Edge's JSON decoder.
A key is kept when present, even if falsy (false, 0, "", []); an explicit null
counts as unset. .spec maps key -> int | bool | string | nestring | strlist
(nestring: an empty string counts as unset). .path prefixes error messages.
Writes nothing itself: call it as `{{- $_ := include "nofire-edge.typedPick" (dict ...) }}`.
*/ -}}
{{- define "nofire-edge.typedPick" -}}
{{- $src := .src | default dict -}}
{{- $path := .path -}}
{{- range $k, $t := .spec -}}
{{- if and (hasKey $src $k) (not (kindIs "invalid" (index $src $k))) -}}
{{- $v := index $src $k -}}
{{- $p := printf "%s.%s" $path $k -}}
{{- if eq $t "int" -}}
{{- if not (regexMatch "^-?[0-9]+$" (toString $v)) -}}{{- fail (printf "%s must be an integer (got %v)" $p $v) -}}{{- end -}}
{{- $_ := set $.out $k (int64 $v) -}}
{{- else if eq $t "bool" -}}
{{- if kindIs "bool" $v -}}{{- $_ := set $.out $k $v -}}
{{- else if has (toString $v) (list "true" "false") -}}{{- $_ := set $.out $k (eq (toString $v) "true") -}}
{{- else -}}{{- fail (printf "%s must be a boolean (got %v)" $p $v) -}}{{- end -}}
{{- else if or (eq $t "string") (eq $t "nestring") -}}
{{- if or (kindIs "map" $v) (kindIs "slice" $v) -}}{{- fail (printf "%s must be a string" $p) -}}{{- end -}}
{{- if or (eq $t "string") (ne (toString $v) "") -}}{{- $_ := set $.out $k (toString $v) -}}{{- end -}}
{{- else if eq $t "strlist" -}}
{{- if not (kindIs "slice" $v) -}}{{- fail (printf "%s must be a list" $p) -}}{{- end -}}
{{- range $e := $v -}}{{- if not (kindIs "string" $e) -}}{{- fail (printf "%s entry %v must be a string" $p $e) -}}{{- end -}}{{- end -}}
{{- $_ := set $.out $k $v -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{- /* nofire-edge.servicesJson: the Edge "services" object, with only the keys the user set. */ -}}
{{- define "nofire-edge.servicesJson" -}}
{{- $s := .Values.config.services | default dict -}}
{{- if not (kindIs "map" $s) -}}{{- fail "config.services must be a map" -}}{{- end -}}
{{- $out := dict -}}
{{- $_ := include "nofire-edge.typedPick" (dict "src" $s "path" "config.services" "out" $out "spec" (dict "workers" "int" "address" "nestring" "maxConns" "int" "readTimeout" "string" "writeTimeout" "string")) -}}
{{- $objects := dict
"tls" (dict "enabled" "bool" "certFile" "string" "keyFile" "string" "caFile" "string" "requireClientCert" "bool" "minVersion" "string" "cipherSuites" "strlist")
"compression" (dict "enabled" "bool" "type" "string" "level" "int")
"handshake" (dict "timeout" "string" "contentType" "string") -}}
{{- range $name, $spec := $objects -}}
{{- $v := get $s $name -}}
{{- if and (hasKey $s $name) (not (kindIs "invalid" $v)) (not (kindIs "map" $v)) -}}{{- fail (printf "config.services.%s must be a map" $name) -}}{{- end -}}
{{- if kindIs "map" $v -}}
{{- $o := dict -}}
{{- $_ := include "nofire-edge.typedPick" (dict "src" $v "path" (printf "config.services.%s" $name) "out" $o "spec" $spec) -}}
{{- if $o -}}{{- $_ := set $out $name $o -}}{{- end -}}
{{- end -}}
{{- end -}}
{{- $out | toJson -}}
{{- end -}}
95 changes: 57 additions & 38 deletions templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,39 @@ data:
{{ $resource | quote }}
{{- end }}
]
{{- $nfRaw := .Values.config.kube.namespaceFilter }}
{{- if and (hasKey .Values.config.kube "namespaceFilter") (not (kindIs "invalid" $nfRaw)) (not (kindIs "map" $nfRaw)) }}
{{- fail "config.kube.namespaceFilter must be a map" }}
{{- end }}
{{- range $key, $_ := $nfRaw }}
{{- if not (has $key (list "mode" "namespaces")) }}
{{- fail (printf "config.kube.namespaceFilter has unknown key %q; supported keys are mode and namespaces" $key) }}
{{- end }}
{{- end }}
{{- $nf := dict }}
{{- $_ := include "nofire-edge.typedPick" (dict "src" $nfRaw "path" "config.kube.namespaceFilter" "out" $nf "spec" (dict "mode" "string" "namespaces" "strlist")) }}
{{- if $nf }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: {{- if $nf }} skips both the validation and the render when typedPick produced an empty dict, which happens when the user's namespaceFilter map contains only keys the spec does not know. The result is a silently ignored namespace filter: the same class of bug this PR fixes.

Failure: config.kube.namespaceFilter: {node: allow} (a plausible typo for mode) renders with no namespaceFilter key at all and no warning; the Edge graphs the whole cluster. A typo in only one of the two keys, such as {modes: allow, namespaces: [x]}, does fail, so the behaviour is inconsistent.

Evidence: ran helm template with namespaceFilter: {node: allow}; jq '.kube | has("namespaceFilter")' returned false.

Fix: when the source map is non-empty but $nf is empty, fail with the unrecognized key names.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 446f0cb. Any key other than mode/namespaces now fails the render: {node: allow} gives config.kube.namespaceFilter has unknown key "node"; supported keys are mode and namespaces. This applies with or without a valid mode alongside (cases ns-filter-fail-unknown-key, ns-filter-fail-unknown-key-valid-mode, plus a unittest).

{{- /* Mirror the Edge's validation so a bad filter fails at install, not as a crashloop. */}}
{{- $mode := get $nf "mode" | default "" }}
{{- $namespaces := get $nf "namespaces" | default list }}
{{- if has $mode (list "" "none") }}
{{- if $namespaces }}
{{- fail (printf "config.kube.namespaceFilter.namespaces is set but mode %q disables filtering; set config.kube.namespaceFilter.mode to 'allow' or 'deny'" $mode) }}
{{- end }}
{{- else if has $mode (list "allow" "deny") }}
{{- if not $namespaces }}
{{- fail (printf "config.kube.namespaceFilter.mode is %q but config.kube.namespaceFilter.namespaces is empty" $mode) }}
{{- end }}
{{- range $ns := $namespaces }}
{{- if not (and (le (len $ns) 63) (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $ns)) }}
{{- fail (printf "config.kube.namespaceFilter.namespaces entry %q is not a valid namespace name (a lowercase DNS-1123 label of at most 63 characters)" $ns) }}
{{- end }}
{{- end }}
{{- else }}
{{- fail (printf "config.kube.namespaceFilter.mode must be one of '', 'none', 'allow', 'deny' (got %q)" $mode) }}
{{- end }},
"namespaceFilter": {{ $nf | toJson }}
{{- end }}
},
"enableCausalAnalysis": {{ .Values.config.enableCausalAnalysis }},
"causal": {
Expand All @@ -38,38 +71,7 @@ data:
"sampleRate": {{ .Values.config.sentry.sampleRate | default 1.0 }},
"tracesSampleRate": {{ .Values.config.sentry.tracesSampleRate | default 0.0 }}
},
"services": {
"workers": {{ .Values.config.services.workers }}
{{- if .Values.config.services.address }},
"address": {{ .Values.config.services.address | quote }},
"maxConns": {{ .Values.config.services.maxConns }},
"readTimeout": {{ .Values.config.services.readTimeout | quote }},
"writeTimeout": {{ .Values.config.services.writeTimeout | quote }},
"tls": {
"enabled": {{ .Values.config.services.tls.enabled | default false }},
"certFile": {{ .Values.config.services.tls.certFile | default "" | quote }},
"keyFile": {{ .Values.config.services.tls.keyFile | default "" | quote }},
"caFile": {{ .Values.config.services.tls.caFile | default "" | quote }},
"requireClientCert": {{ .Values.config.services.tls.requireClientCert | default false }},
"minVersion": {{ .Values.config.services.tls.minVersion | default "" | quote }},
"cipherSuites": [
{{- range $index, $suite := .Values.config.services.tls.cipherSuites }}
{{- if $index }},{{ end }}
{{ $suite | quote }}
{{- end }}
]
},
"compression": {
"enabled": {{ .Values.config.services.compression.enabled | default false }},
"type": {{ .Values.config.services.compression.type | default "" | quote }},
"level": {{ .Values.config.services.compression.level | default -1 }}
},
"handshake": {
"timeout": {{ .Values.config.services.handshake.timeout | default "" | quote }},
"contentType": {{ .Values.config.services.handshake.contentType | default "" | quote }}
}
{{- end }}
},
"services": {{ include "nofire-edge.servicesJson" . }},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: The description says the generated config.json is "byte-identical to #13's" with default values and with examples/production-values.yaml. It is semantically identical, not byte-identical: the services object is now emitted by toJson on one line.

Failure: none at runtime, but checksum/config in the Deployment is a hash of the ConfigMap text, so anyone already on a 0.6.0 pre-release gets a pod roll on upgrade even with unchanged values. Worth stating accurately in the body since the PR's upgrade notes are otherwise thorough.

Evidence: diff of the two renders shows "services": {\n "workers": 4\n }, becoming "services": {"workers":4}, for both value sets; diff <(jq -S . base.json) <(jq -S . head.json) is empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, that was inaccurate. I updated the description: it is semantically identical (jq -S diff empty), not byte-identical. The services line is now compact JSON, so checksum/config changes and pods roll once on upgrade, even with unchanged values.

"enablePublishing": {{ .Values.config.enablePublishing }},
"publisher": {
"apiKey": {{ if .Values.config.publisher.apiKeySecret.enabled }}"{NOFIRE_API_KEY}"{{ else }}{{ .Values.config.publisher.apiKey | default "" | quote }}{{ end }},
Expand All @@ -92,22 +94,39 @@ data:
"prometheusUrl": {{ .Values.config.netobs.prometheusUrl | default "" | quote }},
"source": {{ .Values.config.netobs.source | default "" | quote }},
"prometheusTimeout": {{ .Values.config.netobs.prometheusTimeout | default "10s" | quote }}
{{- if and (hasKey .Values.config.netobs "edgeExistenceTtl") (not (kindIs "invalid" .Values.config.netobs.edgeExistenceTtl)) }}
{{- if not (kindIs "string" .Values.config.netobs.edgeExistenceTtl) }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: edgeExistenceTtl is validated as a string but not as a Go duration, so a typo renders successfully and only fails inside the Edge. Narrow, and it fails closed (the pod does not start with a wrong TTL), but the PR validates namespace names at render for exactly this reason.

Failure: config.netobs.edgeExistenceTtl: "not-a-duration" renders "edgeExistenceTtl":"not-a-duration" and the Edge's time.ParseDuration rejects it at startup.

Evidence: ran helm template with that value and jq -c '.netobs.edgeExistenceTtl' printed "not-a-duration". templates/configmap.yaml:93 only checks kindIs "string".

Fix: add regexMatch "^-?([0-9]+(\\.[0-9]+)?(ns|us|ms|s|m|h))+$" alongside the kind check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 206d63b. edgeExistenceTtl must now match Go duration syntax. not-a-duration gives … is not a valid duration; use Go syntax such as 2h, 90m or 1h30m, and so does a bare 30 with no unit. The leading -, fractions, compound values, µs/us and "0" are still accepted, since time.ParseDuration accepts them. Every ttl-ok-* case also passes the Edge v3.6.0 loader.

{{- fail (printf "config.netobs.edgeExistenceTtl must be a duration string like 2h (got %v)" .Values.config.netobs.edgeExistenceTtl) }}
{{- end }}
{{- /* Go duration syntax: optional sign, then number+unit pairs ("1h30m", "1.5h"), or plain "0". */}}
{{- if not (regexMatch "^([-+]?(([0-9]+[.]?[0-9]*|[.][0-9]+)(ns|us|µs|μs|ms|s|m|h))+|[-+]?0)$" .Values.config.netobs.edgeExistenceTtl) }}
{{- fail (printf "config.netobs.edgeExistenceTtl %q is not a valid duration; use Go syntax such as 2h, 90m or 1h30m" .Values.config.netobs.edgeExistenceTtl) }}
{{- end }},
"edgeExistenceTtl": {{ .Values.config.netobs.edgeExistenceTtl | quote }}
{{- end }}
{{- $netobsEx := dict }}
{{- $_ := include "nofire-edge.typedPick" (dict "src" .Values.config.netobs "path" "config.netobs" "out" $netobsEx "spec" (dict "excludeNamespaces" "strlist")) }}
{{- if hasKey $netobsEx "excludeNamespaces" }},
"excludeNamespaces": {{ $netobsEx.excludeNamespaces | toJson }}
{{- end }}
},
{{- $cmc := .Values.config.configMapCapture | default dict }}
"configMapCapture": {
"clearText": {{ .Values.config.configMapCapture.clearText | default false }},
"captureCap": {{ .Values.config.configMapCapture.captureCap | default 4096 }},
"clearText": {{ $cmc.clearText | default false }},
"captureCap": {{ $cmc.captureCap | default 4096 }},
"redactKeyPatterns": [
{{- range $index, $pattern := .Values.config.configMapCapture.redactKeyPatterns }}
{{- range $index, $pattern := $cmc.redactKeyPatterns }}
{{- if $index }},{{ end }}
{{ $pattern | quote }}
{{- end }}
]
},
{{- $envc := .Values.config.envCapture | default dict }}
"envCapture": {
"clearText": {{ .Values.config.envCapture.clearText | default false }},
"captureCap": {{ .Values.config.envCapture.captureCap | default 4096 }},
"clearText": {{ $envc.clearText | default false }},
"captureCap": {{ $envc.captureCap | default 4096 }},
"redactKeyPatterns": [
{{- range $index, $pattern := .Values.config.envCapture.redactKeyPatterns }}
{{- range $index, $pattern := $envc.redactKeyPatterns }}
{{- if $index }},{{ end }}
{{ $pattern | quote }}
{{- end }}
Expand Down
79 changes: 79 additions & 0 deletions tests/configmap_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
suite: configmap
templates:
- templates/configmap.yaml
tests:
- it: renders a config.json ConfigMap with defaults
asserts:
- isKind:
of: ConfigMap
- matchRegex:
path: metadata.name
pattern: -config$
- exists:
path: data["config.json"]

- it: fails when namespaces are set without a mode
set:
config.kube.namespaceFilter.namespaces: [a]
asserts:
- failedTemplate:
errorPattern: disables filtering

- it: fails when mode is allow with no namespaces
set:
config.kube.namespaceFilter.mode: allow
asserts:
- failedTemplate:
errorPattern: mode is "allow" but

- it: fails on an unknown mode
set:
config.kube.namespaceFilter.mode: bogus
config.kube.namespaceFilter.namespaces: [a]
asserts:
- failedTemplate:
errorPattern: must be one of

- it: fails on a non-string excludeNamespaces entry
set:
config.netobs.excludeNamespaces: [1, 2]
asserts:
- failedTemplate:
errorPattern: excludeNamespaces entry 1 must be a string

- it: keeps excludeNamespaces entries the Edge accepts, such as uppercase
set:
config.netobs.excludeNamespaces: ["Prod_1"]
asserts:
- matchRegex:
path: data["config.json"]
pattern: '"excludeNamespaces": \["Prod_1"\]'

- it: fails on an edgeExistenceTtl that is not a duration
set:
config.netobs.edgeExistenceTtl: not-a-duration
asserts:
- failedTemplate:
errorPattern: edgeExistenceTtl "not-a-duration" is not a valid duration

- it: fails on an edgeExistenceTtl without a unit
set:
config.netobs.edgeExistenceTtl: "30"
asserts:
- failedTemplate:
errorPattern: edgeExistenceTtl "30" is not a valid duration

- it: accepts Go duration syntax for edgeExistenceTtl
set:
config.netobs.edgeExistenceTtl: -1h30m
asserts:
- matchRegex:
path: data["config.json"]
pattern: '"edgeExistenceTtl": "-1h30m"'

- it: fails on an unknown namespaceFilter key
set:
config.kube.namespaceFilter.node: allow
asserts:
- failedTemplate:
errorPattern: namespaceFilter has unknown key "node"
Loading
Loading