From 3401def5ba3acecdfb636e12a25f6051bb44ed4e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 19 Oct 2026 01:59:48 +0200 Subject: [PATCH] fix(deploy): coarse rule for Helm changes not run, BasePath includes, render memory Review round 3 of #2677. The user decided on a rule sound by construction: a change of maps the render does not run exactly leaves every later image of that render UNKNOWN, with no exemption. Changes of maps (helm_template.c): - Under UNKNOWN control -- a skipped if / with / range body, the rest of a loop an UNKNOWN break / continue may leave, an include or template not run, a tpl not run -- any set / unset / merge (the must* forms too), any tpl, and any include / template of a name not known, of no define or template file of the chart, or of one that may change maps, leaves the images after it UNKNOWN. - An executed set / merge does too unless its target is a known map that is poisoned exactly; a merge also for a target already poisoned, the .Values root, or a map not known within it. The own provenance and the freshness scan are gone. - .Template.BasePath is modelled as Helm sets it: /templates, the top chart's name, then /charts/ per subchart (pass_k8s.c). An include naming one of the chart's template files by it (the checksum idiom) is scanned with the same rule; a file reached again while scanned, or no such file, may change maps. Bounds (helm_template.c): - A capture's keys are a hash set and its mark is built in one pass (one sort for the prefix check), its steps charged. - Each render works in an arena of its own; the caller keeps its results only. - The trip reason is recorded where a budget trips: an output trip is logged as output. Releases (pass_terraform.c, pass_k8s.c): - Any set_sensitive leaves the release without an image of its own (the name-overlap check is gone), and so does a postrender written as a dynamic block or an attribute. Cost on the 20 infra corpora against 5c5964ef: 20 Image edges lost, 0 added, no budget trip. 5 are changes the rule rightly cannot follow (a second mustMerge into a poisoned values map; a skipped part that may change maps), 15 come from checksum-included files that hold a tpl. Two controls of rounds 1-3 now read UNKNOWN; their tests say so. Tests: one per item, each failing on 5c5964ef by its assertion (the BasePath ones also on the coarse rule without it); the wrong-image scenarios of rounds 2 and 3 stay as tests. Signed-off-by: Martin Vogel --- src/pipeline/helm_template.c | 1009 +++++++++++------------------- src/pipeline/helm_template.h | 17 +- src/pipeline/pass_k8s.c | 114 +--- src/pipeline/pass_terraform.c | 100 +-- src/pipeline/pipeline_internal.h | 7 +- tests/test_grammar_probe_f.c | 110 +++- tests/test_pipeline.c | 341 +++++++++- 7 files changed, 843 insertions(+), 855 deletions(-) diff --git a/src/pipeline/helm_template.c b/src/pipeline/helm_template.c index 2f96db45d..5004c2e7d 100644 --- a/src/pipeline/helm_template.c +++ b/src/pipeline/helm_template.c @@ -1049,6 +1049,16 @@ void cbm_helm_defines_free(cbm_helm_defines_t *d) { cbm_free(CBM_MEM_CLASS_OTHER, d); } +/* Template file `rel` of the set; NULL: none. */ +static const hd_file_t *hd_file_find(const cbm_helm_defines_t *d, const char *rel) { + for (int i = 0; d && i < d->nfiles; i++) { + if (strcmp(d->files[i]->rel, rel) == 0) { + return d->files[i]; + } + } + return NULL; +} + static const hd_def_t *hd_find(const cbm_helm_defines_t *d, const char *name) { return d && d->names ? cbm_ht_get(d->names, name) : NULL; } @@ -1250,12 +1260,9 @@ struct hv { hv_map_t *m; hv_list_t *l; const char *path; /* the values path it holds; NULL: none */ - bool own; /* a new value holding nothing another part holds (fromYaml, deepCopy, - * a part of one; known or not): changing it changes nothing else */ }; static const hv_t HV_UNKNOWN = {.k = HV_UNK}; -static const hv_t HV_UNKNOWN_OWN = {.k = HV_UNK, .own = true}; static hv_t hv_nil(void) { return (hv_t){.k = HV_NIL}; @@ -1377,17 +1384,23 @@ typedef struct { bool brk; bool cont; long steps; - size_t written; /* bytes appended to any sink */ - size_t arena0; /* the arena's total when the render began */ - bool stop; /* over HELM_MAX_STEPS / _OUTPUT / _MEMORY: the render is UNKNOWN */ + size_t written; /* bytes appended to any sink */ + size_t arena0; /* the arena's total when the render began */ + bool stop; /* over HELM_MAX_STEPS / _OUTPUT / _MEMORY: the render is UNKNOWN */ + const char *why; /* which budget stopped it */ + const char *rel; /* the template's path (logs) */ /* maps a set / unset / merge changed in place (not modelled): what they * hold is UNKNOWN from then on -- by address ("%p" keys in the arena), * and for a merge of values also by values path (everything below it) */ CBMHashTable *poison_maps; CBMHashTable *poison_paths; - bool tainted; /* a change of unknown target: every capture ending later is UNKNOWN */ - CBMHashTable *mutates; /* define name -> it may change a map (he_define_may_mutate) */ - long busy_hits; /* defines met again while being scanned */ + /* a change of maps not run exactly: every capture ending later is UNKNOWN */ + bool tainted; + CBMHashTable *mutates; /* define name -> it may change a map (he_define_may_mutate) */ + CBMHashTable *file_mutates; /* template file -> the same (he_file_may_mutate) */ + const char *base_path; /* .Template.BasePath; NULL: not known */ + const char *chart_dir; /* where the chart's template files are (meta.dir) */ + long busy_hits; /* defines met again while being scanned */ /* probes of the top-level template */ const char *top_src; size_t top_len; @@ -1400,6 +1413,7 @@ typedef struct { const char **ckeys; int nck; int ckcap; + CBMHashTable *ckset; /* ckeys as a set */ TSParser *parser; TSTree **trees; int ntrees; @@ -1411,24 +1425,47 @@ static bool hst_over_memory(const hst_t *st) { return cbm_arena_total(st->a) - st->arena0 > HELM_MAX_MEMORY; } +/* The render stops: budget `why` is spent. */ +static void hst_trip(hst_t *st, const char *why) { + if (!st->stop) { + st->stop = true; + st->why = why; + } +} + /* One step of work; false once the render is over its budget. */ static bool hst_step(hst_t *st) { - if (!st->stop && (++st->steps > HELM_MAX_STEPS || hst_over_memory(st))) { - st->stop = true; + if (!st->stop && ++st->steps > HELM_MAX_STEPS) { + hst_trip(st, "steps"); + } else if (!st->stop && hst_over_memory(st)) { + hst_trip(st, "memory"); } return !st->stop; } /* n more bytes of output; false (and the render stopped) past the budget. */ static bool hst_write(hst_t *st, size_t n) { - if (st->stop || n > HELM_MAX_OUTPUT - st->written || hst_over_memory(st)) { - st->stop = true; + if (!st->stop && n > HELM_MAX_OUTPUT - st->written) { + hst_trip(st, "output"); + } else if (!st->stop && hst_over_memory(st)) { + hst_trip(st, "memory"); + } + if (st->stop) { return false; } st->written += n; return true; } +/* A change of maps the render does not run exactly (see he_call): from here + * on no capture is known. */ +static void hst_taint(hst_t *st, const char *cause) { + if (!st->tainted) { + st->tainted = true; + cbm_log_debug("helm.render.taint", "file", st->rel ? st->rel : "", "cause", cause); + } +} + static void hs_append(hst_t *st, hsink_t *s, const char *p, size_t n) { if (s->unk || n == 0) { return; @@ -1466,15 +1503,21 @@ static void hst_record(hst_t *st, const char *path) { if (st->capture < 0 || !path) { return; } - for (int i = 0; i < st->nck; i++) { - if (strcmp(st->ckeys[i], path) == 0) { - return; - } + if (!st->ckset) { + st->ckset = cbm_ht_create(0); + } + if (!st->ckset) { + st->cap.unk = true; /* a key read not kept: the capture's mark is not whole */ + return; + } + if (cbm_ht_get(st->ckset, path)) { + return; } if (st->nck == st->ckcap) { int cap = st->ckcap ? st->ckcap * PAIR_LEN : CBM_SZ_16; const char **g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); if (!g) { + st->cap.unk = true; return; } if (st->nck) { @@ -1483,6 +1526,7 @@ static void hst_record(hst_t *st, const char *path) { st->ckeys = g; st->ckcap = cap; } + cbm_ht_set(st->ckset, path, (void *)(uintptr_t)SKIP_ONE); st->ckeys[st->nck++] = path; } @@ -1603,9 +1647,10 @@ static bool hst_set_add(hst_t *st, CBMHashTable **set, const char *k) { static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth); -/* Map v changed in place; false when v is no map (or no room to say so). A - * merge (`deep`) may change every map within it too -- a map the template - * made (dict) holds others by reference, so those are poisoned as well. */ +/* Map v changed in place; false when v is no map, when (`deep`, a merge) + * a map within it cannot be poisoned whole, or with no room to say so. A + * merge may change every map within it too -- a map the template made (dict) + * holds others by reference, so those are poisoned as well. */ static bool hst_poison(hst_t *st, const hv_t *v, bool deep) { return hst_poison_at(st, v, deep, 0); } @@ -1614,27 +1659,22 @@ static bool hst_poison_at(hst_t *st, const hv_t *v, bool deep, int depth) { if (depth > HELM_MAX_NESTING || !hst_step(st)) { return false; } - if (deep && v->k == HV_LIST) { - /* a list itself is never changed in place; the maps in it may be */ - for (int i = 0; i < v->l->n; i++) { - if ((hv_is_map(&v->l->items[i]) || hv_is_list(&v->l->items[i])) && - !hst_poison_at(st, &v->l->items[i], deep, depth + SKIP_ONE)) { - return false; - } - } - return true; - } - /* a values list (deep): by its path, which every item's path extends */ - const void *map = v->k == HV_MAP ? (const void *)v->m - : v->k == HV_JSON && (yyjson_mut_is_obj(v->j) || (deep && v->path)) - ? (const void *)v->j - : NULL; + const void *map = v->k == HV_MAP ? (const void *)v->m + : v->k == HV_JSON && yyjson_mut_is_obj(v->j) ? (const void *)v->j + : NULL; if (!map) { return false; } + /* a deep change reaches every map within: one not known, one a change + * already made unknown, the values root (every path below it), or a map + * not of the values (whose maps no path covers) cannot be poisoned whole */ + if (deep && (hv_poisoned(st, v) || (v->path && !v->path[0]) || (v->k == HV_JSON && !v->path))) { + return false; + } if (deep && v->k == HV_MAP) { for (int i = 0; i < v->m->n; i++) { - if (hv_is_map(&v->m->vals[i]) && !hst_poison_at(st, &v->m->vals[i], deep, depth + 1)) { + const hv_t *x = &v->m->vals[i]; + if (x->k == HV_UNK || (hv_is_map(x) && !hst_poison_at(st, x, deep, depth + SKIP_ONE))) { return false; } } @@ -1767,6 +1807,105 @@ static const char *hst_scalar(hst_t *st, const char *s, size_t n) { return n ? cbm_arena_strndup(st->a, s, n) : NULL; } +/* strcmp order of string s against k + "." (lk = strlen(k)): 0 for each s + * that starts with it. */ +static int hst_cmp_below(const char *s, const char *k, size_t lk) { + int r = strncmp(s, k, lk); + if (r != 0) { + return r; + } + unsigned char c = (unsigned char)s[lk]; + return c < '.' ? -SKIP_ONE : c > '.' ? SKIP_ONE : 0; +} + +/* Does sorted (n keys, strcmp order) hold a key below k (k + "." ...)? */ +static bool hst_has_below(const char *const *sorted, int n, const char *k) { + size_t lk = strlen(k); + int lo = 0; + int hi = n; + while (lo < hi) { + int mid = lo + (hi - lo) / PAIR_LEN; + if (hst_cmp_below(sorted[mid], k, lk) < 0) { + lo = mid + SKIP_ONE; + } else { + hi = mid; + } + } + return lo < n && hst_cmp_below(sorted[lo], k, lk) == 0; +} + +static int hst_str_cmp(const void *x, const void *y) { + return strcmp(*(const char *const *)x, *(const char *const *)y); +} + +/* The leaves the capture read as a JSON array in the order read (NULL: none, + * or *whole false: not built in full). A path that is the prefix of another + * (at a `.`) is dropped. One sort and one allocation: k keys cost O(k log k) + * steps and their bytes of memory. */ +static const char *hst_capture_keys(hst_t *st, bool *whole) { + *whole = true; + int n = st->nck; + if (n == 0) { + return NULL; + } + const char **sorted = cbm_arena_alloc(st->a, (size_t)n * sizeof(*sorted)); + bool *keep = cbm_arena_alloc(st->a, (size_t)n * sizeof(*keep)); + if (!sorted || !keep) { + *whole = false; + return NULL; + } + memcpy(sorted, st->ckeys, (size_t)n * sizeof(*sorted)); + qsort(sorted, (size_t)n, sizeof(*sorted), hst_str_cmp); + size_t total = PAIR_LEN; /* [ ] */ + int kept = 0; + for (int i = 0; i < n; i++) { + if (!hst_step(st)) { + *whole = false; + return NULL; + } + const char *k = st->ckeys[i]; + keep[i] = k[0] && !hst_has_below(sorted, n, k); + if (!keep[i]) { + continue; + } + total += PAIR_LEN + (kept ? SKIP_ONE : 0); /* "" and , */ + for (const char *c = k; *c; c++) { + total += *c == '"' || *c == '\\' ? PAIR_LEN : SKIP_ONE; + } + kept++; + } + if (kept == 0) { + return NULL; + } + char *json = cbm_arena_alloc(st->a, total + SKIP_ONE); + if (!json) { + *whole = false; + return NULL; + } + size_t o = 0; + json[o++] = '['; + for (int i = 0; i < n; i++) { + if (!keep[i]) { + continue; + } + if (o > SKIP_ONE) { + json[o++] = ','; + } + json[o++] = '"'; + for (const char *c = st->ckeys[i]; *c; c++) { + if (*c == '"' || *c == '\\') { + json[o++] = '\\'; + } + json[o++] = *c; + } + json[o++] = '"'; + } + json[o++] = ']'; + json[o] = '\0'; + return json; +} + +/* The capture (if any) ends: its image and the keys it read join its probe. */ static void hst_capture_end(hst_t *st) { if (st->capture < 0) { return; @@ -1787,50 +1926,21 @@ static void hst_capture_end(hst_t *st) { p->cap = cap; } } - const char **img = p->n < p->cap ? p->image : NULL; - const char **keys = p->n < p->cap ? p->keys : NULL; - if (img && keys) { - img[p->n] = st->cap.unk || st->tainted - ? NULL - : hst_scalar(st, st->cap.buf ? st->cap.buf : "", st->cap.n); - /* the leaves read: a path that is the prefix of another is dropped */ - const char *json = NULL; + if (p->n < p->cap) { bool whole = true; - for (int i = 0; i < st->nck && whole; i++) { - size_t li = strlen(st->ckeys[i]); - bool prefix = false; - for (int j = 0; j < st->nck && !prefix; j++) { - prefix = j != i && strncmp(st->ckeys[j], st->ckeys[i], li) == 0 && - st->ckeys[j][li] == '.'; - } - if (prefix || !st->ckeys[i][0]) { - continue; - } - char *esc = cbm_arena_alloc(st->a, li * PAIR_LEN + SKIP_ONE); - if (!esc) { - whole = false; - break; - } - size_t o = 0; - for (const char *c = st->ckeys[i]; *c; c++) { - if (*c == '"' || *c == '\\') { - esc[o++] = '\\'; - } - esc[o++] = *c; - } - esc[o] = '\0'; - json = cbm_arena_sprintf(st->a, "%s%s\"%s\"", json ? json : "[", json ? "," : "", esc); - whole = json != NULL; - } - keys[p->n] = json && whole ? cbm_arena_sprintf(st->a, "%s]", json) : NULL; - if (!whole || (json && !keys[p->n])) { - img[p->n] = NULL; /* a mark not written in full: no image, never one unmarked */ - } + const char *keys = hst_capture_keys(st, &whole); + p->image[p->n] = st->cap.unk || st->tainted || !whole + ? NULL /* a mark not written in full: no image, never one unmarked */ + : hst_scalar(st, st->cap.buf ? st->cap.buf : "", st->cap.n); + p->keys[p->n] = whole ? keys : NULL; p->n++; } st->capture = -SKIP_ONE; st->cap = (hsink_t){0}; st->nck = 0; + if (st->ckset) { + cbm_ht_clear(st->ckset); + } } /* Output of the top-level template: into the capture until its newline. */ @@ -2347,25 +2457,6 @@ static const char *he_called_define(hst_t *st, const char *src, TSNode c, bool * return name; } -/* How new a value is: HF_DEEP holds nothing another part holds (fromYaml, - * deepCopy, a dict / list of literals); HF_SHALLOW is a new map or list that - * may hold others' maps by reference (dict / list / omit / pick of values) -- - * a set / unset of it changes nothing else, a merge into it may. */ -typedef enum { HF_NONE = 0, HF_SHALLOW, HF_DEEP } hfresh_lvl_t; - -/* Sprig functions whose result holds nothing another part holds. */ -static bool he_own_fn(const char *s, size_t n) { - static const char *const OWN[] = { - "fromYaml", "fromJson", "fromYamlArray", "fromJsonArray", "deepCopy", "mustDeepCopy", NULL}; - return hy_in(s, n, OWN); -} - -/* Sprig functions whose result is a new map or list of what they are given. */ -static bool he_wrap_fn(const char *s, size_t n) { - static const char *const WRAP[] = {"dict", "list", "omit", "pick", NULL}; - return hy_in(s, n, WRAP); -} - /* The text of call node c's function (a function_call's, or a bare * identifier's); NULL when it has none. */ static const char *he_fn_text(const char *src, TSNode c, size_t *n) { @@ -2378,316 +2469,18 @@ static const char *he_fn_text(const char *src, TSNode c, size_t *n) { return src + b; } -/* The name text of variable node var ("" for `$`). */ -static const char *he_var_text(const char *src, TSNode var, size_t *n) { - TSNode nm = hn_field(var, "name"); - if (ts_node_is_null(nm)) { - *n = 0; - return ""; - } - uint32_t b = ts_node_start_byte(nm); - *n = ts_node_end_byte(nm) - b; - return src + b; +static bool he_is_fn(const char *s, size_t n, const char *name) { + return n == strlen(name) && memcmp(s, name, n) == 0; } -/* The variables a scan saw made new, and how. */ -typedef struct { - const char **name; - size_t *len; - hfresh_lvl_t *lvl; - int n; - int cap; -} hfresh_t; - -static hfresh_lvl_t he_fresh_var(const hfresh_t *f, const char *src, TSNode var) { - size_t vl = 0; - const char *vs = he_var_text(src, var, &vl); - for (int i = f->n - SKIP_ONE; i >= 0; i--) { - if (f->len[i] == vl && memcmp(f->name[i], vs, vl) == 0) { - return f->lvl[i]; - } - } - return HF_NONE; -} - -static bool he_literal(TSNode x) { - return hn_is(x, "interpreted_string_literal") || hn_is(x, "raw_string_literal") || - hn_is(x, "int_literal") || hn_is(x, "float_literal") || hn_is(x, "true") || - hn_is(x, "false") || hn_is(x, "nil"); -} - -/* How new expression `val` is: a constructor's result (`... | fromYaml`, - * `dict`), or a part dug / got / indexed out of a variable that is new. */ -static hfresh_lvl_t he_fresh_value(const hfresh_t *f, const char *src, TSNode val) { - while (hn_is(val, "parenthesized_pipeline") && ts_node_named_child_count(val) == SKIP_ONE) { - val = ts_node_named_child(val, 0); - } - if (hn_is(val, "chained_pipeline")) { - uint32_t c = ts_node_named_child_count(val); - val = c ? ts_node_named_child(val, c - SKIP_ONE) : (TSNode){0}; - } - size_t fl = 0; - const char *fs = - hn_is(val, "function_call") || hn_is(val, "identifier") ? he_fn_text(src, val, &fl) : NULL; - if (!fs) { - return HF_NONE; - } - if (he_own_fn(fs, fl)) { - return HF_DEEP; - } - TSNode args = hn_field(val, "arguments"); - uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); - if (he_wrap_fn(fs, fl)) { - bool literal = true; /* a dict / list of literals holds no one's map */ - for (uint32_t i = 0; i < na && literal; i++) { - literal = he_literal(ts_node_named_child(args, i)); - } - return literal ? HF_DEEP : HF_SHALLOW; - } - if (na == 0) { - return HF_NONE; - } - /* a part of a deep-new value: dig takes its map last; get and index first */ - TSNode from = fl == strlen("dig") && memcmp(fs, "dig", fl) == 0 - ? ts_node_named_child(args, na - SKIP_ONE) - : (fl == strlen("get") && memcmp(fs, "get", fl) == 0) || - (fl == strlen("index") && memcmp(fs, "index", fl) == 0) - ? ts_node_named_child(args, 0) - : (TSNode){0}; - return hn_is(from, "variable") && he_fresh_var(f, src, from) == HF_DEEP ? HF_DEEP : HF_NONE; -} - -/* Is expression x deep-new: a literal, a deep-new variable or value, or a - * dict / list of such? */ -static bool he_deep_value(const hfresh_t *f, const char *src, TSNode x, int depth) { - while (hn_is(x, "parenthesized_pipeline") && ts_node_named_child_count(x) == SKIP_ONE) { - x = ts_node_named_child(x, 0); - } - if (depth > HELM_MAX_NESTING || ts_node_is_null(x)) { - return false; - } - if (he_literal(x)) { - return true; - } - if (hn_is(x, "variable")) { - return he_fresh_var(f, src, x) == HF_DEEP; - } - hfresh_lvl_t lvl = he_fresh_value(f, src, x); - if (lvl != HF_SHALLOW || !hn_is(x, "function_call")) { - return lvl == HF_DEEP; - } - size_t fl = 0; - const char *fs = he_fn_text(src, x, &fl); - if (!fs || !(fl == strlen("dict") || fl == strlen("list")) || - (memcmp(fs, "dict", fl) != 0 && memcmp(fs, "list", fl) != 0)) { - return false; /* omit / pick keep the maps they are given */ - } - TSNode args = hn_field(x, "arguments"); - for (uint32_t i = 0; !ts_node_is_null(args) && i < ts_node_named_child_count(args); i++) { - if (!he_deep_value(f, src, ts_node_named_child(args, i), depth + SKIP_ONE)) { - return false; - } - } - return true; -} - -/* Variable var is `lvl` new from here on (HF_NONE too: what it held before - * no longer counts -- the latest entry of a name is the one looked up). */ -/* Has the scan seen variable var defined or assigned (an entry in f)? */ -static bool he_fresh_seen(const hfresh_t *f, const char *src, TSNode var) { - size_t vl = 0; - const char *vs = he_var_text(src, var, &vl); - for (int i = 0; i < f->n; i++) { - if (f->len[i] == vl && memcmp(f->name[i], vs, vl) == 0) { - return true; - } - } - return false; -} - -/* Note in f that variable var is now lvl-new. A first HF_NONE entry is kept - * too: it hides the running frame's value of var from the scan. */ -static void he_fresh_put(hst_t *st, hfresh_t *f, const char *src, TSNode var, hfresh_lvl_t lvl) { - if (ts_node_is_null(var) || - (lvl == HF_NONE && he_fresh_seen(f, src, var) && he_fresh_var(f, src, var) == HF_NONE)) { - return; - } - if (f->n == f->cap) { - int cap = f->cap ? f->cap * PAIR_LEN : CBM_SZ_16; - const char **g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); - size_t *gl = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gl)); - hfresh_lvl_t *gv = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gv)); - if (!g || !gl || !gv) { - return; - } - if (f->n) { - memcpy(g, f->name, (size_t)f->n * sizeof(*g)); - memcpy(gl, f->len, (size_t)f->n * sizeof(*gl)); - memcpy(gv, f->lvl, (size_t)f->n * sizeof(*gv)); - } - *f = (hfresh_t){g, gl, gv, f->n, cap}; - } - f->name[f->n] = he_var_text(src, var, &f->len[f->n]); - f->lvl[f->n] = lvl; - f->n++; -} - -/* Is expression x deep-new for the scan (f) -- or, a variable the scan did - * not see made, with fr (a part of the running template), a value of fr - * that is own? */ -static bool he_value_owned(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode x) { - if (he_deep_value(f, src, x, 0)) { - return true; - } - if (!fr || !hn_is(x, "variable") || he_fresh_seen(f, src, x)) { - return false; - } - size_t l = 0; - const char *name = hn_var_name(fr, x, &l); - hvar_t *v = hst_var(st, fr, name, l); - return v && v->v.own; -} - -/* Is call node c a set / unset / merge of variable var whose every other - * argument is own (the variable stays as new as it is)? */ -static bool he_self_change(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode c, - TSNode var) { - size_t fl = 0; - const char *fs = hn_is(c, "function_call") ? he_fn_text(src, c, &fl) : NULL; - TSNode args = fs && he_mutator(fs, fl) ? hn_field(c, "arguments") : (TSNode){0}; - uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); - TSNode first = na ? ts_node_named_child(args, 0) : (TSNode){0}; - if (!hn_is(first, "variable")) { - return false; - } - size_t al = 0; - size_t bl = 0; - const char *as = he_var_text(src, first, &al); - const char *bs = he_var_text(src, var, &bl); - if (al != bl || memcmp(as, bs, al) != 0) { - return false; - } - for (uint32_t i = SKIP_ONE; i < na; i++) { - if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { - return false; - } - } - return true; -} - -/* A definition (`$x := v`) or an assignment (`$x = v`) into f: how new $x is - * now -- what v is, or, for `$x = set $x ...` of own values, what $x was. */ -static void he_fresh_note(hst_t *st, hframe_t *fr, hfresh_t *f, const char *src, TSNode def) { - TSNode var = hn_field(def, "variable"); - TSNode val = hn_field(def, "value"); - if (ts_node_is_null(var)) { - return; - } - hfresh_lvl_t lvl = he_deep_value(f, src, val, 0) ? HF_DEEP : he_fresh_value(f, src, val); - if (lvl == HF_NONE && hn_is(def, "assignment") && he_self_change(st, fr, f, src, val, var)) { - lvl = he_fresh_var(f, src, var); - } - he_fresh_put(st, f, src, var, lvl); -} - -/* Are the values set / merge call c puts into its target own (see - * he_value_owned)? */ -static bool he_puts_deep(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode c) { - TSNode args = hn_field(c, "arguments"); - uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); - for (uint32_t i = SKIP_ONE; i < na; i++) { - if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { - return false; - } - } - return !hn_is(ts_node_parent(c), "chained_pipeline"); /* a piped value: not known here */ -} - -/* Does expression x read `.` (a field of it)? In a with / range body `.` is - * not the frame's. */ -static bool he_reads_dot(TSNode x) { - TSTreeCursor cur = ts_tree_cursor_new(x); - bool found = false; - for (bool more = true; more && !found;) { - TSNode c = ts_tree_cursor_current_node(&cur); - found = hn_is(c, "dot") || hn_is(c, "field"); - if (found || ts_tree_cursor_goto_first_child(&cur)) { - continue; - } - while (!ts_tree_cursor_goto_next_sibling(&cur)) { - if (!ts_tree_cursor_goto_parent(&cur)) { - more = false; - break; - } - } - } - ts_tree_cursor_delete(&cur); - return found; -} - -/* Does expression x name a variable the scan (f) saw defined or assigned -- - * one whose value in the running frame is not the one x would read? */ -static bool he_names_rebound(const hfresh_t *f, const char *src, TSNode x) { - TSTreeCursor cur = ts_tree_cursor_new(x); - bool found = false; - for (bool more = true; more && !found;) { - TSNode c = ts_tree_cursor_current_node(&cur); - found = hn_is(c, "variable") && he_fresh_seen(f, src, c); - if (found || ts_tree_cursor_goto_first_child(&cur)) { - continue; - } - while (!ts_tree_cursor_goto_next_sibling(&cur)) { - if (!ts_tree_cursor_goto_parent(&cur)) { - more = false; - break; - } - } - } - ts_tree_cursor_delete(&cur); - return found; -} - -/* What a change of the value of expression x (in frame fr; NULL: not - * running) would reach: false when it reaches nothing another part holds -- - * x is own, or a known map, now poisoned (`deep`: a merge). A value not known - * here -- or x naming a variable the part (f) redefined: true. */ -static bool he_change_reaches(hst_t *st, hframe_t *fr, const hfresh_t *f, const char *src, TSNode x, - bool dot_known, bool deep) { - if (!fr || ts_node_is_null(x) || (!dot_known && he_reads_dot(x)) || - he_names_rebound(f, src, x)) { - return true; - } - /* read without recording: the part is not run, its reads are no capture's */ - int capture = st->capture; - st->capture = -SKIP_ONE; - hv_t v = he_eval(st, fr, x); - st->capture = capture; - /* a known map is poisoned even when own: what it holds is not known now */ - bool poisoned = hst_poison(st, &v, deep); - return !poisoned && !v.own; -} - -static void he_revoke_own(hst_t *st, hframe_t *fr, TSNode x); - -/* May running node n (of text src) change a map another part holds -- a set / - * unset / merge whose target is not a value n made new itself (`$d := dict`, - * `set (dict) ...`; a merge needs it deep-new), in n or in a define it - * includes (whose changes reach only its argument)? With fr (n is a part of - * the running template not run), a target or include argument -- `.`, a - * field, a call's result such as `index .Values "image"`, a variable of fr -- - * is looked at: own, or a known map (then poisoned): no. A change that puts a - * value another part may hold into a variable makes it no longer new / own. - * An include of a computed name is a template file by path, another - * template's work, which no render models. The text a tpl renders is not - * looked into. `made` (NULL: none) is what the parts before n in its body - * made new or redefined; n's own are added to it. */ -static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hframe_t *fr, - bool dot_known, hfresh_t *made) { +/* May running node n (of text src) change a map in place? It may when it + * holds a set / unset / merge (whatever the target), a tpl (its text is not + * looked into), or an include / template of a name not known or of one that + * may (he_define_may_mutate). A variable's name ($set) is no call. */ +static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth) { if (depth > HELM_MAX_NESTING) { return true; } - hfresh_t own = {0}; /* variables n makes from a new map */ - hfresh_t *fresh = made ? made : &own; TSTreeCursor cur = ts_tree_cursor_new(n); bool found = false; bool more = true; @@ -2696,51 +2489,17 @@ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hfram const char *t = ts_node_type(c); size_t fl = 0; const char *fs = NULL; - if (strcmp(t, "variable_definition") == 0 || strcmp(t, "assignment") == 0) { - he_fresh_note(st, fr, fresh, src, c); - } else if (strcmp(t, "function_call") == 0 && (fs = he_fn_text(src, c, &fl)) != NULL && - he_mutator(fs, fl)) { - TSNode args = hn_field(c, "arguments"); - TSNode target = ts_node_is_null(args) ? (TSNode){0} : ts_node_named_child(args, 0); - bool deep = fs[0] == 'm'; /* a merge reaches the maps within its target too */ - bool puts_deep = he_puts_deep(st, fr, fresh, src, c); - /* how new the target is: a variable n made, or a new value itself - * (`set (dict) ...`); anything else -- `.`, a field, a call's - * result (`index .Values "image"`) -- is looked at in fr */ - hfresh_lvl_t lvl = hn_is(target, "variable") ? he_fresh_var(fresh, src, target) - : ts_node_is_null(target) ? HF_NONE - : he_deep_value(fresh, src, target, 0) - ? HF_DEEP - : he_fresh_value(fresh, src, target); - bool spared = lvl == HF_DEEP || (lvl == HF_SHALLOW && !deep); - found = !spared && he_change_reaches(st, fr, fresh, src, target, dot_known, deep); - if (!puts_deep && hn_is(target, "variable")) { - /* it now holds a value another part may hold */ - if (lvl == HF_DEEP) { - he_fresh_put(st, fresh, src, target, HF_SHALLOW); - } else if (fr && lvl == HF_NONE) { - he_revoke_own(st, fr, target); - } - } + bool call = strcmp(t, "function_call") == 0 || + (strcmp(t, "identifier") == 0 && hn_is(ts_node_parent(c), "chained_pipeline")); + if (call && (fs = he_fn_text(src, c, &fl)) != NULL && + (he_mutator(fs, fl) || he_is_fn(fs, fl, "tpl"))) { + found = true; } else if (strcmp(t, "function_call") == 0 || strcmp(t, "template_action") == 0 || strcmp(t, "block_action") == 0) { bool computed = false; const char *name = he_called_define(st, src, c, &computed); - if (!computed && name && he_define_may_mutate(st, name, depth + SKIP_ONE)) { - /* what the define changes is its argument (or a map within it) */ - TSNode args = hn_field(c, "arguments"); - TSNode arg = hn_is(c, "function_call") - ? (ts_node_is_null(args) ? (TSNode){0} - : ts_node_named_child(args, SKIP_ONE)) - : hn_field(c, "argument"); - found = !he_deep_value(fresh, src, arg, 0) && - he_change_reaches(st, fr, fresh, src, arg, dot_known, true); - } - } else if (strcmp(t, "identifier") == 0 && (fs = he_fn_text(src, c, &fl)) != NULL && - he_mutator(fs, fl) && hn_is(ts_node_parent(c), "chained_pipeline")) { - found = true; /* `x | set`: the target is the piped value, not known here */ + found = computed || (name && he_define_may_mutate(st, name, depth + SKIP_ONE)); } - /* a variable's name ($set) is no call */ if (found || (strcmp(t, "variable") != 0 && ts_tree_cursor_goto_first_child(&cur))) { continue; } @@ -2755,6 +2514,51 @@ static bool he_may_mutate(hst_t *st, const char *src, TSNode n, int depth, hfram return found || st->stop; } +/* The chart's template file an include names -- Helm names each one + * `<.Template.BasePath>/` -- or NULL: none of this chart's. */ +static const hd_file_t *he_template_file(hst_t *st, const char *name) { + if (!st->base_path || !st->chart_dir) { + return NULL; + } + size_t bl = strlen(st->base_path); + if (strncmp(name, st->base_path, bl) != 0 || name[bl] != '/') { + return NULL; + } + const char *rel = cbm_arena_sprintf(st->a, "%s%stemplates/%s", st->chart_dir, + st->chart_dir[0] ? "/" : "", name + bl + SKIP_ONE); + return rel ? hd_file_find(st->defs, rel) : NULL; +} + +/* May template file f, included by its name and so run whole, change a map + * in place (he_may_mutate over all of it)? Memoized; a file reached again + * while it is being scanned -- one including itself -- may. A no that leaned + * on a define still being scanned is not kept. */ +static bool he_file_may_mutate(hst_t *st, const hd_file_t *f, int depth) { + enum { HM_NO = 1, HM_YES = 2, HM_BUSY = 3 }; + if (!f->tree) { + return true; + } + if (!st->file_mutates) { + st->file_mutates = cbm_ht_create(0); + } + if (!st->file_mutates) { + return true; + } + uintptr_t memo = (uintptr_t)cbm_ht_get(st->file_mutates, f->rel); + if (memo) { + return memo != HM_NO; + } + cbm_ht_set(st->file_mutates, f->rel, (void *)(uintptr_t)HM_BUSY); + long hits = st->busy_hits; + bool m = he_may_mutate(st, f->src, ts_tree_root_node(f->tree), depth); + if (m || st->busy_hits == hits) { + cbm_ht_set(st->file_mutates, f->rel, (void *)(uintptr_t)(m ? HM_YES : HM_NO)); + } else { + (void)cbm_ht_delete(st->file_mutates, f->rel); + } + return m; +} + /* Memoized per define. A define reached again while it is being scanned * counts as no for now; a no that leaned on such a one is not kept (STALE: * scanned again when next asked), so defines including each other never fix @@ -2763,7 +2567,10 @@ static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { enum { HM_NO = 1, HM_YES = 2, HM_BUSY = 3, HM_STALE = 4 }; const hd_def_t *d = hd_find(st->defs, name); if (!d) { - return false; /* a define not found is an error: nothing of it runs */ + /* a template file of the chart by its name (the checksum idiom) is + * looked into; a name not the chart's (a library's) may */ + const hd_file_t *f = he_template_file(st, name); + return f ? he_file_may_mutate(st, f, depth) : true; } if (!st->mutates) { st->mutates = cbm_ht_create(0); @@ -2781,7 +2588,7 @@ static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { cbm_ht_set(st->mutates, key, (void *)(uintptr_t)HM_BUSY); } long hits = st->busy_hits; - bool m = he_may_mutate(st, d->file->src, d->node, depth, NULL, false, NULL); + bool m = he_may_mutate(st, d->file->src, d->node, depth); bool leaned = st->busy_hits > hits; /* on a define still being scanned */ if (st->mutates && key) { uintptr_t res = m ? HM_YES : leaned ? HM_STALE : HM_NO; @@ -2790,116 +2597,17 @@ static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { return m; } -static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n); - -/* A define not run would change its argument v (or a map within it): a known - * one is poisoned whole; one not known that is not own spoils what follows. */ -static void he_spoil_arg(hst_t *st, const hv_t *v) { - bool known = hv_is_map(v) || hv_is_list(v); - if (!(known && hst_poison(st, v, true)) && !v->own && - (v->k == HV_UNK || hv_is_map(v) || hv_is_list(v))) { - st->tainted = true; - } -} - -/* Is the result of `fn` over a own (see hv_t): a part dug / got / indexed / - * taken out of an own value no change has touched (a poisoned map may hold - * another's now); a dict / list / default / coalesce / ternary whose every map - * or list (and every value not known) is own? */ -static bool he_own_result(const hst_t *st, const char *fn, const hv_t *a, int n) { - if (n < SKIP_ONE) { - return strcmp(fn, "dict") == 0 || strcmp(fn, "list") == 0; /* empty and new */ - } - if (strcmp(fn, "dig") == 0) { - return a[n - SKIP_ONE].own && !hv_poisoned(st, &a[n - SKIP_ONE]); - } - if (strcmp(fn, "get") == 0 || strcmp(fn, "index") == 0 || strcmp(fn, "first") == 0 || - strcmp(fn, "last") == 0) { - return a[0].own && !hv_poisoned(st, &a[0]); - } - if (strcmp(fn, "dict") == 0 || strcmp(fn, "list") == 0 || strcmp(fn, "default") == 0 || - strcmp(fn, "coalesce") == 0 || strcmp(fn, "ternary") == 0) { - for (int i = 0; i < n; i++) { - if (!a[i].own && (a[i].k == HV_UNK || hv_is_map(&a[i]) || hv_is_list(&a[i]))) { - return false; - } - } - return true; - } - return false; -} - -/* A function call `fn` with its evaluated arguments (the piped value last), - * its result marked own when it is. */ -static hv_t he_apply(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { - if (he_own_fn(fn, strlen(fn))) { - return HV_UNKNOWN_OWN; /* fromYaml / deepCopy: a new value, known or not */ - } - hv_t r = he_apply_fn(st, fr, fn, a, n); - r.own = r.own || he_own_result(st, fn, a, n); - return r; -} - -/* The variable expression x reads its value out of (`$v`, `$v.a`, `(index $v - * "a")`, `get $v "a"`, `dig "a" $v`); a null node when there is none. */ -static TSNode he_root_var(const hframe_t *fr, TSNode x) { - for (int depth = 0; depth < HELM_MAX_NESTING && !ts_node_is_null(x); depth++) { - if (hn_is(x, "variable")) { - return x; - } - if (hn_is(x, "parenthesized_pipeline") && ts_node_named_child_count(x) == SKIP_ONE) { - x = ts_node_named_child(x, 0); - } else if (hn_is(x, "selector_expression")) { - x = hn_field(x, "operand"); - } else if (hn_is(x, "function_call")) { - size_t fl = 0; - const char *fs = he_fn_text(fr->src, x, &fl); - TSNode args = hn_field(x, "arguments"); - uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); - bool last = fs && fl == strlen("dig") && memcmp(fs, "dig", fl) == 0; - bool first = fs && ((fl == strlen("get") && memcmp(fs, "get", fl) == 0) || - (fl == strlen("index") && memcmp(fs, "index", fl) == 0) || - (fl == strlen("first") && memcmp(fs, "first", fl) == 0) || - (fl == strlen("last") && memcmp(fs, "last", fl) == 0)); - if (!na || (!first && !last)) { - break; - } - x = ts_node_named_child(args, last ? na - SKIP_ONE : 0); - } else { - break; - } - } - return (TSNode){0}; -} - -/* A change put a value another part may hold into what expression x reads: - * the variable it reads it out of is own no more. */ -static void he_revoke_own(hst_t *st, hframe_t *fr, TSNode x) { - TSNode var = he_root_var(fr, x); - if (ts_node_is_null(var)) { - return; - } - size_t l = 0; - const char *name = hn_var_name(fr, var, &l); - hvar_t *v = hst_var(st, fr, name, l); - if (v) { - v->v.own = false; - } -} - static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) { for (int i = 0; i < n; i++) { if (a[i].k == HV_UNK || hv_poisoned(st, &a[i])) { - /* an include of a known define not run (an argument UNKNOWN or - * poisoned): a change it would make to its argument is missed -- - * a known argument is poisoned whole, one not known and not own - * spoils what follows (one of a name not known -- a template file - * by path -- is another template's work, which no render models) */ - if (strcmp(fn, "include") == 0 && a[0].k == HV_STR && n == PAIR_LEN) { - const char *dn = cbm_arena_strndup(st->a, a[0].s, a[0].sn); - if (!dn || he_define_may_mutate(st, dn, st->nesting)) { - he_spoil_arg(st, &a[1]); - } + /* an include not run (an argument UNKNOWN or poisoned) that may + * change maps, or a tpl not run: what it would change is missed */ + bool runs_code = strcmp(fn, "include") == 0 || strcmp(fn, "tpl") == 0; + const char *dn = strcmp(fn, "include") == 0 && a[0].k == HV_STR + ? cbm_arena_strndup(st->a, a[0].s, a[0].sn) + : NULL; + if (runs_code && (!dn || he_define_may_mutate(st, dn, st->nesting))) { + hst_taint(st, fn); } return HV_UNKNOWN; } @@ -2912,6 +2620,7 @@ static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) } if (strcmp(fn, "tpl") == 0 && n == PAIR_LEN && a[0].k == HV_STR) { if (!st->parser) { + hst_taint(st, "tpl"); return HV_UNKNOWN; } char *src = cbm_arena_strndup(st->a, a[0].s, a[0].sn); @@ -2936,6 +2645,7 @@ static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) TSNode root = ts_tree_root_node(t); if (ts_node_has_error(root) || ++st->nesting > HELM_MAX_NESTING) { st->nesting -= ts_node_has_error(root) ? 0 : SKIP_ONE; + hst_taint(st, "tpl"); return HV_UNKNOWN; } hsink_t out = {0}; @@ -3177,7 +2887,7 @@ static hv_t he_apply_fn(hst_t *st, hframe_t *fr, const char *fn, hv_t *a, int n) lines += s.s[i] == '\n'; } if ((uint64_t)w > HELM_MAX_OUTPUT / lines) { - st->stop = true; + hst_trip(st, "output"); return HV_UNKNOWN; } hsink_t out = {0}; @@ -3256,29 +2966,21 @@ static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { TSNode args = hn_is(call, "identifier") ? (TSNode){0} : hn_field(call, "arguments"); uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); if (he_mutator(fn, fl)) { - /* set / unset / merge change a map in place -- not modelled: what it - * holds is UNKNOWN from here on (after a merge, every map within it). - * A target not known that is not own -- whatever expression gives it: - * index / get / first return a map another part holds -- spoils every - * image after it. */ + /* set / unset / merge change a map in place -- not run: the map is + * poisoned, what it holds UNKNOWN from here on (after a merge, every + * map within it too). That is exact only for a known map (a merge: + * whose every map within is known, see hst_poison); any other target + * -- one not known, whatever gives it -- leaves every image after it + * UNKNOWN. */ TSNode first = na ? ts_node_named_child(args, 0) : (TSNode){0}; hv_t target = na ? he_eval(st, fr, first) : piped ? *piped : HV_UNKNOWN; - /* what goes into it: own too, or the map now holds another's */ - bool put_own = true; - for (uint32_t i = SKIP_ONE; i < na + (piped && na ? SKIP_ONE : 0); i++) { - hv_t v = i < na ? he_eval(st, fr, ts_node_named_child(args, i)) : *piped; - put_own = put_own && (v.own || (v.k != HV_UNK && !hv_is_map(&v) && !hv_is_list(&v))); + for (uint32_t i = SKIP_ONE; i < na; i++) { + (void)he_eval(st, fr, ts_node_named_child(args, i)); /* what it runs */ } - /* a known map is poisoned even when own: what it holds is not known now */ - bool poisoned = hst_poison(st, &target, fn[0] == 'm'); - if (!poisoned && !target.own) { - st->tainted = true; + if (!hv_is_map(&target) || !hst_poison(st, &target, fn[0] == 'm')) { + hst_taint(st, fn); } - if (!put_own && !ts_node_is_null(first)) { - he_revoke_own(st, fr, first); - } - /* the changed map is what set / merge return */ - return target.own && put_own ? HV_UNKNOWN_OWN : HV_UNKNOWN; + return HV_UNKNOWN; /* the changed map */ } bool lazy = strcmp(fn, "and") == 0 || strcmp(fn, "or") == 0; if (lazy) { @@ -3307,7 +3009,7 @@ static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { if (piped) { a[na] = *piped; } - return he_apply(st, fr, fn, a, n); + return he_apply_fn(st, fr, fn, a, n); } static hv_t he_literal_int(hframe_t *fr, TSNode n) { @@ -3463,77 +3165,28 @@ static void he_items(hst_t *st, hframe_t *fr, TSNode parent, const char *field) } } -/* Does assigning expression `value` to variable var keep it own (when it is): - * a deep-new value, or a set / merge of var itself with own values - * (`$dst = ... | fromYaml | merge $dst`)? */ -static bool he_assign_owns(hst_t *st, hframe_t *fr, const hfresh_t *f, TSNode var, TSNode value) { - const char *src = fr->src; - if (he_deep_value(f, src, value, 0)) { - return true; - } - while (hn_is(value, "parenthesized_pipeline") && ts_node_named_child_count(value) == SKIP_ONE) { - value = ts_node_named_child(value, 0); - } - TSNode call = value; - TSNode piped = {0}; - if (hn_is(value, "chained_pipeline")) { - uint32_t c = ts_node_named_child_count(value); - if (c < PAIR_LEN) { - return false; - } - call = ts_node_named_child(value, c - SKIP_ONE); - piped = ts_node_named_child(value, c - PAIR_LEN); - } - size_t fl = 0; - const char *fs = hn_is(call, "function_call") ? he_fn_text(src, call, &fl) : NULL; - TSNode args = fs && he_mutator(fs, fl) ? hn_field(call, "arguments") : (TSNode){0}; - uint32_t na = ts_node_is_null(args) ? 0 : ts_node_named_child_count(args); - TSNode first = na ? ts_node_named_child(args, 0) : (TSNode){0}; - if (!hn_is(first, "variable")) { - return false; - } - size_t al = 0; - size_t bl = 0; - const char *as = he_var_text(src, first, &al); - const char *bs = he_var_text(src, var, &bl); - if (al != bl || memcmp(as, bs, al) != 0) { - return false; - } - for (uint32_t i = SKIP_ONE; i < na; i++) { - if (!he_value_owned(st, fr, f, src, ts_node_named_child(args, i))) { - return false; - } - } - return ts_node_is_null(piped) || he_value_owned(st, fr, f, src, piped); -} - /* How a part not run would leave the loop around it. */ enum { HL_NONE = 0, HL_CONTINUE = 1, HL_BREAK = 2 }; /* The variables of the frames around n that n assigns (`$x = ...`) become - * UNKNOWN (an own one stays own when what n gives it keeps it so); returns - * how n would leave the loop around it (HL_*) -- by a break / continue not - * inside a range of n's own (`in_range`: n is a range's body). `fresh` is - * what the parts before n in its body made new; n's own are added to it. */ -static int he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range, hfresh_t *fresh) { + * UNKNOWN; returns how n would leave the loop around it (HL_*) -- by a break + * / continue not inside a range of n's own (`in_range`: n is a range's + * body). */ +static int he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range) { TSTreeCursor cur = ts_tree_cursor_new(n); int leave = HL_NONE; bool more = true; while (more && hst_step(st)) { TSNode c = ts_tree_cursor_current_node(&cur); const char *t = ts_node_type(c); - if (strcmp(t, "variable_definition") == 0) { - he_fresh_note(st, fr, fresh, fr->src, c); - } else if (strcmp(t, "assignment") == 0) { + if (strcmp(t, "assignment") == 0) { TSNode var = hn_field(c, "variable"); size_t l = 0; const char *name = ts_node_is_null(var) ? NULL : hn_var_name(fr, var, &l); hvar_t *v = name ? hst_var(st, fr, name, l) : NULL; if (v) { - bool keeps = v->v.own && he_assign_owns(st, fr, fresh, var, hn_field(c, "value")); - v->v = keeps ? HV_UNKNOWN_OWN : HV_UNKNOWN; + v->v = HV_UNKNOWN; } - he_fresh_note(st, fr, fresh, fr->src, c); } else if (strcmp(t, "break_action") == 0 || strcmp(t, "continue_action") == 0) { /* a range around it inside n (n itself included) is the one it leaves */ bool own = in_range || hn_is(n, "range_action"); @@ -3560,38 +3213,26 @@ static int he_scan_skipped(hst_t *st, hframe_t *fr, TSNode n, bool in_range, hfr } /* The children of n but its condition / collection (what was run), each: - * the variables it would assign and the changes it would make spoiled (see - * he_scan_skipped / he_may_mutate); returns how it would leave the loop. */ -static int he_spoil_parts(hst_t *st, hframe_t *fr, TSNode n, const char *only, bool in_range, - bool dot_known) { + * the variables it would assign UNKNOWN, and a change of maps it may make + * (he_may_mutate) leaves every image after it UNKNOWN; returns how it would + * leave the loop. */ +static int he_spoil_parts(hst_t *st, hframe_t *fr, TSNode n, const char *only, bool in_range) { int leave = HL_NONE; bool mutates = false; - /* what a branch's parts made new so far: a later part of it sees it */ - hfresh_t scanned = {0}; - hfresh_t made = {0}; - const char *branch = NULL; uint32_t cc = ts_node_child_count(n); for (uint32_t i = 0; i < cc; i++) { TSNode c = ts_node_child(n, i); const char *f = ts_node_field_name_for_child(n, i); - if (f && strcmp(f, "condition") == 0) { - branch = NULL; /* an else if: a new branch */ - } if (!ts_node_is_named(c) || hn_is(c, "range_variable_definition") || (f && (strcmp(f, "condition") == 0 || strcmp(f, "range") == 0)) || (only && !(f && strcmp(f, only) == 0))) { continue; } - if (f && (!branch || strcmp(f, branch) != 0)) { - branch = f; - scanned = (hfresh_t){0}; - made = (hfresh_t){0}; - } - leave |= he_scan_skipped(st, fr, c, in_range, &scanned); - mutates = mutates || he_may_mutate(st, fr->src, c, st->nesting, fr, dot_known, &made); + leave |= he_scan_skipped(st, fr, c, in_range); + mutates = mutates || he_may_mutate(st, fr->src, c, st->nesting); } if (mutates) { - st->tainted = true; + hst_taint(st, "skipped"); } return leave; } @@ -3605,8 +3246,7 @@ static int he_spoil_parts(hst_t *st, hframe_t *fr, TSNode n, const char *only, b static void he_skipped(hst_t *st, hframe_t *fr, TSNode n) { hst_out_unknown(st, fr); bool range = hn_is(n, "range_action"); - bool with = hn_is(n, "with_action"); - int leave = he_spoil_parts(st, fr, n, NULL, range, !range && !with); + int leave = he_spoil_parts(st, fr, n, NULL, range); if (leave == HL_NONE) { return; } @@ -3617,7 +3257,7 @@ static void he_skipped(hst_t *st, hframe_t *fr, TSNode n) { loop = ts_node_parent(loop); } if (hn_is(loop, "range_action")) { - (void)he_spoil_parts(st, fr, loop, "body", true, false); + (void)he_spoil_parts(st, fr, loop, "body", true); } if (leave & HL_BREAK) { st->brk = true; @@ -3764,10 +3404,9 @@ static void he_template(hst_t *st, hframe_t *fr, TSNode n) { hv_t a = ts_node_is_null(arg) ? hv_nil() : he_eval(st, fr, arg); if (a.k == HV_UNK || hv_poisoned(st, &a)) { hst_out_unknown(st, fr); - /* the define not run: a change it would make to its argument is - * missed (see he_spoil_arg) */ + /* the define not run: a change of maps it may make is missed */ if (he_define_may_mutate(st, name, st->nesting)) { - he_spoil_arg(st, &a); + hst_taint(st, "template"); } return; } @@ -3819,8 +3458,18 @@ static void he_item(hst_t *st, hframe_t *fr, TSNode c) { /* Define `name` rendered with dot / $ = arg into `into`. */ static hv_t he_render_define(hst_t *st, const char *name, hv_t arg, hsink_t *into) { const hd_def_t *d = hd_find(st->defs, name); - if (!d || ++st->nesting > HELM_MAX_NESTING) { - st->nesting -= d ? SKIP_ONE : 0; + if (!d) { + /* not run -- a template file of the chart by its name (the checksum + * idiom) or a name not the chart's: its output is UNKNOWN, and a + * change it may make (he_define_may_mutate) is missed */ + if (he_define_may_mutate(st, name, st->nesting)) { + hst_taint(st, "include"); + } + return HV_UNKNOWN; + } + if (++st->nesting > HELM_MAX_NESTING) { + st->nesting--; + hst_taint(st, "include"); /* nested too deep: not run */ return HV_UNKNOWN; } int mark = st->nvars; @@ -3862,6 +3511,32 @@ static hv_t hv_cstr(const char *s) { return s ? hv_str(s, strlen(s)) : hv_nil(); } +/* The probes' captures, made in the render's own arena, copied into a: the + * caller keeps a render's results, never its work. An image whose mark is not + * copied is not kept either. */ +static void helm_keep_results(CBMArena *a, cbm_helm_probe_t *probes, int nprobes) { + for (int i = 0; i < nprobes; i++) { + cbm_helm_probe_t *p = &probes[i]; + int n = p->n; + const char **img = n ? cbm_arena_alloc(a, (size_t)n * sizeof(*img)) : NULL; + const char **keys = n ? cbm_arena_alloc(a, (size_t)n * sizeof(*keys)) : NULL; + if (!img || !keys) { + n = 0; + img = NULL; + keys = NULL; + } + for (int c = 0; c < n; c++) { + const char *k = p->keys[c] ? cbm_arena_strdup(a, p->keys[c]) : NULL; + img[c] = p->image[c] && (k || !p->keys[c]) ? cbm_arena_strdup(a, p->image[c]) : NULL; + keys[c] = k; + } + p->image = img; + p->keys = keys; + p->n = n; + p->cap = n; + } +} + 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) { @@ -3874,14 +3549,21 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, if (!src || nprobes == 0) { return true; } - hst_t st = {.a = a, + /* the render's work lives in its own arena, freed when it ends: the + * memory budget bounds one render, and no render keeps another's */ + CBMArena work; + cbm_arena_init_lazy(&work, CBM_SZ_4K); + hst_t st = {.a = &work, .defs = defs, .top_src = src, .top_len = len, .probes = probes, .nprobes = nprobes, + .rel = rel, + .base_path = chart ? chart->base_path : NULL, + .chart_dir = chart ? chart->dir : NULL, .capture = -SKIP_ONE, - .arena0 = cbm_arena_total(a)}; + .arena0 = cbm_arena_total(&work)}; st.scratch = yyjson_mut_doc_new(cbm_json_writer_allocator()); st.parser = ts_parser_new(); if (!st.scratch || !st.parser || @@ -3892,13 +3574,14 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, if (st.scratch) { yyjson_mut_doc_free(st.scratch); } + cbm_arena_destroy(&work); return true; } int nl = SKIP_ONE; for (size_t i = 0; i < len; i++) { nl += src[i] == '\n'; } - uint32_t *lines = cbm_arena_alloc(a, (size_t)nl * sizeof(*lines)); + uint32_t *lines = cbm_arena_alloc(&work, (size_t)nl * sizeof(*lines)); TSTree *tree = lines ? ts_parser_parse_string(st.parser, NULL, src, (uint32_t)len) : NULL; if (tree) { int k = 0; @@ -3913,7 +3596,8 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, yyjson_mut_val *vals = values ? values : yyjson_mut_obj(st.scratch); hv_map_t *ch = hv_struct(&st, 3); hv_map_t *rl = hv_struct(&st, SKIP_ONE); - hv_map_t *root = hv_struct(&st, PAIR_LEN + SKIP_ONE); + hv_map_t *tp = st.base_path ? hv_struct(&st, SKIP_ONE) : NULL; + hv_map_t *root = hv_struct(&st, PAIR_LEN + PAIR_LEN); if (ch && rl && root) { hv_struct_set(ch, "Name", hv_cstr(chart ? chart->name : NULL)); hv_struct_set(ch, "Version", hv_cstr(chart ? chart->version : NULL)); @@ -3922,6 +3606,11 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, hv_struct_set(root, "Values", hv_json(vals, "")); hv_struct_set(root, "Chart", (hv_t){.k = HV_MAP, .m = ch}); hv_struct_set(root, "Release", (hv_t){.k = HV_MAP, .m = rl}); + if (tp) { + /* .Template.Name is not modelled: UNKNOWN */ + hv_struct_set(tp, "BasePath", hv_cstr(st.base_path)); + hv_struct_set(root, "Template", (hv_t){.k = HV_MAP, .m = tp}); + } hv_t dot = {.k = HV_MAP, .m = root}; hframe_t fr = {.src = src, .dot = dot, .var_base = 0, .out = NULL}; hst_var_push(&st, "", 0, dot); @@ -3938,12 +3627,18 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, if (st.mutates) { cbm_ht_free(st.mutates); } + if (st.file_mutates) { + cbm_ht_free(st.file_mutates); + } if (st.poison_maps) { cbm_ht_free(st.poison_maps); } if (st.poison_paths) { cbm_ht_free(st.poison_paths); } + if (st.ckset) { + cbm_ht_free(st.ckset); + } if (st.stop) { /* Helm would not finish this template: none of its images is known, * also those captured before the budget ran out */ @@ -3954,9 +3649,9 @@ bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len, } } cbm_log_warn("helm.render.budget", "file", rel ? rel : "", "reason", - st.steps > HELM_MAX_STEPS ? "steps" - : st.written >= HELM_MAX_OUTPUT ? "output" - : "memory"); + st.why ? st.why : "memory"); } + helm_keep_results(a, probes, nprobes); + cbm_arena_destroy(&work); return !st.stop; } diff --git a/src/pipeline/helm_template.h b/src/pipeline/helm_template.h index 44985a245..e2136b5e8 100644 --- a/src/pipeline/helm_template.h +++ b/src/pipeline/helm_template.h @@ -44,11 +44,17 @@ bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char * * 1.1) into out; false for a map, a list or null. */ bool cbm_helm_scalar_text(yyjson_mut_val *v, char *out, size_t sz); -/* A chart's metadata, as its templates see it under .Chart. */ +/* A chart's metadata, as its templates see it under .Chart -- and where its + * templates are: .Template.BasePath as Helm sets it (`/templates`, + * the chart path its root's name and `/charts/` per subchart + * below it; NULL: not known) and the chart's directory as its template files + * are added to the defines ("" at the repository root). */ typedef struct { const char *name; const char *version; const char *app_version; + const char *base_path; + const char *dir; } cbm_helm_chart_meta_t; /* The named templates (`define` / `block`) of one chart family -- a chart and @@ -75,10 +81,11 @@ 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. False when the render ran past its step or - * output budget -- a template Helm would not finish either (logged): then - * every capture is UNKNOWN. */ + * in arena a, which gets only these: the render works in an arena of its own). + * Values paths are dot-joined keys, a key's own `.` escaped (`\.`), as --set + * writes them. False when the render ran past its step, output or memory + * 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); diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index d20ca4cce..034e8984c 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -3920,9 +3920,6 @@ typedef struct { int tcap; k8s_himgs_t dflt; /* the containers' images with the defaults */ int edges; - /* the release rendered: its set_sensitive names (JSON array; NULL: none) -- - * an image reading one is not known, its value never read */ - const char *sensitive; } k8s_helm_t; /* The templated images of the facts, kept past them. */ @@ -4319,9 +4316,9 @@ 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 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) { + const k8s_hchart_t *c, const char *as_name, const char *full, + 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); @@ -4329,6 +4326,9 @@ static void k8s_helm_render(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_bundles_ * getAliasDependency) */ cbm_helm_chart_meta_t meta = c->meta; meta.name = as_name ? as_name : meta.name; + /* .Template.BasePath: the chart's path (full) and templates/, as Helm */ + meta.base_path = full ? cbm_arena_sprintf(&h->a, "%s/templates", full) : NULL; + meta.dir = c->dir; cbm_helm_render(&h->a, t[from].file, src, len, values ? values : c->values, &meta, defs, probes, np); (void)to; @@ -4354,68 +4354,8 @@ static void k8s_helm_emit_uses(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const k8s } } -/* Do values paths a and b overlap -- one is the other or lies within it - * (a key boundary `.` / `[` after the shorter; "" is the whole values)? */ -static bool k8s_path_overlap(const char *a, const char *b) { - size_t al = strlen(a); - size_t bl = strlen(b); - size_t m = al < bl ? al : bl; - if (strncmp(a, b, m) != 0) { - return false; - } - char c = (al < bl ? b : a)[m]; - return m == 0 || c == '\0' || c == '.' || c == '['; -} - -/* Does values path `path` (of the release's chart) overlap a set_sensitive - * name of the release rendered? */ -static bool k8s_helm_sensitive_path(const k8s_helm_t *h, const char *path) { - if (!h->sensitive || !path) { - return false; - } - yyjson_doc *d = yyjson_read(h->sensitive, strlen(h->sensitive), 0); - bool hit = !d; /* names not read: say it touches */ - size_t idx, max; - yyjson_val *k; - yyjson_arr_foreach(d ? yyjson_doc_get_root(d) : NULL, idx, max, k) { - const char *s = yyjson_get_str(k); - hit = hit || (s && k8s_path_overlap(path, s)); - } - if (d) { - yyjson_doc_free(d); - } - return hit; -} - -/* Does an image reading values keys `keys` (a JSON array, of its chart at - * key path `prefix` within the release's; global.* are the release's own) - * read a set_sensitive name of the release rendered? */ -static bool k8s_helm_sensitive_keys(k8s_helm_t *h, const char *keys, const char *prefix) { - if (!h->sensitive || !keys) { - return false; - } - yyjson_doc *d = yyjson_read(keys, strlen(keys), 0); - bool hit = !d; - size_t idx, max; - yyjson_val *k; - yyjson_arr_foreach(d ? yyjson_doc_get_root(d) : NULL, idx, max, k) { - const char *key = yyjson_get_str(k); - bool global = key && strncmp(key, "global.", strlen("global.")) == 0; - const char *full = !key ? NULL - : global || !prefix || !prefix[0] - ? key - : cbm_arena_sprintf(&h->a, "%s.%s", prefix, key); - hit = hit || !full || k8s_helm_sensitive_path(h, full); - } - if (d) { - yyjson_doc_free(d); - } - return hit; -} - /* Chart `from` -DEPLOYS {via, ref: image, image, override_of}-> the images an - * overlay changed: rendered there, absent from the defaults' images. An image - * reading a set_sensitive name (whose value is not read) is not known. */ + * overlay changed: rendered there, absent from the defaults' images. */ static void k8s_helm_emit_overrides(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int64_t from, const char *via, const k8s_himgs_t *dflt, const k8s_himgs_t *got, yyjson_mut_val *overlay, @@ -4429,7 +4369,7 @@ static void k8s_helm_emit_overrides(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int6 strcmp(dflt->v[j].ref, x->ref) == 0; } const char *ovr = k8s_helm_overridden(h, x->keys, overlay, globals, prefix); - if (same || !ovr || from <= 0 || k8s_helm_sensitive_keys(h, x->keys, prefix)) { + if (same || !ovr || from <= 0) { continue; } k8s_himg_t e = {from, x->img, x->ref, NULL, ovr}; @@ -4520,12 +4460,14 @@ static bool k8s_helm_disabled(k8s_helm_t *h, yyjson_mut_val *dep, yyjson_mut_val * them, each subchart's defaults with its parent's `:` 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), *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. */ + * the name dir's chart is included under (its alias; NULL for top), *full its + * chart path as Helm's ChartFullPath (top's name, then `/charts/` + * per chart down; NULL: not known). 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, - const char **as_name) { + const char **as_name, const char **full) { 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 */ @@ -4538,6 +4480,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, } *path = ""; *as_name = NULL; + *full = NULL; if (ti < 0) { return NULL; } @@ -4545,6 +4488,7 @@ static yyjson_mut_val *k8s_helm_context(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, if (!cur_chart || !cur_chart->values) { return NULL; } + *full = cur_chart->meta.name; yyjson_mut_val *cur = top_vals ? cbm_helm_values_coalesce(h->doc, cur_chart->values, top_vals) : cur_chart->values; for (int i = (ti == na ? na : ti) - SKIP_ONE; i >= -SKIP_ONE && ti != na; i--) { @@ -4564,6 +4508,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; + *full = *full ? cbm_arena_sprintf(&h->a, "%s/charts/%s", *full, alias) : NULL; 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; @@ -4583,10 +4528,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, const char *as_name, int from, int to, - yyjson_mut_val *vals, const char *src, size_t len, + const k8s_hchart_t *c, const char *as_name, const char *full, + 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); + k8s_helm_render(ctx, h, b, c, as_name, full, 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); @@ -4614,18 +4559,19 @@ static void k8s_helm_link_defaults(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 : NULL; const char *path = NULL; const char *as_name = NULL; + const char *full = 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, &as_name); + vals = k8s_helm_context(ctx, h, b, &l, c->dir, fam, NULL, &path, &as_name, &full); } 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, as_name, from, to, vals, src, (size_t)len, probes, np, - &uses); + k8s_helm_images_of(ctx, h, b, c, as_name, full, from, to, vals, src, (size_t)len, + probes, np, &uses); } cbm_free(CBM_MEM_CLASS_OTHER, src); from = to; @@ -4680,16 +4626,17 @@ static void k8s_helm_release_local(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 k8s_hchart_t *c = in ? k8s_helm_chart(ctx, h, l, dir) : NULL; const char *path = NULL; const char *as_name = NULL; + const char *full = NULL; yyjson_mut_val *vals = - c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path, &as_name) : NULL; + c ? k8s_helm_context(ctx, h, b, l, dir, top, rv, &path, &as_name, &full) : 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, as_name, from, to, vals, srcs, (size_t)len, probes, np, - &got); + k8s_helm_images_of(ctx, h, b, c, as_name, full, 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, @@ -4748,9 +4695,7 @@ static void k8s_helm_convention(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int64_t : "", dig[0] ? dig : tag) : NULL; - /* a set_sensitive name within the mapping: its value is not read */ - bool sensitive = k8s_helm_sensitive_path(h, path ? path : ""); - int64_t img = ref && !sensitive && k8s_helm_has_host(ref) && cbm_image_ref_valid(ref) + int64_t img = ref && k8s_helm_has_host(ref) && cbm_image_ref_valid(ref) ? cbm_pipeline_image_node(ctx, ref) : 0; int before = emitted->n; @@ -4987,12 +4932,9 @@ static void k8s_helm_link_releases(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8 if (jd) { yyjson_doc_free(jd); } - h->sensitive = r->sensitive; k8s_helm_release(ctx, b, h, &l, r->src, "terraform", r->chart, rv); - h->sensitive = NULL; cbm_free(CBM_MEM_CLASS_OTHER, r->values); } - cbm_free(CBM_MEM_CLASS_OTHER, r->sensitive); } cbm_free(CBM_MEM_CLASS_OTHER, ctx->tf_releases); ctx->tf_releases = NULL; diff --git a/src/pipeline/pass_terraform.c b/src/pipeline/pass_terraform.c index a320f6071..e9abd9c38 100644 --- a/src/pipeline/pass_terraform.c +++ b/src/pipeline/pass_terraform.c @@ -645,8 +645,8 @@ 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"` / `"set_list"` / `"set_sensitive"` -- - * entries (and names) made by a for_each, never literal? */ +/* Is block `a` a `dynamic "set"` / `"set_list"` / `"set_sensitive"` / + * `"postrender"` -- 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); @@ -663,7 +663,8 @@ static bool tf_dynamic_set(const tf_t *t, TSNode a) { return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) || (ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0) || (ll == strlen("\"set_sensitive\"") && - strncmp(t->src + lb, "\"set_sensitive\"", ll) == 0); + strncmp(t->src + lb, "\"set_sensitive\"", ll) == 0) || + (ll == strlen("\"postrender\"") && strncmp(t->src + lb, "\"postrender\"", ll) == 0); } /* Is node n an identifier spelling `word`? */ @@ -676,57 +677,15 @@ static bool tf_word(const tf_t *t, TSNode n, const char *word) { return l == strlen(word) && strncmp(t->src + b, word, l) == 0; } -/* The expression of attribute `name` of a block's body or of an HCL object - * (`{ name = ..., value = ... }`); a null node when there is none. */ -static TSNode tf_entry_attr(const tf_t *t, TSNode entry, const char *name) { - TSNode e = tf_unwrap(entry); - for (uint32_t j = 0; j < ts_node_named_child_count(e); j++) { - TSNode c = ts_node_named_child(e, j); - if (tf_type(e, "block") && tf_type(c, "body")) { - for (uint32_t k = 0; k < ts_node_named_child_count(c); k++) { - TSNode at = ts_node_named_child(c, k); - if (tf_type(at, "attribute") && ts_node_named_child_count(at) >= PAIR_LEN && - tf_word(t, ts_node_named_child(at, 0), name)) { - return ts_node_named_child(at, SKIP_ONE); - } - } - } else if (tf_type(e, "object") && tf_type(c, "object_elem")) { - TSNode kn = tf_unwrap(ts_node_child_by_field_name(c, "key", (uint32_t)strlen("key"))); - TSNode id = tf_type(kn, "variable_expr") ? ts_node_named_child(kn, 0) : (TSNode){0}; - if (tf_word(t, id, name)) { - return ts_node_child_by_field_name(c, "val", (uint32_t)strlen("val")); - } - } - } - return (TSNode){0}; -} - -/* The literal name of set_sensitive entry `entry` (a block, or an object of - * the provider v3 list) appended to names -- the name only, the value is - * never read; false when the name is no literal. */ -static bool tf_sensitive_name(const tf_t *t, yyjson_mut_doc *doc, yyjson_mut_val *names, - TSNode entry) { - yyjson_mut_val *nm = tf_json(t, doc, tf_entry_attr(t, entry, "name")); - if (!yyjson_mut_is_str(nm)) { - return false; - } - yyjson_mut_arr_append(names, nm); - return true; -} - /* 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, is made by a `dynamic` block, or a postrender may - * rewrite what it deploys. set_sensitive values are never read: their names - * go to *sensitive (a JSON array, cbm_alloc; NULL: none), and an image that - * reads one is not known (pass_k8s.c). */ -static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles, - char **sensitive) { - *sensitive = NULL; + * part is no literal, is made by a `dynamic` block, is sensitive (its value + * is never read), or a postrender -- a block, an attribute or a dynamic one + * -- may rewrite what it deploys. */ +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; - yyjson_mut_val *names = doc ? yyjson_mut_arr(doc) : NULL; - bool ok = root != NULL && names != NULL; + bool ok = root != NULL; for (int pass = 0; ok && pass < PAIR_LEN; pass++) { for (uint32_t i = 0; ok && i < ts_node_named_child_count(body); i++) { TSNode a = ts_node_named_child(body, i); @@ -735,31 +694,14 @@ 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 and dynamic set values are not read: the release's - * values would be partial; a postrender may rewrite its images */ - if ((kl == strlen("set_list") && strncmp(t->src + kb, "set_list", kl) == 0) || - tf_dynamic_set(t, a) || (tf_type(a, "block") && tf_word(t, key, "postrender"))) { + /* set_list, set_sensitive and dynamic set values are not read: + * the release's values would be partial; a postrender may + * rewrite its images */ + if (tf_word(t, key, "set_list") || tf_word(t, key, "set_sensitive") || + tf_word(t, key, "postrender") || tf_dynamic_set(t, a)) { ok = false; break; } - bool is_sensitive = - kl == strlen("set_sensitive") && strncmp(t->src + kb, "set_sensitive", kl) == 0; - if (pass == 0 && is_sensitive && tf_type(a, "block")) { - ok = tf_sensitive_name(t, doc, names, a); - continue; - } - if (pass == 0 && is_sensitive && tf_type(a, "attribute")) { - /* provider v3: set_sensitive = [{ name, value }, ...] */ - TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE)); - ok = tf_type(list, "tuple"); - for (uint32_t j = 0; ok && j < ts_node_named_child_count(list); j++) { - TSNode it = ts_node_named_child(list, j); - if (!tf_type(it, "tuple_start") && !tf_type(it, "tuple_end")) { - ok = tf_sensitive_name(t, doc, names, it); - } - } - continue; - } if (pass == 0 && is_values && tf_type(a, "attribute")) { TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE)); ok = tf_type(list, "tuple"); @@ -791,15 +733,6 @@ static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, if (ok) { size_t len = 0; out = yyjson_mut_val_write_opts(root, 0, cbm_json_writer_allocator(), &len, NULL); - if (out && yyjson_mut_arr_size(names) > 0) { - *sensitive = - yyjson_mut_val_write_opts(names, 0, cbm_json_writer_allocator(), &len, NULL); - if (!*sensitive) { - /* the names not kept: no image of the release could be told apart */ - cbm_free(CBM_MEM_CLASS_OTHER, out); - out = NULL; - } - } } if (doc) { yyjson_mut_doc_free(doc); @@ -821,9 +754,8 @@ static void tf_release_push(tf_t *t, int64_t src, int64_t chart, TSNode body) { } const char **files = NULL; int nfiles = 0; - char *sensitive = NULL; - char *values = tf_release_values(t, body, &files, &nfiles, &sensitive); - ctx->tf_releases[ctx->tf_release_n++] = (cbm_tf_release_t){src, chart, values, sensitive}; + char *values = tf_release_values(t, body, &files, &nfiles); + ctx->tf_releases[ctx->tf_release_n++] = (cbm_tf_release_t){src, chart, values}; /* the values files it reads, like a kustomization's valuesFile: an edit of * one alone renders the release again (pipeline_incremental.c) */ for (int i = 0; i < nfiles; i++) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 402474c20..553aa19d9 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -385,10 +385,9 @@ typedef struct { * when a part is not a literal (an interpolation, a variable). pass_k8s.c * renders the chart's images with them. */ typedef struct cbm_tf_release { - int64_t src; /* the resource.helm_release node */ - int64_t chart; /* the Chart node */ - char *values; /* owned (cbm_free); NULL: unknown */ - char *sensitive; /* owned: JSON array of its set_sensitive names (never values); NULL: none */ + int64_t src; /* the resource.helm_release node */ + int64_t chart; /* the Chart node */ + char *values; /* owned (cbm_free); NULL: unknown */ } cbm_tf_release_t; /* Shared context passed to each pass function. diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index 7b89c755a..0c8cf008e 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -4871,9 +4871,10 @@ TEST(probe_helm_release_terraform_dynamic_set) { PASS(); } -/* A set_sensitive whose value is never read: a release image reading its - * name (image.tag) is not known; one reading none of them still is. A - * postrender may rewrite what a release deploys: no image of its own. */ +/* A set_sensitive's value is never read: any set_sensitive leaves the + * release without an image of its own, whatever its name -- image.tag or + * db.password (which kept its edge in round 3). A postrender may rewrite + * what a release deploys: no image of its own either. */ TEST(probe_helm_release_terraform_sensitive_and_postrender) { static const GpfFile files[] = { GPF_APP_CHART, @@ -4889,15 +4890,105 @@ TEST(probe_helm_release_terraform_sensitive_and_postrender) { "resource \"helm_release\" \"post\" {\n name = \"post\"\n" " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" " value = \"3.3\"\n }\n postrender {\n binary_path = \"./kustomize.sh\"\n }\n}\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("any set_sensitive and a postrender make release images unknown"); + } + PASS(); +} + +/* A set_sensitive whose name an image reads on another line ($tag, before + * the image line): the release still has no image of its own -- never one + * with the chart's default tag. */ +TEST(probe_helm_release_terraform_sensitive_read_earlier) { + static const GpfFile files[] = { + {"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\nappVersion: \"1.0\"\n"}, + {"charts/web/values.yaml", "image:\n repository: ghcr.io/acme/web\n"}, + {"charts/web/templates/deploy.yaml", + "{{- $tag := .Values.image.tag | default .Chart.AppVersion }}\n" + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n template:\n" + " spec:\n containers:\n - name: web\n" + " image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n"}, + {"tf/main.tf", + "resource \"helm_release\" \"web\" {\n name = \"web\"\n" + " chart = \"../charts/web\"\n set {\n name = \"image.repository\"\n" + " value = \"quay.io/acme/web\"\n }\n set_sensitive {\n name = \"image.tag\"\n" + " value = var.tag\n }\n}\n"}}; + static const char want[] = + "Chart:web -> Image:ghcr.io/acme/web DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\"]\n" + "Resource:Deployment/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:1.0 " + "ref=image default_of=[\"image.repository\"]"; + if (!gpf_expect_image_edges(files, 4, want)) { + FAIL("a set_sensitive read on another line makes the release images unknown"); + } + PASS(); +} + +/* A postrender as a dynamic block or as an attribute (provider v3) may + * rewrite what a release deploys, as a block does: no image of its own. */ +TEST(probe_helm_release_terraform_postrender_forms) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", + "resource \"helm_release\" \"dyn\" {\n name = \"dyn\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" + " value = \"3.3\"\n }\n dynamic \"postrender\" {\n for_each = var.post\n" + " content {\n binary_path = postrender.value\n }\n }\n}\n" + "resource \"helm_release\" \"attr\" {\n name = \"attr\"\n" + " chart = \"../charts/app\"\n set = [{ name = \"image.tag\", value = \"4.4\" }]\n" + " postrender = { binary_path = \"./kustomize.sh\" }\n}\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a dynamic or attribute postrender makes release images unknown"); + } + PASS(); +} + +/* A subchart included under an alias (redis as `cache`) has .Template.BasePath + * app/charts/cache/templates, as Helm sets it (an image here shows it). Its + * checksum of cm.yaml names its own file, which changes no map -- not app's + * cm.yaml, which sets a value: its image stays known, as does app's (which + * checksums its Secret). */ +TEST(probe_helm_checksum_include_by_base_path) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n" + " - name: redis\n version: 1.0.0\n alias: cache\n"}, + {"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"charts/app/templates/cm.yaml", "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n" + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n"}, + {"charts/app/templates/secret.yaml", "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n"}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " metadata:\n annotations:\n checksum/secret: " + "{{ include (print $.Template.BasePath \"/secret.yaml\") . | sha256sum }}\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}, + {"charts/app/charts/redis/Chart.yaml", "apiVersion: v2\nname: redis\nversion: 1.0.0\n"}, + {"charts/app/charts/redis/values.yaml", + "image:\n repository: ghcr.io/acme/redis\n tag: \"7.0\"\n"}, + {"charts/app/charts/redis/templates/cm.yaml", + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: redis\n"}, + {"charts/app/charts/redis/templates/sts.yaml", + "apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: redis\nspec:\n template:\n" + " metadata:\n annotations:\n checksum/config: " + "{{ include (print $.Template.BasePath \"/cm.yaml\") . | sha256sum }}\n" + " spec:\n containers:\n - name: redis\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" + " - name: path\n image: \"ghcr.io/{{ .Template.BasePath }}:1\"\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.pw -> Image:quay.io/acme/pw DEPLOYS " - "image=quay.io/acme/pw:1.0 via=terraform ref=image override_of=[\"image.repository\"]\n" + "Chart:redis -> Image:ghcr.io/acme/redis DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\",\"image.tag\"]\n" + "Chart:redis -> Image:ghcr.io/app/charts/cache/templates DEPLOYS image=- via=chart " + "ref=image\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("set_sensitive names and postrender make release images unknown"); + "ref=image default_of=[\"image.repository\",\"image.tag\"]\n" + "Resource:StatefulSet/redis -> Image:ghcr.io/acme/redis USAGE " + "image=ghcr.io/acme/redis:7.0 ref=image default_of=[\"image.repository\",\"image.tag\"]\n" + "Resource:StatefulSet/redis -> Image:ghcr.io/app/charts/cache/templates USAGE " + "image=ghcr.io/app/charts/cache/templates:1 ref=image"; + if (!gpf_expect_image_edges(files, 9, want)) { + FAIL("a checksum include by .Template.BasePath names the chart's own file"); } PASS(); } @@ -7013,6 +7104,9 @@ SUITE(grammar_probe_f) { RUN_TEST(probe_helm_release_kustomize_unread_sources); RUN_TEST(probe_helm_release_terraform_dynamic_set); RUN_TEST(probe_helm_release_terraform_sensitive_and_postrender); + RUN_TEST(probe_helm_release_terraform_sensitive_read_earlier); + RUN_TEST(probe_helm_release_terraform_postrender_forms); + RUN_TEST(probe_helm_checksum_include_by_base_path); RUN_TEST(probe_helm_release_kustomize_transformers); RUN_TEST(probe_helm_empty_values_file_renders); RUN_TEST(probe_helm_aliased_subchart_chart_name); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index dc03c8cf7..aef677834 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4338,14 +4338,15 @@ TEST(helm_values_follow_helm_semantics) { /* Render template tpl with values src (one probe per `image:` line given) * and return the capture of probe 0 ("" for UNKNOWN, "" 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) { +static char *helm_capture_chart(const char *vals_src, const char *tpl, + const cbm_helm_defines_t *defs, const cbm_helm_chart_meta_t *meta, + 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); + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, meta, defs, &probe, 1); char *out = strdup(probe.n == 0 ? "" : probe.image[0] ? probe.image[0] : ""); cbm_arena_destroy(&a); if (doc) { @@ -4354,6 +4355,12 @@ static char *helm_capture(const char *vals_src, const char *tpl, const cbm_helm_ return out; } +/* helm_capture_chart of a template of no chart. */ +static char *helm_capture(const char *vals_src, const char *tpl, const cbm_helm_defines_t *defs, + int line) { + return helm_capture_chart(vals_src, tpl, defs, NULL, line); +} + /* 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 @@ -4540,10 +4547,12 @@ TEST(helm_unknown_break_leaves_the_loop) { 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) { +/* A change of maps the render does not run exactly leaves every image after + * it UNKNOWN -- also one of a new value (fromYaml / deepCopy results, an empty + * dict): no exemption (the coarse rule, decided in review round 3). Rounds 1-3 + * kept this image (the opentelemetry-ebpf-instrumentation configData + * pattern); it is part of the rule's measured cost. */ +TEST(helm_change_of_a_new_value_spoils_after) { static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\ncfg: {a: 1}\n"; static const char tpl[] = "{{- $config := dict -}}\n" @@ -4554,7 +4563,7 @@ TEST(helm_change_of_a_new_value_spoils_nothing) { "{{- $_ := 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"); + ASSERT_STR_EQ(got, ""); free(got); PASS(); } @@ -4661,7 +4670,9 @@ TEST(helm_unknown_break_spoils_the_rest_of_the_loop) { /* A variable once made from `dict` and then (in a body an UNKNOWN condition * skips) redefined or given .Values.image is no new map: a set of it there - * may change .Values. Without the redefinition the set is of the new map. */ + * may change .Values. Without the redefinition the set is of the new map -- + * still a change in a skipped body, which (the coarse rule) leaves every + * image after it UNKNOWN; rounds 2-3 kept that one's image. */ TEST(helm_skipped_set_after_redefinition) { static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; static const char redefined[] = @@ -4682,7 +4693,7 @@ TEST(helm_skipped_set_after_redefinition) { ASSERT_STR_EQ(got, ""); free(got); got = helm_capture(vals, kept, NULL, 2); - ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(got, ""); free(got); PASS(); } @@ -4744,6 +4755,301 @@ TEST(helm_include_with_poisoned_argument) { PASS(); } +/* The keys a capture read -- 5,000 values paths -- become its mark in one + * pass: memory linear in them, never a copy of the mark so far per key. */ +TEST(helm_capture_keys_cost_linear_memory) { + enum { ITEMS = 5000 }; + size_t cap = (size_t)ITEMS * 8 + CBM_SZ_64; + char *vals = malloc(cap); + ASSERT_NOT_NULL(vals); + size_t o = (size_t)snprintf(vals, cap, "big: ["); + for (int i = 0; i < ITEMS; i++) { + o += (size_t)snprintf(vals + o, cap - o, "%s{x: a}", i ? "," : ""); + } + snprintf(vals + o, cap - o, "]\n"); + static const char tpl[] = "image: \"r/{{ range .Values.big }}{{ .x }}{{ end }}\"\n"; + cbm_mem_class_flush_thread(); + size_t base = cbm_mem_class_live_bytes(CBM_MEM_CLASS_ARENA); + cbm_mem_class_reset_peaks(); + char *got = helm_capture(vals, tpl, NULL, 1); + cbm_mem_class_flush_thread(); + size_t peak = cbm_mem_class_peak_bytes(CBM_MEM_CLASS_ARENA); + free(vals); + ASSERT_NOT_NULL(got); + bool image = strncmp(got, "r/aaaa", strlen("r/aaaa")) == 0; + free(got); + ASSERT_TRUE(image); + /* about 14 bytes a key: ~70 KB of keys; a copy per key is ~175 MB */ + ASSERT_TRUE(peak - base < (size_t)CBM_SZ_32 * CBM_SZ_1K * CBM_SZ_1K); + PASS(); +} + +/* A render's work -- here 20 copies of a 20,000-item list -- stays in an + * arena of its own: the caller's arena grows by the render's results only, + * so renders one after another never keep each other's work. */ +TEST(helm_render_keeps_only_its_results) { + enum { ITEMS = 20000 }; + size_t cap = (size_t)ITEMS * 2 + CBM_SZ_64; + char *vals = malloc(cap); + ASSERT_NOT_NULL(vals); + size_t o = (size_t)snprintf(vals, cap, "big: ["); + for (int i = 0; i < ITEMS; i++) { + o += (size_t)snprintf(vals + o, cap - o, "%s1", i ? "," : ""); + } + snprintf(vals + o, cap - o, "]\n"); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *v = doc ? cbm_helm_values_parse(doc, vals, strlen(vals)) : NULL; + free(vals); + static const char tpl[] = "image: \"ghcr.io/acme/app:1\"\n" + "{{- range 20 }}{{ $x := first $.Values.big }}{{ end }}\n"; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = 1}; + size_t before = cbm_arena_total(&a); + bool ok = cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, &probe, 1); + size_t kept = cbm_arena_total(&a) - before; + bool image = ok && probe.n == 1 && probe.image[0] && + strcmp(probe.image[0], "ghcr.io/acme/app:1") == 0; + cbm_arena_destroy(&a); + if (doc) { + yyjson_mut_doc_free(doc); + } + ASSERT_NOT_NULL(v); + ASSERT_TRUE(image); + ASSERT_TRUE(kept < (size_t)CBM_SZ_1K * CBM_SZ_1K); /* the work: ~30 MB */ + PASS(); +} + +static char g_helm_budget_log[CBM_SZ_4K]; + +static void helm_budget_log_sink(const char *line) { + if (!line || !strstr(line, "helm.render.budget")) { + return; + } + size_t used = strlen(g_helm_budget_log); + snprintf(g_helm_budget_log + used, sizeof(g_helm_budget_log) - used, "%s\n", line); +} + +/* A render its output budget stopped -- an indent that writes past 64 MiB, + * or one too wide to try -- says so: reason=output. */ +TEST(helm_render_budget_logs_its_reason) { + g_helm_budget_log[0] = '\0'; + CBMLogLevel prev = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_sink(helm_budget_log_sink); + static const char big[] = "image: \"ghcr.io/acme/app{{ indent 70000000 \"\" | trim }}:1\"\n"; + char *got = helm_capture("{}\n", big, NULL, 1); + free(got); + static const char huge[] = + "image: \"ghcr.io/acme/app{{ indent 9000000000000000000 \"\" | trim }}:1\"\n"; + got = helm_capture("{}\n", huge, NULL, 1); + free(got); + cbm_log_set_sink(NULL); + cbm_log_set_level(prev); + const char *first = strstr(g_helm_budget_log, "reason=output"); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(strstr(first + SKIP_ONE, "reason=output")); + ASSERT_NULL(strstr(g_helm_budget_log, "reason=memory")); + PASS(); +} + +/* A merge into .Values may change any map of the values, also one a handle + * ($img) was taken of before: every image after it is UNKNOWN. */ +TEST(helm_merge_into_values_root) { + static const char vals[] = "image: {repository: ghcr.io/acme/app}\n"; + static const char tpl[] = + "{{- $img := .Values.image }}\n" + "{{- $_ := merge .Values (dict \"image\" (dict \"tag\" \"2.0\")) }}\n" + "image: \"{{ $img.repository }}:{{ $img.tag | default \"1.0\" }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A set in a body an UNKNOWN condition skips, whatever its target -- here + * the outer $vals (.Values.image), a nested range's own $vals aside -- + * leaves every image after it UNKNOWN. */ +TEST(helm_skipped_set_beside_a_nested_scope) { + static const char vals[] = + "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\njobs: [a]\n"; + static const char tpl[] = "{{- $vals := .Values.image }}\n" + "{{- if .Capabilities.APIVersions.Has \"batch/v1\" }}\n" + "{{- range .Values.jobs }}{{- $vals := dict }}{{- end }}\n" + "{{- $_ := set $vals \"tag\" \"2.0\" }}\n" + "{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 6); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An UNKNOWN break in a range over maps: whether the later elements ($c of + * web) were changed is not known -- not only the current one's. */ +TEST(helm_unknown_break_spoils_later_elements) { + static const char vals[] = "components:\n" + " api: {repository: ghcr.io/acme/api, tag: \"1.0\"}\n" + " web: {repository: ghcr.io/acme/web, tag: \"1.0\"}\n"; + static const char tpl[] = + "{{- range $n, $c := .Values.components }}\n" + "{{- if semverCompare \">=1.29-0\" $.Capabilities.KubeVersion.Version }}{{ break }}" + "{{ end }}\n" + "{{- $_ := set $c \"tag\" \"2.0\" }}\n" + "{{- end }}\n" + "image: \"{{ .Values.components.web.repository }}:{{ .Values.components.web.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 5); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A skipped range whose element variable ($c, each component) is set is no + * change of an outer $c (a new dict): every image after it is UNKNOWN. */ +TEST(helm_skipped_range_sets_its_element) { + static const char vals[] = "components:\n" + " web: {repository: ghcr.io/acme/web, tag: \"1.0\"}\n"; + static const char tpl[] = + "{{- $c := dict }}\n" + "{{- if .Capabilities.APIVersions.Has \"x/v1\" }}{{- range $_, $c := .Values.components }}" + "{{- $_ := set $c \"tag\" \"2.0\" }}{{- end }}{{- end }}\n" + "image: \"{{ .Values.components.web.repository }}:{{ .Values.components.web.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A deep merge into a map that holds a values map by reference ($d.img is + * .Values.image, put by a set) changes that values map: the image after it + * is UNKNOWN. */ +TEST(helm_deep_merge_reaches_a_held_values_map) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = + "{{- $d := dict }}{{- $_ := set $d \"img\" .Values.image }}\n" + "{{- $_ := mergeOverwrite $d (dict \"img\" (dict \"tag\" \"2.0\")) }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* Template files of chart app (charts/app) and of its subchart redis, which + * app's dependencies alias `cache` (charts/app/charts/redis): a Secret and a + * ConfigMap with no change of maps, an app ConfigMap that sets a value, and + * a file that includes itself. */ +static cbm_helm_defines_t *helm_chart_files(void) { + static const char secret[] = "apiVersion: v1\nkind: Secret\nmetadata:\n" + " name: {{ include \"app.name\" . }}\n"; + static const char helpers[] = "{{- define \"app.name\" -}}{{ .Chart.Name }}{{- end -}}\n"; + static const char app_cm[] = "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n" + "apiVersion: v1\nkind: ConfigMap\n"; + static const char sub_cm[] = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n" + " name: {{ .Chart.Name }}\n"; + static const char loop[] = "{{ include \"app/templates/loop.yaml\" . }}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + if (d) { + cbm_helm_defines_add(d, "charts/app/templates/_helpers.tpl", helpers, strlen(helpers)); + cbm_helm_defines_add(d, "charts/app/templates/secret.yaml", secret, strlen(secret)); + cbm_helm_defines_add(d, "charts/app/templates/cm.yaml", app_cm, strlen(app_cm)); + cbm_helm_defines_add(d, "charts/app/templates/loop.yaml", loop, strlen(loop)); + cbm_helm_defines_add(d, "charts/app/charts/redis/templates/cm.yaml", sub_cm, + strlen(sub_cm)); + } + return d; +} + +/* A template of chart `meta` checksumming its file `file`, then an image. */ +static char *helm_capture_checksum(const cbm_helm_defines_t *d, const cbm_helm_chart_meta_t *meta, + const char *file) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + char tpl[CBM_SZ_512]; + snprintf(tpl, sizeof(tpl), + "metadata:\n annotations:\n" + " checksum: {{ include (print $.Template.BasePath \"/%s\") . | sha256sum }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n", + file); + return helm_capture_chart(vals, tpl, d, meta, 4); +} + +static const cbm_helm_chart_meta_t HELM_APP = { + .name = "app", .base_path = "app/templates", .dir = "charts/app"}; + +/* The checksum idiom names one of the chart's own template files by its + * .Template.BasePath: one with no change of maps in it (a Secret) spoils + * nothing -- its output is not rendered, the image after it is known. */ +TEST(helm_checksum_include_keeps_the_image) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + char *got = helm_capture_checksum(d, &HELM_APP, "secret.yaml"); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + PASS(); +} + +/* An included template file that sets a value (cm.yaml) may change any map: + * every image after it is UNKNOWN -- beside one that does not (secret.yaml). */ +TEST(helm_include_of_a_changing_template_file) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + char *clean = helm_capture_checksum(d, &HELM_APP, "secret.yaml"); + char *changing = helm_capture_checksum(d, &HELM_APP, "cm.yaml"); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(clean, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(changing, ""); + free(clean); + free(changing); + PASS(); +} + +/* A subchart's .Template.BasePath -- app/charts/cache/templates, by the alias + * it is included under -- names its own cm.yaml (no change), not app's (a + * set): its image is known; app's own checksum of cm.yaml is not. */ +TEST(helm_subchart_base_path_names_its_own_file) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + const cbm_helm_chart_meta_t sub = {.name = "cache", + .base_path = "app/charts/cache/templates", + .dir = "charts/app/charts/redis"}; + char *own = helm_capture_checksum(d, &sub, "cm.yaml"); + char *parent = helm_capture_checksum(d, &HELM_APP, "cm.yaml"); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(own, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(parent, ""); + free(own); + free(parent); + PASS(); +} + +/* An include naming no template file of the chart is not looked into: every + * image after it is UNKNOWN -- beside one naming a file that is there. */ +TEST(helm_include_of_a_missing_template_file) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + char *there = helm_capture_checksum(d, &HELM_APP, "secret.yaml"); + char *missing = helm_capture_checksum(d, &HELM_APP, "missing.yaml"); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(there, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(missing, ""); + free(there); + free(missing); + PASS(); +} + +/* A template file that includes itself is never "no change": every image + * after an include of it is UNKNOWN. */ +TEST(helm_template_file_including_itself) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + char *got = helm_capture_checksum(d, &HELM_APP, "loop.yaml"); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + 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. */ @@ -24481,7 +24787,7 @@ SUITE(pipeline_semantic_manifest_repro) { 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(helm_change_of_a_new_value_spoils_after); RUN_TEST(helm_render_captures_grow_linearly); RUN_TEST(helm_render_memory_budget_ends_unknown); RUN_TEST(helm_skipped_set_of_a_call_result); @@ -24492,6 +24798,19 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(helm_many_changed_maps_keep_reads_right); RUN_TEST(helm_define_memo_under_mutual_recursion); RUN_TEST(helm_include_with_poisoned_argument); + RUN_TEST(helm_capture_keys_cost_linear_memory); + RUN_TEST(helm_render_keeps_only_its_results); + RUN_TEST(helm_render_budget_logs_its_reason); + RUN_TEST(helm_merge_into_values_root); + RUN_TEST(helm_skipped_set_beside_a_nested_scope); + RUN_TEST(helm_unknown_break_spoils_later_elements); + RUN_TEST(helm_skipped_range_sets_its_element); + RUN_TEST(helm_deep_merge_reaches_a_held_values_map); + RUN_TEST(helm_checksum_include_keeps_the_image); + RUN_TEST(helm_include_of_a_changing_template_file); + RUN_TEST(helm_subchart_base_path_names_its_own_file); + RUN_TEST(helm_include_of_a_missing_template_file); + RUN_TEST(helm_template_file_including_itself); 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);