Skip to content

Commit b16b614

Browse files
test(hack): keep every chart's stored Helm release within budget
A chart can grow past the 1 MiB Helm release Secret without any test noticing: it renders, lints and installs, and only the upgrade whose manifest is large enough fails, on a live cluster, with a release that stays failed. backupstrategy-controller sat under 1 KB from the limit before it crossed it. Estimate the stored size the way Helm encodes it (release JSON with the chart's templates and files, gzip, base64) and fail any chart under packages/ above 90 percent of the limit, plus the largest revision of backupstrategy-controller with every default Strategy rendered. The estimate counts files a .helmignore names, because the production path stores them. Assisted-by: LLM Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent 9900ef8 commit b16b614

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

‎hack/helm-release-size.bats‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bats
2+
# Helm keeps every release revision in one Secret, and the apiserver refuses a
3+
# Secret over 1 MiB. A chart past that budget installs and then fails the one
4+
# upgrade whose manifest is large enough ("Secret sh.helm.release.v1.<name>.vN
5+
# is invalid: data: Too long"), and that release stays failed. The stored
6+
# release carries the whole chart directory as it reaches helm-controller:
7+
# the packages artifact applies no .helmignore, so tests/, images/ and the
8+
# Makefile count too. hack/helm-release-size.py reproduces the encoding.
9+
#
10+
# The budget is 90 percent of the limit: the estimate runs about 1 percent
11+
# above real Secrets, and the rest is room for a chart to grow by more than
12+
# one change before anyone has to act.
13+
#
14+
# Harness compatibility note: CI runs this file under Bats through
15+
# `make bats-unit-tests`. It also remains compatible with the narrower legacy
16+
# `hack/cozytest.sh` translator, so it avoids `run`, `$status`, `$output`,
17+
# `skip`, and setup()/teardown(). Paths stay repo-root-relative for both
18+
# runners.
19+
#
20+
# Run with: bats hack/helm-release-size.bats
21+
22+
load test_helper
23+
24+
LIMIT=1048576
25+
BUDGET=943718
26+
27+
@test "the estimator counts chart files the way Helm stores them" {
28+
tmp=$(mktemp -d)
29+
mkdir -p "$tmp/chart/templates" "$tmp/chart/tests"
30+
printf 'apiVersion: v2\nname: probe\nversion: 0.0.0\n' > "$tmp/chart/Chart.yaml"
31+
# Incompressible, so the stored size cannot be smaller than its base64.
32+
head -c 300000 /dev/urandom > "$tmp/chart/tests/blob"
33+
size=$(printf '' | python3 -I hack/helm-release-size.py "$tmp/chart")
34+
if [ "$size" -lt 400000 ]; then
35+
echo "estimate $size for a chart carrying 300000 random bytes; files are not counted" >&2
36+
false
37+
fi
38+
rm -rf "$tmp"
39+
}
40+
41+
@test "every chart's stored release fits the budget with its default render" {
42+
tmp=$(mktemp -d)
43+
: > "$tmp/over"
44+
n=0
45+
for c in packages/*/*/; do
46+
c=${c%/}
47+
[ -f "$c/Chart.yaml" ] || continue
48+
n=$((n + 1))
49+
# A chart that does not render offline still stores its files and
50+
# templates, so it is measured on those alone rather than skipped.
51+
helm template release "$c" -n default > "$tmp/manifest" 2>/dev/null || : > "$tmp/manifest"
52+
size=$(python3 -I hack/helm-release-size.py "$c" < "$tmp/manifest")
53+
if [ "$size" -gt "$BUDGET" ]; then
54+
echo "$c: $size bytes (budget $BUDGET, limit $LIMIT)" >> "$tmp/over"
55+
fi
56+
done
57+
if [ "$n" -lt 50 ]; then
58+
echo "only $n charts found under packages/; the sweep checked almost nothing" >&2
59+
false
60+
fi
61+
if [ -s "$tmp/over" ]; then
62+
echo "charts whose stored Helm release is over budget:" >&2
63+
cat "$tmp/over" >&2
64+
false
65+
fi
66+
rm -rf "$tmp"
67+
}
68+
69+
@test "backupstrategy-controller fits the budget on its largest revision" {
70+
# The default render gates every Strategy CR off. The revision that renders
71+
# all of them, with the Velero location on as in production, is the one
72+
# that grew past the limit once.
73+
c=packages/system/backupstrategy-controller
74+
size=$(helm template backupstrategy-controller "$c" -n cozy-backup-controller \
75+
--set backupStorage.bucketNameOverride=cozy-backups-0123 \
76+
--set velero.bslEnabled=true \
77+
| python3 -I hack/helm-release-size.py "$c")
78+
if [ "$size" -gt "$BUDGET" ]; then
79+
echo "$c: $size bytes with every default Strategy rendered (budget $BUDGET, limit $LIMIT)" >&2
80+
false
81+
fi
82+
}

‎hack/helm-release-size.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
"""Estimate the size of the Secret in which Helm stores a release of a chart.
3+
4+
Helm's Secret storage driver keeps each revision as one Secret whose `release`
5+
value is base64(gzip(json(release))), and the apiserver refuses a Secret whose
6+
data exceeds 1 MiB. A chart that crosses it installs fine at first and then
7+
fails the one upgrade whose rendered manifest is large enough, with "Secret
8+
sh.helm.release.v1.<name>.vN is invalid: data: Too long". The stored release
9+
carries the whole chart, not just what renders: every file under the chart dir
10+
that is not Chart.yaml, values.yaml, values.schema.json, templates/ or charts/
11+
ends up in chart.files, and the production path (the packages OCI artifact
12+
loaded by helm-controller) applies no .helmignore, so tests/, images/ and the
13+
Makefile are stored too.
14+
15+
This reproduces the encoding: the release JSON with the chart's templates and
16+
files as base64 []byte, the rendered manifest read from stdin (`helm template`
17+
output), gzip at Helm's BestCompression, then base64. It does not reproduce
18+
Helm's gzip byte for byte or the runtime fields (info, config); measured
19+
against real release Secrets it lands within about 0.5 percent, which is why
20+
callers compare it to a threshold with headroom rather than to the limit.
21+
22+
Usage: helm template ... | helm-release-size.py <chart-dir> [--exclude GLOB ...]
23+
Prints the estimated stored size in bytes.
24+
"""
25+
import base64
26+
import fnmatch
27+
import gzip
28+
import json
29+
import os
30+
import sys
31+
32+
# Paths Helm keeps out of chart.files: they are stored elsewhere in the chart
33+
# object (metadata, values, schema, templates) or are subcharts.
34+
NOT_FILES = {"Chart.yaml", "Chart.lock", "values.yaml", "values.schema.json"}
35+
NOT_FILE_DIRS = ("templates/", "charts/")
36+
37+
38+
def go_json(obj):
39+
# encoding/json escapes <, > and & inside strings; match it so the size
40+
# does not drift with how much HTML-ish text a chart carries.
41+
s = json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
42+
return s.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
43+
44+
45+
def main(argv):
46+
if len(argv) < 2:
47+
sys.stderr.write(__doc__)
48+
return 2
49+
chart = argv[1].rstrip("/")
50+
excludes = []
51+
rest = argv[2:]
52+
while rest:
53+
if rest[0] != "--exclude" or len(rest) < 2:
54+
sys.stderr.write("unexpected argument: %s\n" % rest[0])
55+
return 2
56+
excludes.append(rest[1])
57+
rest = rest[2:]
58+
59+
templates, files = [], []
60+
for root, dirs, names in os.walk(chart):
61+
dirs.sort()
62+
for n in sorted(names):
63+
path = os.path.join(root, n)
64+
rel = os.path.relpath(path, chart)
65+
if any(fnmatch.fnmatch(rel, g) for g in excludes):
66+
continue
67+
with open(path, "rb") as fh:
68+
data = base64.b64encode(fh.read()).decode()
69+
entry = {"name": rel, "data": data}
70+
if rel.startswith("templates/"):
71+
templates.append(entry)
72+
elif rel in NOT_FILES or rel.startswith(NOT_FILE_DIRS):
73+
continue
74+
else:
75+
files.append(entry)
76+
77+
def read(rel):
78+
p = os.path.join(chart, rel)
79+
return open(p).read() if os.path.exists(p) else ""
80+
81+
release = {
82+
"name": "release",
83+
"info": {"status": "deployed", "description": "Upgrade complete"},
84+
"chart": {
85+
"metadata": {"name": os.path.basename(chart), "version": "0.0.0", "apiVersion": "v2"},
86+
"lock": None,
87+
"templates": templates,
88+
# Helm stores the parsed values; the raw text is a close stand-in.
89+
"values": read("values.yaml"),
90+
"schema": base64.b64encode(read("values.schema.json").encode()).decode() or None,
91+
"files": files,
92+
},
93+
"manifest": sys.stdin.read(),
94+
"version": 1,
95+
"namespace": "default",
96+
}
97+
raw = go_json(release).encode()
98+
stored = base64.b64encode(gzip.compress(raw, compresslevel=9))
99+
print(len(stored))
100+
return 0
101+
102+
103+
if __name__ == "__main__":
104+
sys.exit(main(sys.argv))

0 commit comments

Comments
 (0)