fix(deploy): bound Helm rendering on hostile input, no partial or wrong release images

Review round 1 of #2677. Every case ends in an explicit unknown, never in a
hang, an OOM, a partial or a wrong image.

Bounds (helm_template.c):
- A per-render step and output budget (10,000,000 steps, 64 MiB): a range
  over a huge int, a huge indent, defines that include each other in a fan
  stop the render; every probe of the template is UNKNOWN, also one captured
  before, and the trip is logged (helm.render.budget). Counted, never timed.
  On the 20 infra corpora no render reaches it.
- go-yaml v2's alias limit: values whose decodes are mostly alias expansion
  (a billion laughs) do not parse, as in Helm.
- --set: an index past strvals' MaxIndex (65536) or a key longer than the
  parser holds is no value (the release is unknown), never a padded list or
  a split key.

Reads (pass_k8s.c, pass_terraform.c): a values file, Chart.yaml, kustomize
valuesFile or Terraform file() named by path is read only when it is a
discovered file of the repository -- never through a symlink out of it --
with the size cap and tree-sitter padding of every other read. A chart file
that is there but not read makes the chart's values unknown, not empty.

Wrong images:
- Terraform number sets reach the chart as Terraform's exact decimal
  (1.10 -> "1.1"), not %.17g.
- A release with a value source not read -- Flux valuesFrom, valuesFiles,
  postRenderers; kustomize additionalValuesFiles, a valuesMerge other than
  override, a valuesFile not read; Terraform dynamic "set" -- has no image of
  its own.
- An aliased subchart renders with .Chart.Name its alias.
- An UNKNOWN if / with / range makes what its bodies assign UNKNOWN and
  leaves the loop a break in them would; set / unset / merge poison the map
  they change (merge: the maps within too). A change that may reach values
  another part holds taints every later capture; one of a new value
  (fromYaml, deepCopy, an empty dict, a part of one) does not.

Marks and secrets:
- The chart edge reads default_of as JSON (list paths kept), builds its
  properties to size, and writes no edge whose mark it could not carry whole.
- Build arguments with a secret (by name or value) are never kept; build
  edge properties and values-path marks are never cut.

Tests: one per bound and per wrong-image case, each RED on d21949ef by its
assertion; a Helm chart joins the Kubernetes complexity fixture.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-18 21:08:20 +02:00
parent d21949ef2c
commit 4cea54f758
12 changed files with 1780 additions and 151 deletions
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -75,8 +75,10 @@ typedef struct {
/* Render template `rel` (text src, len) with `values` (its .Values; NULL:
* an empty map), `chart` and the family's defines, filling `probes` (strings
* in arena a). Values paths are dot-joined keys, a key's own `.` escaped
* (`\.`), as --set writes them. */
void cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len,
* (`\.`), as --set writes them. False when the render ran past its step or
* output budget -- a template Helm would not finish either (logged): then
* every capture is UNKNOWN. */
bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len,
yyjson_mut_val *values, const cbm_helm_chart_meta_t *chart,
const cbm_helm_defines_t *defs, cbm_helm_probe_t *probes, int nprobes);
+13 -6
View File
@@ -1086,7 +1086,8 @@ static int ci_compose_env(const ci_t *c, TSNode svc, const ci_env_t *dotenv, con
/* A compose build's `args` (a mapping, or a list of "K=V" items), their
* values interpolated, as a JSON object; NULL: none. An argument without a
* value (the environment's) or with an unknown one is left out. */
* value (the environment's), with an unknown one or with a secret (by name or
* value -- never kept in the graph, never an image) is left out. */
static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_env_t *env) {
TSNode args = ts_node_is_null(build) ? build : ci_get(c, build, "args");
if (ts_node_is_null(args)) {
@@ -1109,13 +1110,19 @@ static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_e
raw = ts_node_is_null(pairs[i].value) ? NULL : ci_scalar(c, pairs[i].value, NULL);
}
const char *value = raw ? ci_interp(c->a, raw, env) : NULL;
if (!key || !key[0] || !value) {
if (!key || !key[0] || !value || cbm_is_secret_binding(key, value)) {
continue;
}
char ek[CBM_SZ_256];
char ev[CBM_SZ_1K];
cbm_json_escape(ek, (int)sizeof(ek), key);
cbm_json_escape(ev, (int)sizeof(ev), value);
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int kn = (int)(strlen(key) * 6 + SKIP_ONE);
int vn = (int)(strlen(value) * 6 + SKIP_ONE);
char *ek = cbm_arena_alloc(c->a, (size_t)kn);
char *ev = cbm_arena_alloc(c->a, (size_t)vn);
if (!ek || !ev) {
return NULL;
}
cbm_json_escape(ek, kn, key);
cbm_json_escape(ev, vn, value);
json = cbm_arena_sprintf(c->a, "%s%s\"%s\":\"%s\"", json ? json : "{", json ? "," : "", ek,
ev);
}
+166 -64
View File
@@ -3939,22 +3939,45 @@ static void k8s_helm_collect(cbm_pipeline_ctx_t *ctx, const k8s_facts_t *f, k8s_
}
}
/* Repository file `rel`, read only when discovery took it in: a values file,
* a Chart.yaml, a kustomize valuesFile named by path is never a symlink or
* under one -- nothing is read from outside the repository. */
static char *k8s_repo_read(cbm_pipeline_ctx_t *ctx, const char *rel, int *len) {
if (!cbm_pipeline_file_node(ctx->gbuf, ctx->project_name, rel)) {
return NULL;
}
char path[CBM_SZ_4K];
snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel);
return k8s_read_file(path, len);
int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel);
return n > 0 && (size_t)n < sizeof(path) ? k8s_read_file(path, len) : NULL;
}
/* A file of `dir` (one of `names`), parsed as values; *found when one exists. */
/* Is repository path `rel` there at all -- a file, a symlink, anything (a
* link is reported, never followed)? */
static bool k8s_repo_has(cbm_pipeline_ctx_t *ctx, const char *rel) {
char path[CBM_SZ_4K];
int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel);
cbm_path_info_t info;
return n > 0 && (size_t)n < sizeof(path) && cbm_path_info_utf8(path, &info) == 0;
}
/* A file of `dir` (one of `names`), parsed as values; *found when one exists.
* *unread when one is there but is not read (a symlink, too large): what it
* holds is unknown, which is not the same as no file. */
static yyjson_mut_val *k8s_helm_yaml(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const char *dir,
const char *const *names, bool *found) {
const char *const *names, bool *found, bool *unread) {
*found = false;
*unread = false;
for (int i = 0; names[i]; i++) {
char rel[CBM_SZ_1K];
snprintf(rel, sizeof(rel), "%s%s%s", dir, dir[0] ? "/" : "", names[i]);
int len = 0;
char *src = k8s_repo_read(ctx, rel, &len);
if (!src) {
if (k8s_repo_has(ctx, rel)) {
*found = true;
*unread = true;
return NULL;
}
continue;
}
*found = true;
@@ -3986,9 +4009,13 @@ static k8s_hchart_t *k8s_helm_chart(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_
static const char *const CHART[] = {"Chart.yaml", "Chart.yml", NULL};
static const char *const VALUES[] = {"values.yaml", "values.yml", NULL};
bool found = false;
c->chart_yaml = k8s_helm_yaml(ctx, h, dir, CHART, &found);
yyjson_mut_val *vals = k8s_helm_yaml(ctx, h, dir, VALUES, &found);
c->values = found ? vals : yyjson_mut_obj(h->doc);
bool chart_unread = false;
bool values_unread = false;
c->chart_yaml = k8s_helm_yaml(ctx, h, dir, CHART, &found, &chart_unread);
yyjson_mut_val *vals = k8s_helm_yaml(ctx, h, dir, VALUES, &found, &values_unread);
/* a Chart.yaml or values file there but not read: the chart's values are
* unknown (it renders nothing), never empty */
c->values = chart_unread || values_unread ? NULL : found ? vals : yyjson_mut_obj(h->doc);
const cbm_gbuf_node_t *cn = c->chart > 0 ? cbm_gbuf_find_by_id(ctx->gbuf, c->chart) : NULL;
const char *name = k8s_helm_meta_str(c->chart_yaml, "name");
c->meta.name = name ? name : cn && cn->name ? cbm_arena_strdup(&h->a, cn->name) : NULL;
@@ -4139,12 +4166,12 @@ static yyjson_mut_val *k8s_helm_dig(yyjson_mut_val *v, const char *path) {
}
/* Is values key path `key` (dot-joined, `\.` escaped) set in `v`? */
static bool k8s_helm_sets(yyjson_mut_val *v, const char *key) {
char part[CBM_SZ_512];
static bool k8s_helm_sets(k8s_helm_t *h, yyjson_mut_val *v, const char *key) {
char *part = cbm_arena_alloc(&h->a, strlen(key) + SKIP_ONE); /* a key is never cut */
const char *p = key;
while (v && *p) {
while (v && part && *p) {
size_t k = 0;
while (*p && *p != '.' && *p != '[' && k + SKIP_ONE < sizeof(part)) {
while (*p && *p != '.' && *p != '[') {
if (*p == '\\' && p[1]) {
p++;
}
@@ -4180,13 +4207,19 @@ static const char *k8s_helm_overridden(k8s_helm_t *h, const char *keys, yyjson_m
yyjson_arr_foreach(arr, idx, max, k) {
const char *key = yyjson_get_str(k);
bool global = key && strncmp(key, "global.", strlen("global.")) == 0;
bool set = key && (global ? k8s_helm_sets(globals, key + strlen("global."))
: k8s_helm_sets(overlay, key));
bool set = key && (global ? k8s_helm_sets(h, globals, key + strlen("global."))
: k8s_helm_sets(h, overlay, key));
if (!set) {
continue;
}
char esc[CBM_SZ_1K];
cbm_json_escape(esc, (int)sizeof(esc), key);
/* JSON escaping writes at most 6 bytes a byte */
int en = (int)(strlen(key) * 6 + SKIP_ONE);
char *esc = cbm_arena_alloc(&h->a, (size_t)en);
if (!esc) {
out = NULL;
break;
}
cbm_json_escape(esc, en, key);
out = cbm_arena_sprintf(&h->a, "%s%s\"%s%s%s\"", out ? out : "[", out ? "," : "",
global || !prefix ? "" : prefix, global || !prefix ? "" : ".",
esc);
@@ -4245,14 +4278,18 @@ static void k8s_helm_take(cbm_pipeline_ctx_t *ctx, const cbm_helm_probe_t *p, in
/* The images of templated containers [from, to) of one template, rendered
* with `values` (NULL: the chart's own). */
static void k8s_helm_render(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b,
const k8s_hchart_t *c, const k8s_timg_t *t, int from, int to,
yyjson_mut_val *values, const char *src, size_t len,
const k8s_hchart_t *c, const char *as_name, const k8s_timg_t *t,
int from, int to, yyjson_mut_val *values, const char *src, size_t len,
cbm_helm_probe_t *probes, int np) {
char fam[CBM_SZ_1K];
k8s_chart_family(b, c->dir, fam, sizeof(fam));
const cbm_helm_defines_t *defs = k8s_helm_defines(ctx, b, h, fam);
cbm_helm_render(&h->a, t[from].file, src, len, values ? values : c->values, &c->meta, defs,
probes, np);
/* a subchart a dependency aliases is .Chart.Name = its alias (Helm's
* getAliasDependency) */
cbm_helm_chart_meta_t meta = c->meta;
meta.name = as_name ? as_name : meta.name;
cbm_helm_render(&h->a, t[from].file, src, len, values ? values : c->values, &meta, defs, probes,
np);
(void)to;
}
@@ -4381,11 +4418,13 @@ static bool k8s_helm_disabled(k8s_helm_t *h, yyjson_mut_val *dep, yyjson_mut_val
* with values `top_vals` (NULL: top's defaults): down the charts between
* them, each subchart's defaults with its parent's `<alias>:` values and
* `global:` coalesced over them (Helm's coalesce; a subchart always has a
* `global`). *path gets dir's key path from top ("" for top itself). NULL:
* a dependency condition disables it, or a values file does not parse. */
* `global`). *path gets dir's key path from top ("" for top itself), *as_name
* the name dir's chart is included under (its alias; NULL for top). NULL: a
* dependency condition disables it, or a values file does not parse. */
static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b,
k8s_dep_link_t *l, const char *dir, const char *top,
yyjson_mut_val *top_vals, const char **path) {
yyjson_mut_val *top_vals, const char **path,
const char **as_name) {
const char **anc = NULL;
int na = k8s_helm_ancestors(h, b, dir, &anc);
int ti = -SKIP_ONE; /* top's index among the ancestors; na: dir itself */
@@ -4397,6 +4436,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h,
}
}
*path = "";
*as_name = NULL;
if (ti < 0) {
return NULL;
}
@@ -4422,6 +4462,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h,
}
*path = (*path)[0] ? cbm_arena_sprintf(&h->a, "%s.%s", *path, alias)
: cbm_arena_strdup(&h->a, alias);
*as_name = alias;
yyjson_mut_val *sub = yyjson_mut_is_obj(cur) ? yyjson_mut_obj_get(cur, alias) : NULL;
yyjson_mut_val *next = cbm_helm_values_coalesce(h->doc, child->values, sub);
yyjson_mut_val *pg = yyjson_mut_is_obj(cur) ? yyjson_mut_obj_get(cur, "global") : NULL;
@@ -4441,10 +4482,10 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h,
/* Render the templated containers [from, to) of one template with `vals`
* and take each container's images. */
static void k8s_helm_images_of(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_t *b,
const k8s_hchart_t *c, int from, int to, yyjson_mut_val *vals,
const char *src, size_t len, cbm_helm_probe_t *probes, int np,
k8s_himgs_t *out) {
k8s_helm_render(ctx, h, b, c, h->t, from, to, vals, src, len, probes, np);
const k8s_hchart_t *c, const char *as_name, int from, int to,
yyjson_mut_val *vals, const char *src, size_t len,
cbm_helm_probe_t *probes, int np, k8s_himgs_t *out) {
k8s_helm_render(ctx, h, b, c, as_name, h->t, from, to, vals, src, len, probes, np);
for (int i = from; i < to; i++) {
k8s_helm_take(ctx, probes, np, h->t[i].start_line, h->t[i].end_line, h->t[i].node,
h->t[i].container, out);
@@ -4471,17 +4512,19 @@ static void k8s_helm_link_defaults(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8
? k8s_helm_chart(ctx, h, &l, dir)
: NULL;
const char *path = NULL;
const char *as_name = NULL;
yyjson_mut_val *vals = NULL;
if (c) {
k8s_chart_family(b, c->dir, fam, sizeof(fam));
vals = k8s_helm_context(ctx, h, b, &l, c->dir, fam, NULL, &path);
vals = k8s_helm_context(ctx, h, b, &l, c->dir, fam, NULL, &path, &as_name);
}
int len = 0;
char *src = vals ? k8s_repo_read(ctx, h->t[from].file, &len) : NULL;
cbm_helm_probe_t *probes = NULL;
int np = src ? k8s_helm_probes(h, h->t, from, to, src, (size_t)len, &probes) : 0;
if (np > 0) {
k8s_helm_images_of(ctx, h, b, c, from, to, vals, src, (size_t)len, probes, np, &uses);
k8s_helm_images_of(ctx, h, b, c, as_name, from, to, vals, src, (size_t)len, probes, np,
&uses);
}
cbm_free(CBM_MEM_CLASS_OTHER, src);
from = to;
@@ -4535,14 +4578,17 @@ static void k8s_helm_release_local(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8
(strcmp(dir, top) == 0 || (tl == 0 || (strncmp(dir, top, tl) == 0 && dir[tl] == '/')));
k8s_hchart_t *c = in ? k8s_helm_chart(ctx, h, l, dir) : NULL;
const char *path = NULL;
yyjson_mut_val *vals = c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path) : NULL;
const char *as_name = NULL;
yyjson_mut_val *vals =
c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path, &as_name) : NULL;
int len = 0;
char *srcs = vals ? k8s_repo_read(ctx, h->t[from].file, &len) : NULL;
cbm_helm_probe_t *probes = NULL;
int np = srcs ? k8s_helm_probes(h, h->t, from, to, srcs, (size_t)len, &probes) : 0;
if (np > 0) {
k8s_himgs_t got = {0};
k8s_helm_images_of(ctx, h, b, c, from, to, vals, srcs, (size_t)len, probes, np, &got);
k8s_helm_images_of(ctx, h, b, c, as_name, from, to, vals, srcs, (size_t)len, probes, np,
&got);
yyjson_mut_val *overlay = path && path[0] ? k8s_helm_dig(rv, path) : rv;
yyjson_mut_val *globals = yyjson_mut_obj_get(rv, "global");
k8s_helm_emit_overrides(ctx, h, src, via, &h->dflt, &got, overlay, globals,
@@ -4700,8 +4746,40 @@ static bool k8s_json_str(const char *props, const char *key, char *out, size_t s
return ok;
}
/* Does field `key` of `obj` give a value (a non-null scalar, a non-empty list
* or map)? */
static bool k8s_helm_gives(yyjson_mut_val *obj, const char *key) {
yyjson_mut_val *v = yyjson_mut_is_obj(obj) ? yyjson_mut_obj_get(obj, key) : NULL;
if (!v || yyjson_mut_is_null(v)) {
return false;
}
if (yyjson_mut_is_arr(v)) {
return yyjson_mut_arr_size(v) > 0;
}
if (yyjson_mut_is_obj(v)) {
return yyjson_mut_obj_size(v) > 0;
}
return !yyjson_mut_is_str(v) || yyjson_mut_get_len(v) > 0;
}
/* A HelmRelease's values: spec.values; NULL when there are none -- or when a
* source not read also gives some (valuesFrom, the chart's valuesFiles, a
* postRenderer that may patch the images): its images are then unknown. */
static yyjson_mut_val *k8s_helm_flux_values(yyjson_mut_val *docv) {
yyjson_mut_val *spec = yyjson_mut_is_obj(docv) ? yyjson_mut_obj_get(docv, "spec") : NULL;
yyjson_mut_val *chart = yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "chart") : NULL;
yyjson_mut_val *cspec = yyjson_mut_is_obj(chart) ? yyjson_mut_obj_get(chart, "spec") : NULL;
if (k8s_helm_gives(spec, "valuesFrom") || k8s_helm_gives(spec, "postRenderers") ||
k8s_helm_gives(cspec, "valuesFiles") || k8s_helm_gives(cspec, "valuesFile")) {
return NULL;
}
return yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "values") : NULL;
}
/* A kustomize helmCharts entry's values: valuesFile, then valuesInline over
* it; NULL when neither. */
* it; NULL when neither -- or when a part is not read (additionalValuesFiles,
* a valuesMerge other than the default override, a valuesFile that cannot be
* read): its images are then unknown. */
static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h,
const cbm_gbuf_node_t *kust, const char *props) {
char chart[CBM_SZ_512] = "";
@@ -4723,6 +4801,11 @@ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_he
const char *rel = k8s_helm_meta_str(c, "releaseName");
if (name && strcmp(name, chart) == 0 &&
(!release[0] || (rel && strcmp(rel, release) == 0))) {
const char *merge = k8s_helm_meta_str(c, "valuesMerge");
if (k8s_helm_gives(c, "additionalValuesFiles") ||
(merge && strcmp(merge, "override") != 0)) {
return NULL;
}
inline_vals = yyjson_mut_obj_get(c, "valuesInline");
break;
}
@@ -4733,6 +4816,9 @@ static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_he
char *fs = k8s_repo_read(ctx, vfile, &fl);
file_vals = fs ? cbm_helm_values_parse(h->doc, fs, (size_t)fl) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, fs);
if (!file_vals) {
return NULL; /* a valuesFile not read: the release's values would be partial */
}
}
if (!inline_vals && !file_vals) {
return NULL;
@@ -4772,10 +4858,7 @@ static void k8s_helm_link_releases(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8
bool hr = strstr(rels[i].props, "\"via\":\"helmrelease\"") != NULL;
yyjson_mut_val *rv = NULL;
if (hr) {
yyjson_mut_val *docv = k8s_helm_node_yaml(ctx, h, src);
yyjson_mut_val *spec =
yyjson_mut_is_obj(docv) ? yyjson_mut_obj_get(docv, "spec") : NULL;
rv = yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "values") : NULL;
rv = k8s_helm_flux_values(k8s_helm_node_yaml(ctx, h, src));
} else {
rv = k8s_helm_kustomize_values(ctx, h, src, rels[i].props);
}
@@ -4840,14 +4923,13 @@ typedef struct {
int64_t chart;
int64_t img;
bool literal;
bool lost; /* a mark that could not be carried whole: no edge, never an unmarked one */
char *keys; /* JSON array (cbm_alloc); NULL: none yet */
} k8s_cimg_t;
/* JSON array `a` (owned, may be NULL) with the strings of JSON array `b`
* appended once; the result is owned. */
static char *k8s_keys_union(char *a, const char *b) {
yyjson_doc *db = b ? yyjson_read(b, strlen(b), 0) : NULL;
yyjson_val *bv = db ? yyjson_doc_get_root(db) : NULL;
/* JSON array `a` (owned, may be NULL) with the strings of JSON array value
* `bv` appended once; the result is owned. *lost when one could not be. */
static char *k8s_keys_union(char *a, yyjson_val *bv, bool *lost) {
size_t idx, max;
yyjson_val *k;
yyjson_arr_foreach(bv, idx, max, k) {
@@ -4855,29 +4937,38 @@ static char *k8s_keys_union(char *a, const char *b) {
if (!s) {
continue;
}
char esc[CBM_SZ_1K];
char quoted[CBM_SZ_1K + PAIR_LEN + PAIR_LEN];
cbm_json_escape(esc, (int)sizeof(esc), s);
snprintf(quoted, sizeof(quoted), "\"%s\"", esc);
/* JSON escaping writes at most 6 bytes (\u00XX) a byte */
size_t qn = strlen(s) * 6 + PAIR_LEN + SKIP_ONE;
char *quoted = cbm_alloc(CBM_MEM_CLASS_OTHER, qn);
if (!quoted) {
*lost = true;
break;
}
quoted[0] = '"';
cbm_json_escape(quoted + SKIP_ONE, (int)(qn - PAIR_LEN), s);
size_t ql = strlen(quoted);
quoted[ql++] = '"';
quoted[ql] = '\0';
if (a && strstr(a, quoted)) {
cbm_free(CBM_MEM_CLASS_OTHER, quoted);
continue;
}
size_t al = a ? strlen(a) : 0;
char *g = cbm_alloc(CBM_MEM_CLASS_OTHER, al + strlen(quoted) + 3);
char *g = cbm_alloc(CBM_MEM_CLASS_OTHER, al + ql + 3);
if (!g) {
cbm_free(CBM_MEM_CLASS_OTHER, quoted);
*lost = true;
break;
}
if (a) {
snprintf(g, al + strlen(quoted) + 3, "%.*s,%s]", (int)(al - SKIP_ONE), a, quoted);
snprintf(g, al + ql + 3, "%.*s,%s]", (int)(al - SKIP_ONE), a, quoted);
} else {
snprintf(g, strlen(quoted) + 3, "[%s]", quoted);
snprintf(g, ql + 3, "[%s]", quoted);
}
cbm_free(CBM_MEM_CLASS_OTHER, quoted);
cbm_free(CBM_MEM_CLASS_OTHER, a);
a = g;
}
if (db) {
yyjson_doc_free(db);
}
return a;
}
@@ -4923,30 +5014,41 @@ static void k8s_link_chart_images(cbm_pipeline_ctx_t *ctx) {
if (!k8s_grow((void **)&v, &cap, n, sizeof(*v))) {
break;
}
v[n] = (k8s_cimg_t){charts[c]->id, img->id, false, NULL};
v[n] = (k8s_cimg_t){charts[c]->id, img->id, false, false, NULL};
at = n++;
}
const char *dflt = strstr(up, "\"default_of\":");
if (!dflt) {
/* the USAGE's default_of, read as JSON (a key path may hold `]`) */
yyjson_doc *ud = yyjson_read(up, strlen(up), 0);
yyjson_val *dv = ud ? yyjson_obj_get(yyjson_doc_get_root(ud), "default_of") : NULL;
if (!ud) {
v[at].lost = true;
} else if (!dv) {
v[at].literal = true;
} else if (yyjson_is_arr(dv)) {
v[at].keys = k8s_keys_union(v[at].keys, dv, &v[at].lost);
} else {
char keys[CBM_SZ_4K];
const char *arr = dflt + strlen("\"default_of\":");
const char *end = strchr(arr, ']');
if (end && (size_t)(end - arr) + PAIR_LEN < sizeof(keys)) {
snprintf(keys, sizeof(keys), "%.*s", (int)(end - arr + SKIP_ONE), arr);
v[at].keys = k8s_keys_union(v[at].keys, keys);
}
v[at].lost = true;
}
if (ud) {
yyjson_doc_free(ud);
}
}
}
}
for (int i = 0; i < n; i++) {
char props[CBM_SZ_4K];
bool marked = !v[i].literal && v[i].keys;
snprintf(props, sizeof(props), "{\"via\":\"chart\",\"ref\":\"image\"%s%s}",
marked ? ",\"default_of\":" : "", marked ? v[i].keys : "");
cbm_gbuf_insert_edge(ctx->gbuf, v[i].chart, v[i].img, "DEPLOYS", props);
if (!v[i].literal && v[i].lost) {
cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys);
continue; /* the mark is not whole: no edge rather than an unmarked one */
}
size_t pn = (marked ? strlen(v[i].keys) : 0) + CBM_SZ_64;
char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn);
if (props) {
snprintf(props, pn, "{\"via\":\"chart\",\"ref\":\"image\"%s%s}",
marked ? ",\"default_of\":" : "", marked ? v[i].keys : "");
cbm_gbuf_insert_edge(ctx->gbuf, v[i].chart, v[i].img, "DEPLOYS", props);
cbm_free(CBM_MEM_CLASS_OTHER, props);
}
cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys);
}
cbm_free(CBM_MEM_CLASS_OTHER, v);
+23 -5
View File
@@ -2489,7 +2489,8 @@ static const char *sh_image_repo(sh_t *s, TSNode cmd, const sh_argv_t *a, int i)
}
/* JSON object `json` (NULL: none) with build argument `kv` ("K=V") added; a
* `K` alone (the environment's value) or an unknown word adds nothing. */
* `K` alone (the environment's value), an unknown word or a secret (by name
* or value -- never kept in the graph, never an image) adds nothing. */
static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) {
const char *eq = kv ? strchr(kv, '=') : NULL;
if (!eq || eq == kv) {
@@ -2500,8 +2501,17 @@ static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) {
return json;
}
}
char ev[CBM_SZ_1K];
cbm_json_escape(ev, (int)sizeof(ev), eq + SKIP_ONE);
const char *key = cbm_arena_strndup(s->a, kv, (size_t)(eq - kv));
if (!key || cbm_is_secret_binding(key, eq + SKIP_ONE)) {
return json;
}
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int vn = (int)(strlen(eq + SKIP_ONE) * 6 + SKIP_ONE);
char *ev = cbm_arena_alloc(s->a, (size_t)vn);
if (!ev) {
return json;
}
cbm_json_escape(ev, vn, eq + SKIP_ONE);
const char *pair = cbm_arena_sprintf(s->a, "\"%.*s\":\"%s\"", (int)(eq - kv), kv, ev);
if (!pair) {
return json;
@@ -3763,17 +3773,25 @@ static int sh_link_build(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, CBMHashTabl
char esc_file[CBM_SZ_1K];
char esc_img[CBM_SZ_1K];
char ci[CBM_SZ_1K];
char props[CBM_SZ_4K];
cbm_json_escape(esc_file, (int)sizeof(esc_file), x->file);
cbm_json_escape(esc_img, (int)sizeof(esc_img), x->ext);
sh_ci_props(x, ci, sizeof(ci));
snprintf(props, sizeof(props),
/* sized to what it holds: default_of and build_args are never cut */
size_t pn = strlen(esc_file) + strlen(esc_img) + strlen(ci) +
(x->default_of ? strlen(x->default_of) : 0) +
(x->build_args ? strlen(x->build_args) : 0) + CBM_SZ_256;
char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn);
if (!props) {
return 0;
}
snprintf(props, pn,
"{\"via\":\"build\",\"tool\":\"docker-build\",\"file\":\"%s\",\"image\":\"%s\"%s,"
"\"line\":%d%s%s%s%s}",
esc_file, esc_img, ci, x->line, x->default_of ? ",\"default_of\":" : "",
x->default_of ? x->default_of : "", x->build_args ? ",\"build_args\":" : "",
x->build_args ? x->build_args : "");
cbm_gbuf_insert_edge(ctx->gbuf, img, mod, "DEPENDS_ON", props);
cbm_free(CBM_MEM_CLASS_OTHER, props);
return SKIP_ONE;
}
+140 -46
View File
@@ -31,6 +31,7 @@
#include "helpers.h"
#include "lang_specs.h"
#include <ctype.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
@@ -330,9 +331,96 @@ static char *tf_heredoc(const tf_t *t, TSNode n) {
return out;
}
/* HCL number literal s (digits, a fraction, an exponent) as Terraform turns a
* number into a string: exact decimal, no exponent, no trailing zeros (cty's
* big.Float Text('f', -1); 1.10 -> "1.1", 2e3 -> "2000"). False when it is
* no such literal or does not fit out. */
static bool tf_number_text(const char *s, size_t n, char *out, size_t sz) {
enum { TF_MAX_EXP = 4096 };
char dig[CBM_SZ_256];
size_t nd = 0;
long exp = 0;
bool frac = false;
size_t i = 0;
for (; i < n && (isdigit((unsigned char)s[i]) || s[i] == '.'); i++) {
if (s[i] == '.') {
if (frac) {
return false;
}
frac = true;
continue;
}
if (nd + SKIP_ONE >= sizeof(dig)) {
return false;
}
dig[nd++] = s[i];
exp -= frac ? SKIP_ONE : 0;
}
if (i < n) {
if (s[i] != 'e' && s[i] != 'E') {
return false;
}
i++;
bool neg = i < n && s[i] == '-';
i += i < n && (s[i] == '-' || s[i] == '+');
long e = 0;
if (i == n) {
return false;
}
for (; i < n; i++) {
if (!isdigit((unsigned char)s[i]) || e > TF_MAX_EXP) {
return false;
}
e = e * 10 + (s[i] - '0');
}
exp += neg ? -e : e;
}
size_t b = 0;
while (b < nd && dig[b] == '0') {
b++;
}
if (b == nd) {
return snprintf(out, sz, "0") == SKIP_ONE; /* every digit 0 (or none) */
}
size_t e = nd;
while (dig[e - SKIP_ONE] == '0') { /* stops at dig[b], which is no 0 */
e--;
exp++;
}
size_t m = e - b; /* significant digits: dig[b, e) x 10^exp */
size_t o = 0;
long point = (long)m + exp; /* digits before the decimal point */
size_t need = exp >= 0 ? m + (size_t)exp
: point > 0 ? m + SKIP_ONE
: m + PAIR_LEN + (size_t)-point;
if (need + SKIP_ONE > sz) {
return false;
}
if (point <= 0) {
out[o++] = '0';
out[o++] = '.';
for (long z = 0; z < -point; z++) {
out[o++] = '0';
}
}
for (size_t k = 0; k < m; k++) {
if (point > 0 && (long)k == point) {
out[o++] = '.';
}
out[o++] = dig[b + k];
}
for (long z = 0; z < exp; z++) {
out[o++] = '0';
}
out[o] = '\0';
return true;
}
/* An HCL literal as JSON: a string without interpolation, a number, a bool,
* null, a tuple or object of those; NULL: anything else. */
static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
* null, a tuple or object of those; NULL: anything else. `num_text`: a number
* as the string Terraform makes of it (a helm_release set value is a string),
* else a float64. */
static yyjson_mut_val *tf_json_v(const tf_t *t, yyjson_mut_doc *doc, TSNode n, bool num_text) {
n = tf_unwrap(n);
if (tf_type(n, "string_lit") || tf_type(n, "quoted_template")) {
char buf[CBM_SZ_4K];
@@ -371,7 +459,11 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
uint32_t b = ts_node_start_byte(n);
uint32_t e = ts_node_end_byte(n);
if (tf_type(n, "numeric_lit")) {
char buf[CBM_SZ_64];
char buf[CBM_SZ_256];
if (num_text) {
return tf_number_text(t->src + b, e - b, buf, sizeof(buf)) ? yyjson_mut_strcpy(doc, buf)
: NULL;
}
snprintf(buf, sizeof(buf), "%.*s", (int)(e - b), t->src + b);
return yyjson_mut_real(doc, strtod(buf, NULL));
}
@@ -388,7 +480,7 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
if (tf_type(c, "tuple_start") || tf_type(c, "tuple_end")) {
continue;
}
yyjson_mut_val *v = tf_json(t, doc, c);
yyjson_mut_val *v = tf_json_v(t, doc, c, num_text);
if (!v) {
return NULL;
}
@@ -411,9 +503,9 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
uint32_t kb = ts_node_start_byte(id);
key = yyjson_mut_strncpy(doc, t->src + kb, ts_node_end_byte(id) - kb);
} else {
key = tf_json(t, doc, kn);
key = tf_json_v(t, doc, kn, false);
}
yyjson_mut_val *val = tf_json(t, doc, vn);
yyjson_mut_val *val = tf_json_v(t, doc, vn, num_text);
if (!key || !yyjson_mut_is_str(key) || !val) {
return NULL;
}
@@ -424,38 +516,22 @@ static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
return NULL;
}
/* A repository file's text (cbm_alloc), NULL when unreadable. */
static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
return tf_json_v(t, doc, n, false);
}
static char *tf_read(const char *path, size_t *len);
/* A repository file's text (cbm_alloc; capped and padded like every file
* read), NULL when unreadable -- or when it is no file discovery took in: a
* symlink, or a path through one, is never followed out of the repository. */
static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) {
char full[CBM_SZ_4K];
snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path);
FILE *f = cbm_fopen(full, "rb");
if (!f) {
if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) {
return NULL;
}
char *buf = NULL;
size_t cap = 0;
char chunk[CBM_SZ_4K];
size_t r;
*len = 0;
while ((r = fread(chunk, SKIP_ONE, sizeof(chunk), f)) > 0) {
if (*len + r + SKIP_ONE > cap) {
cap = (*len + r + SKIP_ONE) * PAIR_LEN;
char *g = cbm_realloc(CBM_MEM_CLASS_OTHER, buf, cap);
if (!g) {
cbm_free(CBM_MEM_CLASS_OTHER, buf);
(void)fclose(f);
return NULL;
}
buf = g;
}
memcpy(buf + *len, chunk, r);
*len += r;
}
(void)fclose(f);
if (buf) {
buf[*len] = '\0';
}
return buf;
char full[CBM_SZ_4K];
int n = snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path);
return n > 0 && (size_t)n < sizeof(full) ? tf_read(full, len) : NULL;
}
/* A `values = [...]` item as values: a YAML string / heredoc, file(<path>)
@@ -512,8 +588,8 @@ static yyjson_mut_val *tf_values_item(const tf_t *t, yyjson_mut_doc *doc, TSNode
: NULL;
}
/* A `set` entry (name / value / type) applied to root; false when it is no
* literal. */
/* A `set` entry (name / value / type; read with numbers as text) applied to
* root; false when it is no literal. */
static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *entry) {
yyjson_mut_val *nm = yyjson_mut_obj_get(entry, "name");
yyjson_mut_val *val = yyjson_mut_obj_get(entry, "value");
@@ -521,13 +597,9 @@ static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_v
if (!yyjson_mut_is_str(nm) || !val) {
return false;
}
char vbuf[CBM_SZ_64];
const char *v = NULL;
if (yyjson_mut_is_str(val)) {
v = yyjson_mut_get_str(val);
} else if (yyjson_mut_is_real(val)) {
snprintf(vbuf, sizeof(vbuf), "%.17g", yyjson_mut_get_real(val));
v = vbuf;
} else if (yyjson_mut_is_bool(val)) {
v = yyjson_mut_get_bool(val) ? "true" : "false";
}
@@ -550,7 +622,7 @@ static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode b
}
TSNode key = ts_node_named_child(at, 0);
uint32_t b = ts_node_start_byte(key);
yyjson_mut_val *v = tf_json(t, doc, ts_node_named_child(at, SKIP_ONE));
yyjson_mut_val *v = tf_json_v(t, doc, ts_node_named_child(at, SKIP_ONE), true);
if (!v) {
return NULL;
}
@@ -561,9 +633,29 @@ static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode b
return entry;
}
/* Is block `a` a `dynamic "set"` / `dynamic "set_list"` -- entries made by a
* for_each, never literal? */
static bool tf_dynamic_set(const tf_t *t, TSNode a) {
TSNode kw = ts_node_named_child(a, 0);
TSNode label = ts_node_named_child(a, SKIP_ONE);
if (!tf_type(a, "block") || ts_node_is_null(kw) || ts_node_is_null(label)) {
return false;
}
uint32_t kb = ts_node_start_byte(kw);
if (ts_node_end_byte(kw) - kb != strlen("dynamic") ||
strncmp(t->src + kb, "dynamic", strlen("dynamic")) != 0) {
return false;
}
uint32_t lb = ts_node_start_byte(label);
size_t ll = ts_node_end_byte(label) - lb;
return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) ||
(ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0);
}
/* The values a helm_release body gives its chart -- `values` items
* coalesced in order, then each `set` -- as JSON (cbm_alloc); NULL when a
* part is no literal. set_sensitive is never read. */
* part is no literal, or is made by a `dynamic` block. set_sensitive is
* never read. */
static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(cbm_json_writer_allocator());
yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
@@ -576,8 +668,10 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files,
size_t kl = ts_node_is_null(key) ? 0 : ts_node_end_byte(key) - kb;
bool is_set = kl == strlen("set") && strncmp(t->src + kb, "set", kl) == 0;
bool is_values = kl == strlen("values") && strncmp(t->src + kb, "values", kl) == 0;
/* set_list values are not read: the release's values are partial */
if (kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) {
/* set_list and dynamic set values are not read: the release's
* values would be partial */
if ((kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) ||
tf_dynamic_set(t, a)) {
ok = false;
break;
}
@@ -598,7 +692,7 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files,
ok = entry && tf_set_entry(doc, root, entry);
} else if (pass == SKIP_ONE && is_set && tf_type(a, "attribute")) {
/* provider v3: set = [{ name, value, type }, ...] */
yyjson_mut_val *list = tf_json(t, doc, ts_node_named_child(a, SKIP_ONE));
yyjson_mut_val *list = tf_json_v(t, doc, ts_node_named_child(a, SKIP_ONE), true);
ok = yyjson_mut_is_arr(list);
size_t idx, max;
yyjson_mut_val *entry;
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v2
name: cx
version: 0.1.0
appVersion: "1.0"
@@ -0,0 +1,4 @@
{{- define "cx.image" -}}
{{- $reg := .registry -}}
{{- printf "%s/%s:%s" $reg .repository .tag -}}
{{- end -}}
@@ -0,0 +1,14 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cx
spec:
template:
spec:
containers:
- name: main
image: "{{ include "cx.image" .Values.image }}"
{{- range .Values.sidecars }}
- name: {{ .name }}
image: "{{ .image.repository }}:{{ .image.tag }}"
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
common: &common
pullPolicy: IfNotPresent
image:
<<: *common
registry: ghcr.io
repository: acme/cx
tag: "1.0"
sidecars:
- name: a
image: {repository: ghcr.io/acme/cx-side, tag: "2.0"}
- name: b
image: {repository: ghcr.io/acme/cx-side, tag: "2.1"}
+355
View File
@@ -4766,6 +4766,351 @@ TEST(probe_helm_release_images) {
PASS();
}
/* The chart every release test below deploys: an image from two values. */
#define GPF_APP_CHART \
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, \
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, \
{ \
"charts/app/templates/deploy.yaml", \
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" \
" spec:\n containers:\n - name: app\n" \
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" \
}
/* The edges of the chart's defaults (every test below has them). */
#define GPF_APP_DEFAULTS \
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " \
"default_of=[\"image.repository\",\"image.tag\"]\n" \
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " \
"ref=image default_of=[\"image.repository\",\"image.tag\"]"
/* A Terraform number set on a helm_release reaches the chart as the string
* Terraform makes of it -- its exact decimal (1.1, 1.10 -> "1.1", 2e3 ->
* "2000"), never a float's %.17g ("1.1000000000000001"). */
TEST(probe_helm_release_terraform_number_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = 1.10\n }\n}\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS "
"image=ghcr.io/acme/app:1.1 via=terraform ref=image override_of=[\"image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 4, want)) {
FAIL("a Terraform number set is its exact decimal");
}
PASS();
}
/* A Flux HelmRelease whose values also come from a source not read --
* valuesFrom, the chart's valuesFiles, a postRenderer that may patch the
* images -- renders no image of its own: its values are not known whole. */
TEST(probe_helm_release_flux_unread_sources) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"flux/app.yaml",
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: files\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n valuesFiles:\n"
" - charts/app/values-prod.yaml\n sourceRef:\n kind: GitRepository\n"
" name: repo\n values:\n image:\n tag: \"9.9\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: from\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n valuesFrom:\n"
" - kind: ConfigMap\n name: app-values\n values:\n image:\n"
" tag: \"8.8\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: post\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n postRenderers:\n"
" - kustomize:\n images:\n - name: ghcr.io/acme/app\n"
" newName: quay.io/acme/app\n values:\n image:\n tag: \"7.7\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a HelmRelease with a source not read has no image of its own");
}
PASS();
}
/* A kustomize helmCharts entry with additionalValuesFiles, or a valuesMerge
* other than the default override, renders no image of its own. */
TEST(probe_helm_release_kustomize_unread_sources) {
static const GpfFile files[] = {
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: more\n additionalValuesFiles:\n"
" - extra.yaml\n valuesInline:\n image:\n tag: \"5.5\"\n"
" - name: app\n releaseName: merged\n valuesMerge: merge\n"
" valuesInline:\n image:\n tag: \"6.6\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helmCharts entry with a part not read has no image of its own");
}
PASS();
}
/* A Terraform helm_release with a `dynamic "set"` block (entries made by a
* for_each) renders no image of its own, whatever its literal sets say. */
TEST(probe_helm_release_terraform_dynamic_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n dynamic \"set\" {\n"
" for_each = var.sets\n content {\n name = set.key\n"
" value = set.value\n }\n }\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helm_release with a dynamic set has no image of its own");
}
PASS();
}
/* A subchart a dependency aliases renders with .Chart.Name its alias (Helm's
* getAliasDependency), so an image naming the chart names the alias. */
TEST(probe_helm_aliased_subchart_chart_name) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n - name: worker\n"
" version: 0.1.0\n alias: worker-high\n"},
{"charts/app/values.yaml", "worker-high: {}\n"},
{"charts/app/charts/worker/Chart.yaml",
"apiVersion: v2\nname: worker\nversion: 0.1.0\nappVersion: \"1.0\"\n"},
{"charts/app/charts/worker/values.yaml", "{}\n"},
{"charts/app/charts/worker/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n template:\n"
" spec:\n containers:\n - name: worker\n"
" image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}};
static const char want[] =
"Chart:worker -> Image:ghcr.io/acme/worker-high DEPLOYS image=- via=chart ref=image\n"
"Resource:Deployment/worker -> Image:ghcr.io/acme/worker-high USAGE "
"image=ghcr.io/acme/worker-high:1.0 ref=image";
if (!gpf_expect_image_edges(files, 5, want)) {
FAIL("an aliased subchart's .Chart.Name is the alias");
}
PASS();
}
/* An image read from a values list keeps its mark on the chart's edge:
* default_of names the list paths (images[0].repository) whole. */
TEST(probe_helm_list_path_mark_on_chart_edge) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"charts/app/values.yaml",
"images:\n - repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ (index .Values.images 0).repository }}:"
"{{ (index .Values.images 0).tag }}\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]";
if (!gpf_expect_image_edges(files, 3, want)) {
FAIL("a list path's mark is kept whole on the chart edge");
}
PASS();
}
/* gpf_image_edges with symlinks laid in after the files: links[i] names a
* repository path and the absolute or relative target it points at. */
static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links,
int nlinks) {
GpfProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_gpf_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
return NULL;
}
gpf_to_fwd_slashes(lp.tmpdir);
for (int i = 0; i < nfiles + nlinks; i++) {
const GpfFile *f = i < nfiles ? &files[i] : &links[i - nfiles];
char path[700];
snprintf(path, sizeof(path), "%s/%s", lp.tmpdir, f->name);
char *slash = strrchr(path, '/');
if (slash && slash > path + strlen(lp.tmpdir)) {
*slash = '\0';
cbm_mkdir_p(path, 0755);
*slash = '/';
}
if (i >= nfiles) {
if (symlink(f->content, path) != 0) {
return NULL;
}
continue;
}
FILE *fp = fopen(path, "wb");
if (!fp) {
return NULL;
}
fputs(f->content, fp);
fclose(fp);
}
cbm_store_t *store = gpf_open_indexed(&lp);
char *out = NULL;
if (store) {
sqlite3_stmt *st = NULL;
const char *sql =
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name "
"|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || "
"coalesce(json_extract(e.properties,'$.image'),'-') || "
"coalesce(' via=' || json_extract(e.properties,'$.via'), '') AS x "
"FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id "
"WHERE e.project=?1 AND t.label='Image' ORDER BY x)";
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
out = strdup((const char *)sqlite3_column_text(st, 0));
}
}
sqlite3_finalize(st);
}
gpf_cleanup(&lp, store);
return out;
}
/* A values file a chart, a kustomization or a Terraform file() names by path
* is read only when it is a file of the repository: a symlink out of it --
* to a file holding an image -- is never followed. The chart whose values
* file is such a link renders nothing (its values are unknown, not empty);
* the releases reading one have no image of their own. */
TEST(probe_helm_values_symlink_not_followed) {
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/values.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"linked/Chart.yaml", "apiVersion: v2\nname: linked\nversion: 1.0.0\n"},
{"linked/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: linked\nspec:\n template:\n"
" spec:\n containers:\n - name: linked\n"
" image: \"ghcr.io/acme/linked:{{ .Values.tag | default \"latest\" }}\"\n"},
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesFile: prod.yaml\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
const GpfFile links[] = {{"linked/values.yaml", leak}, {"kz/prod.yaml", leak}};
char *got = gpf_image_edges_linked(files, 9, links, 2);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0\n"
"Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no values read through a symlink out of the repository");
}
PASS();
}
/* A Terraform helm_release reading its values with file() of a symlink out
* of the repository has no image of its own: the link is never followed. */
TEST(probe_helm_terraform_values_symlink_not_followed) {
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/vals.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n values = [file(\"vals.yaml\")]\n}\n"}};
const GpfFile links[] = {{"tf/vals.yaml", leak}};
char *got = gpf_image_edges_linked(files, 4, links, 1);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no Terraform values read through a symlink out of the repository");
}
PASS();
}
/* A build argument holding a secret -- by its name (*_TOKEN) or its value (a
* GitHub token) -- is never kept on a build edge; the others still choose the
* base image they set. */
TEST(probe_docker_build_args_keep_no_secret) {
static const GpfFile files[] = {
{"web/Dockerfile", "ARG GO=1.22\nFROM golang:${GO}\n"},
{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{".env", "NPM_TOKEN=npm-secret-value\n"},
{"compose.yaml", "services:\n web:\n image: ghcr.io/acme/web\n build:\n"
" context: ./web\n args:\n NPM_TOKEN: ${NPM_TOKEN}\n"
" GO: \"1.23\"\n"},
{"scripts/build.sh",
"#!/bin/sh\ndocker build --build-arg CI_KEY=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB "
"--build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"}};
int secrets = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"(properties LIKE '%npm-secret-value%' OR properties LIKE "
"'%ghp_abcdefghij%' OR properties LIKE '%NPM_TOKEN%')");
int overrides = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"json_extract(properties,'$.override_of') IS NOT NULL");
ASSERT_EQ(secrets, 0);
ASSERT_EQ(overrides, 2);
PASS();
}
/* Build arguments of any length are kept whole on the build edge (never cut
* to malformed JSON): the one choosing the base still makes its edge. */
TEST(probe_docker_long_build_args_kept_whole) {
enum { ARGS = 40, VAL = 150, CAP = ARGS * (VAL + 40) + 512 };
char *script = malloc(CAP);
ASSERT_NOT_NULL(script);
int o = snprintf(script, CAP, "#!/bin/sh\ndocker build");
char val[VAL + 1];
memset(val, 'v', VAL);
val[VAL] = '\0';
for (int i = 0; i < ARGS; i++) {
o += snprintf(script + o, (size_t)(CAP - o), " --build-arg LABEL%d=%s", i, val);
}
snprintf(script + o, (size_t)(CAP - o), " --build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n");
const GpfFile files[] = {{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{"scripts/build.sh", script}};
int overrides =
gpf_query_int(files, 2,
"SELECT count(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE "
"e.project=?1 AND t.name='python' AND "
"json_extract(e.properties,'$.override_of') IS NOT NULL");
free(script);
ASSERT_EQ(overrides, 1);
PASS();
}
/* A template's `include "name"` calls the define of that name in its chart
* family -- the chart and its subcharts share one template namespace -- never
* a same-named define of an unrelated chart; a define may also be written in
@@ -6580,6 +6925,16 @@ SUITE(grammar_probe_f) {
RUN_TEST(probe_docker_default_images_marked_and_overrides);
RUN_TEST(probe_helm_template_images_render_with_defaults);
RUN_TEST(probe_helm_release_images);
RUN_TEST(probe_helm_release_terraform_number_set);
RUN_TEST(probe_helm_release_flux_unread_sources);
RUN_TEST(probe_helm_release_kustomize_unread_sources);
RUN_TEST(probe_helm_release_terraform_dynamic_set);
RUN_TEST(probe_helm_aliased_subchart_chart_name);
RUN_TEST(probe_helm_list_path_mark_on_chart_edge);
RUN_TEST(probe_helm_values_symlink_not_followed);
RUN_TEST(probe_helm_terraform_values_symlink_not_followed);
RUN_TEST(probe_docker_build_args_keep_no_secret);
RUN_TEST(probe_docker_long_build_args_kept_whole);
RUN_TEST(probe_helm_include_resolves_in_the_chart_family);
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
RUN_TEST(probe_k8s_selector_within_kustomize_build);
+234
View File
@@ -4335,6 +4335,230 @@ TEST(helm_values_follow_helm_semantics) {
PASS();
}
/* Render template tpl with values src (one probe per `image:` line given)
* and return the capture of probe 0 ("<UNKNOWN>" for UNKNOWN, "<none>" when
* nothing was captured) -- a copy, freed by the caller. */
static char *helm_capture(const char *vals_src, const char *tpl, const cbm_helm_defines_t *defs,
int line) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *v = doc ? cbm_helm_values_parse(doc, vals_src, strlen(vals_src)) : NULL;
CBMArena a;
cbm_arena_init_lazy(&a, CBM_SZ_4K);
cbm_helm_probe_t probe = {.line = line};
cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, defs, &probe, 1);
char *out = strdup(probe.n == 0 ? "<none>" : probe.image[0] ? probe.image[0] : "<UNKNOWN>");
cbm_arena_destroy(&a);
if (doc) {
yyjson_mut_doc_free(doc);
}
return out;
}
/* A render doing far more work than any chart -- here a range over an int
* of 20,000,000 -- stops at its step budget (helm_template.c): every capture
* of the template is UNKNOWN, also the one made before the loop, never a
* partial or wrong image; a range over a huge int ends the same way. */
TEST(helm_render_range_budget_ends_unknown) {
static const char vals[] = "tag: \"1.0\"\n";
static const char ok[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n";
char *got = helm_capture(vals, ok, NULL, 1);
ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0");
free(got);
static const char rng[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n"
"{{- range 20000000 }}{{ end }}\n";
got = helm_capture(vals, rng, NULL, 1);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
static const char huge[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n"
"{{- range 2000000000 }}{{ range 2000000000 }}{{ end }}{{ end }}\n";
got = helm_capture(vals, huge, NULL, 1);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
PASS();
}
/* An indent whose output passes the output budget stops the render: no
* capture of it is known (an indent of 9e18 stops before any work). */
TEST(helm_render_indent_budget_ends_unknown) {
static const char big[] = "image: \"ghcr.io/acme/app{{ indent 70000000 \"\" | trim }}:1\"\n";
char *got = helm_capture("{}\n", big, NULL, 1);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
static const char huge[] =
"image: \"ghcr.io/acme/app{{ indent 9000000000000000000 \"\" | trim }}:1\"\n";
got = helm_capture("{}\n", huge, NULL, 1);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
PASS();
}
/* Defines that each include the one below twice multiply the work by two a
* level: past the step budget the render stops, its captures UNKNOWN. */
TEST(helm_render_define_fanout_ends_unknown) {
enum { LEVELS = 21, FAN_CAP = 8192 };
char *fan = malloc(FAN_CAP);
ASSERT_NOT_NULL(fan);
int o = snprintf(fan, FAN_CAP, "{{- define \"d0\" -}}x{{- end -}}\n");
for (int k = 1; k < LEVELS; k++) {
o += snprintf(fan + o, (size_t)(FAN_CAP - o),
"{{- define \"d%d\" -}}{{ include \"d%d\" . }}{{ include \"d%d\" . }}"
"{{- end -}}\n",
k, k - 1, k - 1);
}
cbm_helm_defines_t *d = cbm_helm_defines_new();
ASSERT_NOT_NULL(d);
cbm_helm_defines_add(d, "templates/_fan.tpl", fan, strlen(fan));
free(fan);
static const char tpl[] = "image: \"ghcr.io/acme/app:{{ include \"d20\" . | trunc 1 }}\"\n";
char *got = helm_capture("{}\n", tpl, d, 1);
cbm_helm_defines_free(d);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
PASS();
}
/* go-yaml v2's aliasing limit (Helm reads values with it): a document whose
* decodes come mostly from alias expansion -- a billion laughs -- is no
* values; a few aliases still read. */
TEST(helm_values_alias_expansion_limit) {
enum { LEVELS = 5, BUF = 4096 };
char laughs[BUF];
int o = snprintf(laughs, sizeof(laughs), "a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n");
for (int k = 1; k < LEVELS; k++) {
o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "a%d: &a%d [", k, k);
for (int i = 0; i < 10; i++) {
o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "%s*a%d", i ? ", " : "", k - 1);
}
o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "]\n");
}
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
ASSERT_NOT_NULL(doc);
static const char few[] = "base: &b {tag: \"1\"}\none: *b\ntwo: *b\n";
yyjson_mut_val *ok = cbm_helm_values_parse(doc, few, strlen(few));
yyjson_mut_val *bomb = cbm_helm_values_parse(doc, laughs, strlen(laughs));
yyjson_mut_doc_free(doc);
ASSERT_NOT_NULL(ok);
ASSERT_NULL(bomb);
PASS();
}
/* --set as Helm's strvals reads it: a list index past MaxIndex (65536) is an
* error, so the set is no value (a release with it is unknown) -- never a
* list padded to it; a key longer than the parser holds is no value either,
* never split into two keys. */
TEST(helm_values_set_index_cap_and_long_key) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
ASSERT_NOT_NULL(doc);
yyjson_mut_val *root = yyjson_mut_obj(doc);
ASSERT_TRUE(cbm_helm_values_set(doc, root, "list[3]", "x", false));
ASSERT_FALSE(cbm_helm_values_set(doc, root, "big[65537]", "x", false));
ASSERT_FALSE(cbm_helm_values_set(doc, root, "huge[9000000000000]", "x", false));
char key[700];
memset(key, 'k', sizeof(key) - 1);
key[sizeof(key) - 1] = '\0';
ASSERT_FALSE(cbm_helm_values_set(doc, root, key, "x", false));
yyjson_mut_doc_free(doc);
PASS();
}
/* A values key longer than 1 KiB read by an image is in its default_of
* mark whole -- a mark is never cut. */
TEST(helm_render_long_key_mark_whole) {
enum { KEY = 1500 };
char *key = malloc(KEY + 1);
ASSERT_NOT_NULL(key);
memset(key, 'k', KEY);
key[KEY] = '\0';
size_t vn = KEY + CBM_SZ_64;
char *vals = malloc(vn);
char *tpl = malloc(vn);
ASSERT_NOT_NULL(vals);
ASSERT_NOT_NULL(tpl);
snprintf(vals, vn, "%s: \"1.0\"\n", key);
snprintf(tpl, vn, "image: \"ghcr.io/acme/app:{{ index .Values \"%s\" }}\"\n", key);
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *v = cbm_helm_values_parse(doc, vals, strlen(vals));
CBMArena a;
cbm_arena_init_lazy(&a, CBM_SZ_4K);
cbm_helm_probe_t probe = {.line = 1};
cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, &probe, 1);
char want[KEY + 8];
snprintf(want, sizeof(want), "[\"%s\"]", key);
bool whole = probe.n == 1 && probe.keys[0] && strcmp(probe.keys[0], want) == 0;
cbm_arena_destroy(&a);
yyjson_mut_doc_free(doc);
free(key);
free(vals);
free(tpl);
ASSERT_TRUE(whole);
PASS();
}
/* A condition that is UNKNOWN runs no branch -- and what a branch would
* assign is UNKNOWN after it: `$tag = .Values.image.tagNew` under a
* .Capabilities test leaves no image naming the old tag. */
TEST(helm_unknown_branch_spoils_what_it_assigns) {
static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\", tagNew: \"2.0\"}\n";
static const char tpl[] =
"{{- $tag := .Values.image.tag -}}\n"
"{{- if semverCompare \">=1.29-0\" .Capabilities.KubeVersion.Version }}"
"{{- $tag = .Values.image.tagNew -}}{{- end }}\n"
"image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n";
char *got = helm_capture(vals, tpl, NULL, 3);
ASSERT_STR_EQ(got, "<UNKNOWN>");
free(got);
PASS();
}
/* set / unset / merge change a map in place -- not modelled: what it holds
* after is UNKNOWN (an image read before the change keeps its value). */
TEST(helm_mutator_spoils_the_map_it_changes) {
static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n";
static const char tpl[] = "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
"{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n"
"image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n";
char *before = helm_capture(vals, tpl, NULL, 1);
char *after = helm_capture(vals, tpl, NULL, 3);
ASSERT_STR_EQ(before, "ghcr.io/acme/app:1.0");
ASSERT_STR_EQ(after, "<UNKNOWN>");
free(before);
free(after);
PASS();
}
/* A break under an UNKNOWN condition in a range: whether the loop goes on
* is unknown, so its later iterations are not rendered (Helm with a true
* condition renders no image here). */
TEST(helm_unknown_break_leaves_the_loop) {
static const char tpl[] = "{{- range list \"a\" \"b\" }}\n"
"{{- if .Capabilities.APIVersions }}{{ break }}{{ end }}\n"
"image: \"ghcr.io/acme/{{ . }}:1\"\n"
"{{ end }}\n";
char *got = helm_capture("{}\n", tpl, NULL, 3);
ASSERT_STR_EQ(got, "<none>");
free(got);
PASS();
}
/* A change made to a new value -- fromYaml / deepCopy results, a part of one,
* an empty dict -- reaches nothing another part holds: the images after it
* stay known (the opentelemetry-ebpf-instrumentation configData pattern). */
TEST(helm_change_of_a_new_value_spoils_nothing) {
static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\ncfg: {a: 1}\n";
static const char tpl[] =
"{{- $config := dict -}}\n"
"{{- $user := deepCopy (.Values.cfg | default dict) -}}\n"
"{{- if .Capabilities.APIVersions }}{{- $config = toYaml .Values.cfg | fromYaml -}}"
"{{- $config = mergeOverwrite $config $user -}}{{- end }}\n"
"{{- $config = mergeOverwrite $config $user -}}\n"
"{{- $_ := unset (deepCopy .Values.cfg) \"a\" }}\n"
"image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n";
char *got = helm_capture(vals, tpl, NULL, 6);
ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0");
free(got);
PASS();
}
/* A compose project's .env feeds its compose file (variables in image,
* build): changing it is no closure repair -- the unchanged compose file's
* services would keep the old image. It rebuilds; no stale image remains. */
@@ -24063,6 +24287,16 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(incremental_werf_values_edit_keeps_chart_edge);
RUN_TEST(incremental_release_values_edit_rebuilds);
RUN_TEST(helm_values_follow_helm_semantics);
RUN_TEST(helm_render_range_budget_ends_unknown);
RUN_TEST(helm_render_indent_budget_ends_unknown);
RUN_TEST(helm_render_define_fanout_ends_unknown);
RUN_TEST(helm_values_alias_expansion_limit);
RUN_TEST(helm_values_set_index_cap_and_long_key);
RUN_TEST(helm_render_long_key_mark_whole);
RUN_TEST(helm_unknown_branch_spoils_what_it_assigns);
RUN_TEST(helm_mutator_spoils_the_map_it_changes);
RUN_TEST(helm_unknown_break_leaves_the_loop);
RUN_TEST(helm_change_of_a_new_value_spoils_nothing);
RUN_TEST(incremental_compose_dotenv_change_rebuilds);
RUN_TEST(incremental_dockerfile_edit_keeps_docker_build_edges);
RUN_TEST(incremental_second_build_declarer_keeps_the_edge);