diff --git a/Makefile.cbm b/Makefile.cbm index b4b8c88277..1e30558744 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -537,6 +537,7 @@ PIPELINE_SRCS = \ src/pipeline/pass_compile_commands.c \ src/pipeline/pass_infrascan.c \ src/pipeline/pass_k8s.c \ + src/pipeline/helm_template.c \ src/pipeline/pass_operator.c \ src/pipeline/pass_shell.c \ src/pipeline/pass_terraform.c \ diff --git a/README.md b/README.md index 590c9eef96..ee1db98bbb 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,7 @@ The install script placed beside the binary is **reported, not deleted**: uninst - `INFRA_MAPS`: a Kubernetes Service to each workload whose pod labels carry every `key: value` of the Service's selector, within one namespace - `CONNECTS_TO`: a Kubernetes workload or compose service to the Service its containers' environment addresses (`PRODUCT_CATALOG_SERVICE_ADDR: productcatalogservice:3550`, a URL's host, `..svc...`, a bare name on an `*_ADDR` / `*_HOST` / `*_URL` / ... key; compose: the project's `.env` interpolated): a Kubernetes Service of the same bundle and namespace, or a compose service of the same file. The edge keeps `via: env`, `key` and `container`, never the value - `USAGE` with `ref: image`: a Kubernetes workload (any pod template: containers, initContainers, CronJobs, custom resources) or a docker compose service to the `Image` it runs +- Helm templates are rendered for their images: with the chart's values (the `Chart` `DEPLOYS` them, `default_of`: the values keys read) and with each release's (a Flux HelmRelease, a kustomize `helmCharts` entry, a Terraform `helm_release`: `override_of`). An image the render cannot know is left out, never guessed: after a change of values it does not run exactly, a `tpl` or include it cannot look into, a release value it cannot read, or a work budget it runs past. Known limit: each template file renders on its own, while Helm renders a chart's files with one shared `.Values` (a subchart's is its parent's `.Values.`), so a chart that changes `.Values` in one file and reads it in another can still show a wrong image - `DEPLOYS`: what puts manifests, kustomizations or charts on a cluster, to what it puts there: a Flux Kustomization or Argo Application to the kustomizations / manifests / chart of its path, a Flux HelmRelease to its chart (`spec.chart` or `chartRef` → OCIRepository), a shell command or Make recipe (`kubectl apply -f/-k`, `kustomize build | kubectl apply`, `helm install/upgrade/template`), a Go installer loading a chart with the Helm SDK, a Terraform `helm_release`, a Terraform `kubernetes_*` resource the object it declares (a `Resource` like a manifest's, `ecosystem: terraform`, when its name is a literal), a kustomization inflating a chart (`helmCharts`; its values file an `IMPORTS`). A chart outside the repository is an external `Chart`, keyed by its publisher when its repository URL is stated (`__chart__/`, the same node in every project, the URL in `repository`), else one per project and name; the edge says `via` (`flux`, `argo`, `helmrelease`, `kubectl`, `kustomize`, `helm`, `helm-sdk`, `terraform`, `helmcharts`, ...) - `DEPENDS_ON`: a Helm chart to the charts it declares (the local chart for a resolvable `file://` repository, otherwise an external `Chart`; the edge keeps `repository`), and a `go.mod` / `requirements.txt` file to an external `Package`; a Dockerfile to the `Image`s it builds on (`via: from`, `copy-from`); an `Image` to the Dockerfile it is built from (`via: build`; `tool`: `skaffold`, `compose`, `docker-build` for `docker` / `podman` / `nerdctl` / `buildah` build commands in scripts, Make and CI); a compose service to the services its `depends_on` / `links` name - `CONFIGURES`: code reading an environment variable to the shared `EnvVar` of that name (`strategy: env_access`); a Kubernetes container, compose service or Dockerfile (`ENV` of the image) setting one to that container's own `EnvVar` (`strategy: env_set`, with `source`: `value`, `configMapKeyRef`, `secretKeyRef`, `fieldRef`, ..., `compose`, `dockerfile`; values are never stored); and that container's `EnvVar` to the code reading the name inside the files its image is built from (`strategy: env_read`, with `dockerfile`): the image's files are the sources its Dockerfile's `COPY` / `ADD` put into it, less the context's `.dockerignore` diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 86586dd15b..103cc0cde1 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -719,7 +719,9 @@ typedef struct { * from the URL `name`; a HelmRelease whose chartRef names it deploys the * chart there. * CBM_K8S_FACT_IMAGE: object def_index runs its container `container` - * (NULL: unnamed) from image `name` (a literal reference); via "container". + * (NULL: unnamed) from image `name` (a literal reference, or a Helm + * template's placeholder `<...>`, which names none); via "container"; + * `line` the image's line, start_line / end_line the container's. * CBM_K8S_FACT_ENV: object def_index sets environment variable `name` in * its container `container` (NULL: unnamed) at `line`; via = where the * value comes from ("value", or the valueFrom kind) -- never the value. @@ -758,6 +760,8 @@ typedef struct { const char *url; /* DEPLOY (kustomize): the chart repository */ const char *release; /* DEPLOY (kustomize): the release name */ int line; + int start_line; /* IMAGE: the container mapping's first line */ + int end_line; /* IMAGE: its last line */ } CBMK8sFact; typedef struct { diff --git a/internal/cbm/extract_k8s.c b/internal/cbm/extract_k8s.c index a3572763e8..ce41a56906 100644 --- a/internal/cbm/extract_k8s.c +++ b/internal/cbm/extract_k8s.c @@ -1487,12 +1487,18 @@ static void k8s_container(k8s_walk_t *w, const char *path, TSNode container) { const char *cname = k8s_text(w, container, "name"); cname = cname && cname[0] && !strstr(cname, "{{") ? cname : NULL; const char *image = k8s_text(w, container, "image"); - if (k8s_image_literal(image)) { + /* a Helm template's image is its skeleton placeholder: pass_k8s.c renders it */ + if (k8s_image_literal(image) || (image && strchr(image, '<'))) { + TSNode iv = k8s_child(w, container, "image"); CBMK8sFact f = {.fact = CBM_K8S_FACT_IMAGE, .def_index = w->def_index, .name = image, .container = cname, - .via = "container"}; + .via = "container", + .line = + ts_node_is_null(iv) ? 0 : (int)ts_node_start_point(iv).row + SKIP_ONE, + .start_line = (int)ts_node_start_point(container).row + SKIP_ONE, + .end_line = (int)ts_node_end_point(container).row + SKIP_ONE}; cbm_k8sfact_push(&w->ctx->result->k8s_facts, w->ctx->arena, f); } k8s_iter_t env = k8s_iter(k8s_child(w, container, "env")); diff --git a/src/pipeline/helm_template.c b/src/pipeline/helm_template.c new file mode 100644 index 0000000000..7c9a5d8946 --- /dev/null +++ b/src/pipeline/helm_template.c @@ -0,0 +1,3737 @@ +/* + * helm_template.c -- Helm values and the rendering of a chart template's + * image references (see helm_template.h). + * + * Values: YAML -> JSON as Helm does it (sigs.k8s.io/yaml over go-yaml v2): + * YAML 1.1 booleans (yes/no/on/off), `~` / null / empty a null, every number + * a float64 -- a float prints like Go's %v (1.10 -> 1.1, 20231010 -> + * 2.023101e+07), the way Helm renders it. --set values keep int64. + * + * Rendering: Go text/template over the vendored gotmpl grammar -- text with + * {{- / -}} trimming; if / else if / else, with, range (lists, maps by sorted + * key, else, break / continue), variables (:= / =, `$` the template's data), + * define / template / block / include / tpl, pipelines (the piped value is + * the last argument); and the Sprig functions image expressions use. A missing + * map key renders "" (Helm drops ""); a field of nil is an error. + * An error, a function not modelled, .Release.Name / .Capabilities / lookup, + * a define not found -- each is UNKNOWN, which spreads to what reads it; a + * condition or range that is UNKNOWN runs no branch. Nesting of include / + * template stops where Helm's does (1000), an error there too. + */ +#include "pipeline/helm_template.h" + +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/json_writer.h" /* cbm_json_writer_allocator */ +#include "foundation/log.h" +#include "foundation/mem_core.h" +#include "cbm.h" +#include "lang_specs.h" + +#include +#include +#include +#include +#include +#include + +enum { HELM_MAX_NESTING = 1000 }; /* Helm engine's recursionMaxNums */ +enum { HELM_MAX_SET_INDEX = 65536 }; /* Helm strvals' MaxIndex for `--set a[n]` */ + +/* A render doing more than this -- steps (an action, an evaluation, a range + * iteration), bytes written to any sink, or memory it takes (the arena's + * growth: captures, list copies, strings) -- is one Helm never finishes + * either (a range over a huge int, defines that call each other in a fan): + * it stops, and every probe of the template is UNKNOWN. Counted, never timed; + * far above any real chart (the 20 infra corpora and the helm oracle charts + * never reach it). */ +enum { HELM_MAX_STEPS = 10000000 }; +static const size_t HELM_MAX_OUTPUT = (size_t)CBM_SZ_64 * CBM_SZ_1K * CBM_SZ_1K; /* 64 MiB */ +static const size_t HELM_MAX_MEMORY = (size_t)CBM_SZ_256 * CBM_SZ_1K * CBM_SZ_1K; /* 256 MiB */ + +/* ── Values: YAML -> JSON ─────────────────────────────────────────── */ + +typedef struct { + const char *name; + size_t len; + yyjson_mut_val *val; + long decodes; /* the nodes its value took to decode, its aliases expanded */ +} hy_anchor_t; + +typedef struct { + yyjson_mut_doc *doc; + const char *src; + hy_anchor_t *anchors; + int nanchors; + int cap; + bool unknown; + long decodes; /* nodes decoded, alias expansions included (go-yaml decodeCount) */ + long alias_decodes; /* of those, the ones an alias expanded (aliasCount) */ +} hy_t; + +/* go-yaml v2 (decode.go): a document whose decodes come mostly from alias + * expansion -- a billion laughs -- is an error, so Helm reads no values. The + * share allowed falls from 99% below 400,000 decodes to 10% above 4,000,000. */ +enum { + HY_ALIAS_MIN_ALIASES = 100, + HY_ALIAS_MIN_DECODES = 1000, + HY_ALIAS_RANGE_LOW = 400000, + HY_ALIAS_RANGE_HIGH = 4000000 +}; +static const double HY_ALIAS_RATIO_SMALL = 0.99; +static const double HY_ALIAS_RATIO_LARGE = 0.10; + +static bool hy_excessive_aliasing(const hy_t *y) { + if (y->alias_decodes <= HY_ALIAS_MIN_ALIASES || y->decodes <= HY_ALIAS_MIN_DECODES) { + return false; + } + double allowed = HY_ALIAS_RATIO_SMALL; + if (y->decodes >= HY_ALIAS_RANGE_HIGH) { + allowed = HY_ALIAS_RATIO_LARGE; + } else if (y->decodes > HY_ALIAS_RANGE_LOW) { + allowed = HY_ALIAS_RATIO_SMALL - (HY_ALIAS_RATIO_SMALL - HY_ALIAS_RATIO_LARGE) * + ((double)(y->decodes - HY_ALIAS_RANGE_LOW) / + (double)(HY_ALIAS_RANGE_HIGH - HY_ALIAS_RANGE_LOW)); + } + return (double)y->alias_decodes / (double)y->decodes > allowed; +} + +static bool hy_type(TSNode n, const char *t) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), t) == 0; +} + +static const char *hy_text(const hy_t *y, TSNode n, size_t *len) { + uint32_t b = ts_node_start_byte(n); + *len = ts_node_end_byte(n) - b; + return y->src + b; +} + +static void hy_anchor(hy_t *y, const char *name, size_t len, yyjson_mut_val *v, long decodes) { + if (y->nanchors == y->cap) { + int cap = y->cap ? y->cap * PAIR_LEN : CBM_SZ_16; + hy_anchor_t *g = cbm_realloc(CBM_MEM_CLASS_OTHER, y->anchors, (size_t)cap * sizeof(*g)); + if (!g) { + y->unknown = true; + return; + } + y->anchors = g; + y->cap = cap; + } + y->anchors[y->nanchors++] = (hy_anchor_t){name, len, v, decodes}; +} + +/* An alias: its anchor's value decoded again (a copy), as go-yaml counts it. */ +static yyjson_mut_val *hy_alias(hy_t *y, const char *name, size_t len) { + for (int i = y->nanchors - SKIP_ONE; i >= 0; i--) { + if (y->anchors[i].len == len && memcmp(y->anchors[i].name, name, len) == 0) { + y->decodes += y->anchors[i].decodes; + y->alias_decodes += y->anchors[i].decodes; + if (hy_excessive_aliasing(y)) { + y->unknown = true; + return NULL; + } + return yyjson_mut_val_mut_copy(y->doc, y->anchors[i].val); + } + } + y->unknown = true; + return NULL; +} + +static bool hy_in(const char *s, size_t n, const char *const *set) { + for (; *set; set++) { + if (strlen(*set) == n && memcmp(*set, s, n) == 0) { + return true; + } + } + return false; +} + +/* A float of go-yaml v2's form: [-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)? */ +static bool hy_float_form(const char *s, size_t n) { + size_t i = 0; + if (i < n && (s[i] == '-' || s[i] == '+')) { + i++; + } + size_t digits = 0; + while (i < n && isdigit((unsigned char)s[i])) { + i++; + digits++; + } + if (i < n && s[i] == '.') { + i++; + size_t frac = 0; + while (i < n && isdigit((unsigned char)s[i])) { + i++; + frac++; + } + if (digits == 0 && frac == 0) { + return false; + } + } else if (digits == 0) { + return false; + } + if (i < n && (s[i] == 'e' || s[i] == 'E')) { + i++; + if (i < n && (s[i] == '-' || s[i] == '+')) { + i++; + } + size_t ed = 0; + while (i < n && isdigit((unsigned char)s[i])) { + i++; + ed++; + } + if (ed == 0) { + return false; + } + } + return i == n; +} + +/* An integer the way go-yaml v2 reads one: strconv.ParseInt(s, 0, 64) after + * dropping `_` -- decimal, 0x hex, 0b binary, a leading 0 octal. */ +static bool hy_int_form(const char *s, size_t n, int64_t *out) { + char buf[CBM_SZ_64]; + size_t k = 0; + for (size_t i = 0; i < n && k + SKIP_ONE < sizeof(buf); i++) { + if (s[i] != '_') { + buf[k++] = s[i]; + } + } + if (k == 0 || k + SKIP_ONE >= sizeof(buf)) { + return false; + } + buf[k] = '\0'; + const char *p = buf; + bool neg = false; + if (*p == '-' || *p == '+') { + neg = *p == '-'; + p++; + } + int base = 10; + if (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) { + base = 16; + p += PAIR_LEN; + } else if (p[0] == '0' && (p[1] == 'b' || p[1] == 'B')) { + base = 2; + p += PAIR_LEN; + } else if (p[0] == '0' && p[1]) { + base = 8; + p++; + } + if (!*p) { + return false; + } + char *end = NULL; + long long v = strtoll(p, &end, base); + if (!end || *end) { + return false; + } + *out = neg ? -(int64_t)v : (int64_t)v; + return true; +} + +static yyjson_mut_val *hy_plain(hy_t *y, const char *s, size_t n) { + static const char *const NULLS[] = {"", "~", "null", "Null", "NULL", NULL}; + static const char *const TRUES[] = {"y", "Y", "yes", "Yes", "YES", "true", + "True", "TRUE", "on", "On", "ON", NULL}; + static const char *const FALSES[] = {"n", "N", "no", "No", "NO", "false", + "False", "FALSE", "off", "Off", "OFF", NULL}; + static const char *const SPECIAL[] = {".inf", ".Inf", ".INF", "+.inf", "+.Inf", + "+.INF", "-.inf", "-.Inf", "-.INF", ".nan", + ".NaN", ".NAN", NULL}; + if (hy_in(s, n, NULLS)) { + return yyjson_mut_null(y->doc); + } + if (hy_in(s, n, TRUES)) { + return yyjson_mut_true(y->doc); + } + if (hy_in(s, n, FALSES)) { + return yyjson_mut_false(y->doc); + } + if (hy_in(s, n, SPECIAL)) { + y->unknown = true; /* JSON holds no inf / nan: Helm fails here */ + return NULL; + } + char c = n ? s[0] : '\0'; + if (isdigit((unsigned char)c) || c == '-' || c == '+' || c == '.') { + int64_t iv = 0; + if (hy_int_form(s, n, &iv)) { + return yyjson_mut_real(y->doc, (double)iv); + } + if (hy_float_form(s, n)) { + char buf[CBM_SZ_64]; + snprintf(buf, sizeof(buf), "%.*s", (int)n, s); + return yyjson_mut_real(y->doc, strtod(buf, NULL)); + } + } + return yyjson_mut_strncpy(y->doc, s, n); +} + +static int hy_hex(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + c = (char)tolower((unsigned char)c); + return c >= 'a' && c <= 'f' ? c - 'a' + 10 : -SKIP_ONE; +} + +static size_t hy_utf8(unsigned long cp, char *out) { + if (cp < 0x80) { + out[0] = (char)cp; + return SKIP_ONE; + } + if (cp < 0x800) { + out[0] = (char)(0xC0 | (cp >> 6)); + out[1] = (char)(0x80 | (cp & 0x3F)); + return PAIR_LEN; + } + if (cp < 0x10000) { + out[0] = (char)(0xE0 | (cp >> 12)); + out[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[2] = (char)(0x80 | (cp & 0x3F)); + return 3; + } + out[0] = (char)(0xF0 | (cp >> 18)); + out[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + out[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[3] = (char)(0x80 | (cp & 0x3F)); + return 4; +} + +/* Fold a quoted scalar's line breaks: a break and the blanks around it + * become one space, an empty line a newline. */ +static size_t hy_fold(char *buf, size_t n) { + size_t k = 0; + for (size_t i = 0; i < n;) { + if (buf[i] == '\n') { + while (k > 0 && (buf[k - SKIP_ONE] == ' ' || buf[k - SKIP_ONE] == '\t')) { + k--; + } + size_t breaks = 0; + while (i < n && (buf[i] == '\n' || buf[i] == ' ' || buf[i] == '\t' || buf[i] == '\r')) { + breaks += buf[i] == '\n'; + i++; + } + if (breaks > SKIP_ONE) { + for (size_t b = SKIP_ONE; b < breaks; b++) { + buf[k++] = '\n'; + } + } else { + buf[k++] = ' '; + } + continue; + } + buf[k++] = buf[i++]; + } + return k; +} + +static yyjson_mut_val *hy_double(hy_t *y, const char *s, size_t n) { + if (n < PAIR_LEN) { + y->unknown = true; + return NULL; + } + char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, n * 4 + SKIP_ONE); + if (!buf) { + y->unknown = true; + return NULL; + } + size_t k = 0; + for (size_t i = SKIP_ONE; i + SKIP_ONE < n; i++) { + char c = s[i]; + if (c != '\\') { + buf[k++] = c; + continue; + } + if (++i + SKIP_ONE >= n + SKIP_ONE) { + break; + } + c = s[i]; + static const char ESC_IN[] = "0abtnvfre \"/\\N_LP\t"; + static const char ESC_OUT[] = "\0\a\b\t\n\v\f\r\x1b \"/\\"; + const char *e = strchr(ESC_IN, c); + if (c == '\n') { + while (i + SKIP_ONE < n && (s[i + SKIP_ONE] == ' ' || s[i + SKIP_ONE] == '\t')) { + i++; + } + } else if (e && (size_t)(e - ESC_IN) < sizeof(ESC_OUT) - SKIP_ONE) { + buf[k++] = ESC_OUT[e - ESC_IN]; + } else if (c == 'x' || c == 'u' || c == 'U') { + int digits = c == 'x' ? PAIR_LEN : c == 'u' ? 4 : 8; + uint32_t cp = 0; + for (int d = 0; d < digits; d++) { + int h = i + SKIP_ONE < n ? hy_hex(s[i + SKIP_ONE]) : -SKIP_ONE; + if (h < 0) { + y->unknown = true; + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return NULL; + } + cp = cp * 16 + (uint32_t)h; + i++; + } + k += hy_utf8(cp, buf + k); + } else { + y->unknown = true; + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return NULL; + } + } + k = hy_fold(buf, k); + yyjson_mut_val *v = yyjson_mut_strncpy(y->doc, buf, k); + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return v; +} + +static yyjson_mut_val *hy_single(hy_t *y, const char *s, size_t n) { + if (n < PAIR_LEN) { + y->unknown = true; + return NULL; + } + char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, n + SKIP_ONE); + if (!buf) { + y->unknown = true; + return NULL; + } + size_t k = 0; + for (size_t i = SKIP_ONE; i + SKIP_ONE < n; i++) { + buf[k++] = s[i]; + if (s[i] == '\'' && i + PAIR_LEN < n && s[i + SKIP_ONE] == '\'') { + i++; + } + } + k = hy_fold(buf, k); + yyjson_mut_val *v = yyjson_mut_strncpy(y->doc, buf, k); + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return v; +} + +/* A block scalar (| or >, chomping - / +, an explicit indentation digit). */ +static yyjson_mut_val *hy_block(hy_t *y, const char *s, size_t n) { + size_t i = 0; + bool folded = n > 0 && s[0] == '>'; + char chomp = 'c'; + int explicit_indent = 0; + for (i = SKIP_ONE; i < n && s[i] != '\n' && s[i] != '#'; i++) { + if (s[i] == '-' || s[i] == '+') { + chomp = s[i]; + } else if (isdigit((unsigned char)s[i])) { + explicit_indent = s[i] - '0'; + } + } + while (i < n && s[i] != '\n') { + i++; + } + i += i < n ? SKIP_ONE : 0; + /* the content's indentation: the explicit one, else its first non-empty line's */ + size_t indent = 0; + if (explicit_indent > 0) { + /* relative to the parent's: the line indentation of the scalar's own start */ + const char *ls = s; + while (ls > y->src && ls[-1] != '\n') { + ls--; + } + size_t parent = 0; + while (ls + parent < s && ls[parent] == ' ') { + parent++; + } + indent = parent + (size_t)explicit_indent; + } else { + for (size_t j = i; j < n;) { + size_t sp = 0; + while (j + sp < n && s[j + sp] == ' ') { + sp++; + } + if (j + sp < n && s[j + sp] != '\n') { + indent = sp; + break; + } + j += sp + SKIP_ONE; + } + } + char *buf = cbm_alloc(CBM_MEM_CLASS_OTHER, n + PAIR_LEN); + if (!buf) { + y->unknown = true; + return NULL; + } + size_t k = 0; + bool prev_more = false; /* the previous line was more indented (folded keeps its break) */ + bool first = true; + while (i < n) { + size_t sp = 0; + while (i + sp < n && s[i + sp] == ' ' && sp < indent) { + sp++; + } + size_t e = i + sp; + while (e < n && s[e] != '\n') { + e++; + } + bool empty = e == i + sp; + bool more = !empty && (s[i + sp] == ' ' || s[i + sp] == '\t'); + if (folded && !first && k > 0 && buf[k - SKIP_ONE] == '\n' && !empty && !more && + !prev_more) { + /* a break between two plain lines folds into a space ... */ + size_t back = k - SKIP_ONE; + bool only = back == 0 || buf[back - SKIP_ONE] != '\n'; + if (only) { + buf[back] = ' '; + } else { + /* ... one of several breaks is dropped */ + k = back; + } + } + memcpy(buf + k, s + i + sp, e - (i + sp)); + k += e - (i + sp); + buf[k++] = '\n'; + prev_more = more; + first = empty && first; + i = e + SKIP_ONE; + } + if (chomp != '+') { + while (k > 0 && buf[k - SKIP_ONE] == '\n') { + k--; + } + if (chomp == 'c' && k > 0) { + buf[k++] = '\n'; + } + } + yyjson_mut_val *v = yyjson_mut_strncpy(y->doc, buf, k); + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return v; +} + +static yyjson_mut_val *hy_node(hy_t *y, TSNode n); + +/* A mapping key as JSON: its scalar's text (sigs.k8s.io/yaml stringifies). */ +static yyjson_mut_val *hy_key(hy_t *y, TSNode k) { + yyjson_mut_val *v = ts_node_is_null(k) ? yyjson_mut_null(y->doc) : hy_node(y, k); + if (!v) { + return NULL; + } + if (yyjson_mut_is_str(v)) { + return v; + } + if (yyjson_mut_is_null(v)) { + return yyjson_mut_strcpy(y->doc, "null"); + } + if (yyjson_mut_is_bool(v)) { + return yyjson_mut_strcpy(y->doc, yyjson_mut_get_bool(v) ? "true" : "false"); + } + if (yyjson_mut_is_real(v)) { + /* go-yaml v2 keeps an integer key an int: its decimal digits */ + double d = yyjson_mut_get_real(v); + char buf[CBM_SZ_64]; + if (d == floor(d) && fabs(d) < 1e18) { + snprintf(buf, sizeof(buf), "%lld", (long long)d); + } else { + snprintf(buf, sizeof(buf), "%.17g", d); + } + return yyjson_mut_strcpy(y->doc, buf); + } + y->unknown = true; /* a mapping / sequence as a key */ + return NULL; +} + +/* YAML merge key: `<<: *a` / `<<: [*a, *b]` -- entries not already there. */ +static void hy_merge(yyjson_mut_val *obj, yyjson_mut_val *src) { + if (yyjson_mut_is_arr(src)) { + size_t idx, max; + yyjson_mut_val *it; + yyjson_mut_arr_foreach(src, idx, max, it) { + hy_merge(obj, it); + } + return; + } + if (!yyjson_mut_is_obj(src)) { + return; + } + size_t idx, max; + yyjson_mut_val *k, *v; + yyjson_mut_obj_foreach(src, idx, max, k, v) { + if (!yyjson_mut_obj_getn(obj, yyjson_mut_get_str(k), yyjson_mut_get_len(k))) { + yyjson_mut_obj_add(obj, k, v); + } + } +} + +static yyjson_mut_val *hy_mapping(hy_t *y, TSNode n, const char *pair_type) { + yyjson_mut_val *obj = yyjson_mut_obj(y->doc); + yyjson_mut_val *merges = NULL; + for (uint32_t i = 0; obj && i < ts_node_named_child_count(n); i++) { + TSNode p = ts_node_named_child(n, i); + if (hy_type(p, "comment")) { + continue; + } + TSNode kn = p; + TSNode vn = {0}; + if (hy_type(p, pair_type)) { + kn = ts_node_child_by_field_name(p, "key", (uint32_t)strlen("key")); + vn = ts_node_child_by_field_name(p, "value", (uint32_t)strlen("value")); + } else if (!hy_type(p, "flow_node")) { + y->unknown = true; + return NULL; + } + size_t kl = 0; + const char *kt = ts_node_is_null(kn) ? "" : hy_text(y, kn, &kl); + yyjson_mut_val *val = ts_node_is_null(vn) ? yyjson_mut_null(y->doc) : hy_node(y, vn); + if (!val || y->unknown) { + y->unknown = true; + return NULL; + } + if (kl == PAIR_LEN && memcmp(kt, "<<", PAIR_LEN) == 0) { + merges = merges ? merges : yyjson_mut_arr(y->doc); + yyjson_mut_arr_append(merges, val); + continue; + } + yyjson_mut_val *key = hy_key(y, kn); + if (!key) { + y->unknown = true; + return NULL; + } + (void)yyjson_mut_obj_put(obj, key, val); /* a duplicate key: the last one */ + } + if (obj && merges) { + hy_merge(obj, merges); + } + return obj; +} + +static yyjson_mut_val *hy_sequence(hy_t *y, TSNode n) { + yyjson_mut_val *arr = yyjson_mut_arr(y->doc); + for (uint32_t i = 0; arr && i < ts_node_named_child_count(n); i++) { + TSNode it = ts_node_named_child(n, i); + if (hy_type(it, "comment")) { + continue; + } + TSNode content = it; + if (hy_type(it, "block_sequence_item")) { + content = ts_node_named_child_count(it) > 0 ? ts_node_named_child(it, 0) : (TSNode){0}; + while (!ts_node_is_null(content) && hy_type(content, "comment")) { + content = ts_node_next_named_sibling(content); + } + } + yyjson_mut_val *v = ts_node_is_null(content) ? yyjson_mut_null(y->doc) + : hy_type(content, "flow_pair") ? NULL + : hy_node(y, content); + if (hy_type(content, "flow_pair")) { + /* [a: b] -- a single-pair map */ + v = yyjson_mut_obj(y->doc); + TSNode kn = ts_node_child_by_field_name(content, "key", (uint32_t)strlen("key")); + TSNode vn = ts_node_child_by_field_name(content, "value", (uint32_t)strlen("value")); + yyjson_mut_val *key = hy_key(y, kn); + yyjson_mut_val *val = ts_node_is_null(vn) ? yyjson_mut_null(y->doc) : hy_node(y, vn); + if (!key || !val) { + y->unknown = true; + return NULL; + } + yyjson_mut_obj_add(v, key, val); + } + if (!v || y->unknown) { + y->unknown = true; + return NULL; + } + yyjson_mut_arr_append(arr, v); + } + return arr; +} + +static yyjson_mut_val *hy_node(hy_t *y, TSNode n) { + if (ts_node_is_null(n) || ts_node_has_error(n) || y->unknown) { + y->unknown = true; + return NULL; + } + const char *t = ts_node_type(n); + if (strcmp(t, "block_node") == 0 || strcmp(t, "flow_node") == 0) { + TSNode content = {0}; + const char *anchor = NULL; + size_t al = 0; + bool str_tag = false; + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + if (hy_type(c, "anchor")) { + TSNode nm = ts_node_named_child(c, 0); + anchor = ts_node_is_null(nm) ? NULL : hy_text(y, nm, &al); + } else if (hy_type(c, "tag")) { + size_t tl = 0; + const char *tt = hy_text(y, c, &tl); + str_tag = tl == strlen("!!str") && memcmp(tt, "!!str", tl) == 0; + } else if (!hy_type(c, "comment")) { + content = c; + } + } + yyjson_mut_val *v = NULL; + long before = y->decodes; + if (ts_node_is_null(content)) { + y->decodes++; + v = yyjson_mut_null(y->doc); + } else if (str_tag && hy_type(content, "plain_scalar")) { + size_t l = 0; + const char *s = hy_text(y, content, &l); + y->decodes++; + v = yyjson_mut_strncpy(y->doc, s, l); + } else { + v = hy_node(y, content); + } + if (v && anchor) { + hy_anchor(y, anchor, al, v, y->decodes - before); + } + return v; + } + y->decodes++; /* a mapping, sequence, scalar or alias node */ + size_t l = 0; + const char *s = hy_text(y, n, &l); + if (strcmp(t, "block_mapping") == 0) { + return hy_mapping(y, n, "block_mapping_pair"); + } + if (strcmp(t, "flow_mapping") == 0) { + return hy_mapping(y, n, "flow_pair"); + } + if (strcmp(t, "block_sequence") == 0 || strcmp(t, "flow_sequence") == 0) { + return hy_sequence(y, n); + } + if (strcmp(t, "plain_scalar") == 0) { + return hy_plain(y, s, l); + } + if (strcmp(t, "double_quote_scalar") == 0) { + return hy_double(y, s, l); + } + if (strcmp(t, "single_quote_scalar") == 0) { + return hy_single(y, s, l); + } + if (strcmp(t, "block_scalar") == 0) { + return hy_block(y, s, l); + } + if (strcmp(t, "alias") == 0) { + TSNode nm = ts_node_named_child(n, 0); + size_t nl = 0; + const char *name = ts_node_is_null(nm) ? NULL : hy_text(y, nm, &nl); + return name ? hy_alias(y, name, nl) : NULL; + } + y->unknown = true; + return NULL; +} + +static void hy_done(hy_t *y) { + cbm_free(CBM_MEM_CLASS_OTHER, y->anchors); + y->anchors = NULL; +} + +yyjson_mut_val *cbm_helm_values_node(yyjson_mut_doc *doc, const char *src, TSNode node) { + hy_t y = {.doc = doc, .src = src}; + yyjson_mut_val *v = hy_node(&y, node); + hy_done(&y); + return y.unknown ? NULL : v; +} + +yyjson_mut_val *cbm_helm_values_parse(yyjson_mut_doc *doc, const char *src, size_t len) { + TSParser *parser = ts_parser_new(); + if (!parser || !ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_YAML))) { + if (parser) { + ts_parser_delete(parser); + } + return NULL; + } + TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)len); + yyjson_mut_val *out = NULL; + if (tree) { + TSNode root = ts_tree_root_node(tree); + TSNode docn = {0}; + for (uint32_t i = 0; i < ts_node_named_child_count(root); i++) { + if (hy_type(ts_node_named_child(root, i), "document")) { + docn = ts_node_named_child(root, i); + break; + } + } + TSNode content = {0}; + for (uint32_t i = 0; !ts_node_is_null(docn) && i < ts_node_named_child_count(docn); i++) { + TSNode c = ts_node_named_child(docn, i); + if (!hy_type(c, "comment")) { + content = c; + break; + } + } + if (ts_node_has_error(root) && (ts_node_is_null(docn) || ts_node_has_error(docn))) { + out = NULL; + } else if (ts_node_is_null(content)) { + out = yyjson_mut_obj(doc); + } else { + out = cbm_helm_values_node(doc, src, content); + if (out && yyjson_mut_is_null(out)) { + out = yyjson_mut_obj(doc); + } + } + ts_tree_delete(tree); + } + ts_parser_delete(parser); + return out; +} + +static yyjson_mut_val *hc_merge(yyjson_mut_doc *doc, yyjson_mut_val *dst, yyjson_mut_val *over) { + if (!yyjson_mut_is_obj(dst) || !yyjson_mut_is_obj(over)) { + return over ? yyjson_mut_val_mut_copy(doc, over) : dst; + } + size_t idx, max; + yyjson_mut_val *k, *v; + yyjson_mut_obj_foreach(over, idx, max, k, v) { + const char *ks = yyjson_mut_get_str(k); + size_t kl = yyjson_mut_get_len(k); + if (yyjson_mut_is_null(v)) { + (void)yyjson_mut_obj_remove_keyn(dst, ks, kl); + continue; + } + yyjson_mut_val *cur = yyjson_mut_obj_getn(dst, ks, kl); + yyjson_mut_val *merged = cur && yyjson_mut_is_obj(cur) && yyjson_mut_is_obj(v) + ? hc_merge(doc, cur, v) + : yyjson_mut_val_mut_copy(doc, v); + if (merged != cur) { + (void)yyjson_mut_obj_remove_keyn(dst, ks, kl); + yyjson_mut_obj_add(dst, yyjson_mut_strncpy(doc, ks, kl), merged); + } + } + return dst; +} + +yyjson_mut_val *cbm_helm_values_coalesce(yyjson_mut_doc *doc, yyjson_mut_val *base, + yyjson_mut_val *over) { + yyjson_mut_val *out = base ? yyjson_mut_val_mut_copy(doc, base) : yyjson_mut_obj(doc); + return over ? hc_merge(doc, out, over) : out; +} + +/* Helm strvals' typedVal. */ +static yyjson_mut_val *hs_typed(yyjson_mut_doc *doc, const char *v, bool as_string) { + if (as_string) { + return yyjson_mut_strcpy(doc, v); + } + if (strcasecmp(v, "true") == 0) { + return yyjson_mut_true(doc); + } + if (strcasecmp(v, "false") == 0) { + return yyjson_mut_false(doc); + } + if (strcasecmp(v, "null") == 0) { + return yyjson_mut_null(doc); + } + if (strcmp(v, "0") == 0) { + return yyjson_mut_sint(doc, 0); + } + if (v[0] && v[0] != '0') { + char *end = NULL; + long long iv = strtoll(v, &end, 10); + if (end && !*end && (isdigit((unsigned char)v[0]) || v[0] == '-' || v[0] == '+')) { + return yyjson_mut_sint(doc, iv); + } + } + return yyjson_mut_strcpy(doc, v); +} + +bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *name, + const char *value, bool as_string) { + if (!yyjson_mut_is_obj(root) || !name || !name[0] || !value) { + return false; + } + yyjson_mut_val *cur = root; + const char *p = name; + char key[CBM_SZ_512]; + while (*p) { + size_t k = 0; + while (*p && *p != '.' && *p != '[' && k + SKIP_ONE < sizeof(key)) { + if (*p == '\\' && p[1]) { + p++; + } + key[k++] = *p++; + } + key[k] = '\0'; + if (*p && *p != '.' && *p != '[') { + return false; /* a key longer than the buffer: never split into two */ + } + long index = -SKIP_ONE; + if (*p == '[') { + char *end = NULL; + index = strtol(p + SKIP_ONE, &end, 10); + /* strvals: a negative index or one past MaxIndex is an error */ + if (!end || *end != ']' || index < 0 || index > HELM_MAX_SET_INDEX) { + return false; + } + p = end + SKIP_ONE; + } + bool last = !*p || (*p == '.' && !p[1]); + if (*p == '.') { + p++; + } + if (k == 0 || !yyjson_mut_is_obj(cur)) { + return false; + } + yyjson_mut_val *child = yyjson_mut_obj_get(cur, key); + if (index >= 0) { + if (!yyjson_mut_is_arr(child)) { + child = yyjson_mut_arr(doc); + (void)yyjson_mut_obj_remove_key(cur, key); + yyjson_mut_obj_add(cur, yyjson_mut_strcpy(doc, key), child); + } + while (yyjson_mut_arr_size(child) <= (size_t)index) { + yyjson_mut_arr_append(child, yyjson_mut_null(doc)); + } + if (last) { + (void)yyjson_mut_arr_replace(child, (size_t)index, hs_typed(doc, value, as_string)); + return true; + } + yyjson_mut_val *el = yyjson_mut_arr_get(child, (size_t)index); + if (!yyjson_mut_is_obj(el)) { + el = yyjson_mut_obj(doc); + (void)yyjson_mut_arr_replace(child, (size_t)index, el); + } + cur = el; + continue; + } + if (last) { + (void)yyjson_mut_obj_remove_key(cur, key); + yyjson_mut_obj_add(cur, yyjson_mut_strcpy(doc, key), hs_typed(doc, value, as_string)); + return true; + } + if (!yyjson_mut_is_obj(child)) { + child = yyjson_mut_obj(doc); + (void)yyjson_mut_obj_remove_key(cur, key); + yyjson_mut_obj_add(cur, yyjson_mut_strcpy(doc, key), child); + } + cur = child; + } + return false; +} + +/* ── Defines ──────────────────────────────────────────────────────── */ + +typedef struct { + char *rel; + char *src; + size_t len; + TSTree *tree; +} hd_file_t; + +typedef struct { + const hd_file_t *file; + TSNode node; /* the define_action / block_action */ + int depth; +} hd_def_t; + +struct cbm_helm_defines { + hd_file_t **files; /* each allocated once: defines point into them */ + int nfiles; + int cap; + CBMHashTable *names; /* name -> hd_def_t* */ + CBMHashTable *by_rel; /* path -> hd_file_t* */ +}; + +cbm_helm_defines_t *cbm_helm_defines_new(void) { + cbm_helm_defines_t *d = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*d)); + if (d) { + d->names = cbm_ht_create(0); + d->by_rel = cbm_ht_create(0); + if (!d->names || !d->by_rel) { + cbm_helm_defines_free(d); + return NULL; + } + } + return d; +} + +static int hd_depth(const char *rel) { + int n = 0; + for (const char *p = rel; *p; p++) { + n += *p == '/'; + } + return n; +} + +/* A Go string literal's value ("..." with escapes, `...` raw); NULL: invalid. */ +static char *hg_string(const char *s, size_t n, size_t *out_len); + +static void hd_scan(cbm_helm_defines_t *d, hd_file_t *f, TSNode n, int depth) { + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + const char *t = ts_node_type(c); + bool def = strcmp(t, "define_action") == 0; + bool block = strcmp(t, "block_action") == 0; + if (def || block) { + TSNode nm = ts_node_child_by_field_name(c, "name", (uint32_t)strlen("name")); + size_t nl = 0; + char *name = ts_node_is_null(nm) + ? NULL + : hg_string(f->src + ts_node_start_byte(nm), + ts_node_end_byte(nm) - ts_node_start_byte(nm), &nl); + if (name) { + hd_def_t *old = cbm_ht_get(d->names, name); + bool wins = !old || depth < old->depth || + (depth == old->depth && strcmp(f->rel, old->file->rel) < 0); + hd_def_t *nd = wins ? cbm_alloc(CBM_MEM_CLASS_OTHER, sizeof(*nd)) : NULL; + if (nd) { + *nd = (hd_def_t){f, c, depth}; + if (old) { + const char *key = cbm_ht_get_key(d->names, name); + cbm_ht_set(d->names, key, nd); + cbm_free(CBM_MEM_CLASS_OTHER, old); + cbm_free(CBM_MEM_CLASS_OTHER, name); + } else { + cbm_ht_set(d->names, name, nd); + } + name = NULL; + } + cbm_free(CBM_MEM_CLASS_OTHER, name); + } + } + if (block || strcmp(t, "if_action") == 0 || strcmp(t, "with_action") == 0 || + strcmp(t, "range_action") == 0) { + hd_scan(d, f, c, depth); /* a define is top-level; a block nests */ + } + } +} + +void cbm_helm_defines_add(cbm_helm_defines_t *d, const char *rel, const char *src, size_t len) { + if (!d || !src) { + return; + } + if (d->nfiles == d->cap) { + int cap = d->cap ? d->cap * PAIR_LEN : CBM_SZ_16; + hd_file_t **g = cbm_realloc(CBM_MEM_CLASS_OTHER, d->files, (size_t)cap * sizeof(*g)); + if (!g) { + return; + } + d->files = g; + d->cap = cap; + } + hd_file_t *f = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*f)); + if (!f) { + return; + } + f->rel = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, rel); + f->src = cbm_alloc(CBM_MEM_CLASS_OTHER, len + SKIP_ONE); + if (!f->rel || !f->src) { + cbm_free(CBM_MEM_CLASS_OTHER, f->rel); + cbm_free(CBM_MEM_CLASS_OTHER, f->src); + cbm_free(CBM_MEM_CLASS_OTHER, f); + return; + } + memcpy(f->src, src, len); + f->src[len] = '\0'; + f->len = len; + TSParser *parser = ts_parser_new(); + if (parser && ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_GOTEMPLATE))) { + f->tree = ts_parser_parse_string(parser, NULL, f->src, (uint32_t)len); + } + if (parser) { + ts_parser_delete(parser); + } + d->files[d->nfiles++] = f; + cbm_ht_set(d->by_rel, f->rel, f); + if (f->tree) { + hd_scan(d, f, ts_tree_root_node(f->tree), hd_depth(rel)); + } +} + +static void hd_free_def(const char *key, void *value, void *ud) { + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); + cbm_free(CBM_MEM_CLASS_OTHER, value); +} + +void cbm_helm_defines_free(cbm_helm_defines_t *d) { + if (!d) { + return; + } + if (d->names) { + cbm_ht_foreach(d->names, hd_free_def, NULL); + cbm_ht_free(d->names); + } + if (d->by_rel) { + cbm_ht_free(d->by_rel); /* its keys are the files' own */ + } + for (int i = 0; i < d->nfiles; i++) { + if (d->files[i]->tree) { + ts_tree_delete(d->files[i]->tree); + } + cbm_free(CBM_MEM_CLASS_OTHER, d->files[i]->rel); + cbm_free(CBM_MEM_CLASS_OTHER, d->files[i]->src); + cbm_free(CBM_MEM_CLASS_OTHER, d->files[i]); + } + cbm_free(CBM_MEM_CLASS_OTHER, d->files); + 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) { + return d && d->by_rel ? cbm_ht_get(d->by_rel, rel) : 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; +} + +/* ── Go literals and formatting ───────────────────────────────────── */ + +/* The value of Go string literal s ("..." or `...`) into out (capacity n); + * its length, or (size_t)-1 when it is no valid literal. */ +static size_t hg_unquote(const char *s, size_t n, char *out) { + if (n >= PAIR_LEN && s[0] == '`' && s[n - SKIP_ONE] == '`') { + memcpy(out, s + SKIP_ONE, n - PAIR_LEN); + return n - PAIR_LEN; + } + if (n < PAIR_LEN || s[0] != '"' || s[n - SKIP_ONE] != '"') { + return (size_t)-1; + } + size_t k = 0; + for (size_t i = SKIP_ONE; i + SKIP_ONE < n; i++) { + char c = s[i]; + if (c != '\\') { + out[k++] = c; + continue; + } + if (++i + SKIP_ONE >= n) { + return (size_t)-1; + } + c = s[i]; + static const char IN[] = "abfnrtv\\\"'"; + static const char OUT[] = "\a\b\f\n\r\t\v\\\"'"; + const char *e = strchr(IN, c); + if (e && c) { + out[k++] = OUT[e - IN]; + } else if (c == 'x' || c == 'u' || c == 'U') { + int digits = c == 'x' ? PAIR_LEN : c == 'u' ? 4 : 8; + uint32_t cp = 0; + for (int d = 0; d < digits; d++) { + int h = i + SKIP_ONE + SKIP_ONE < n ? hy_hex(s[i + SKIP_ONE]) : -SKIP_ONE; + if (h < 0) { + return (size_t)-1; + } + cp = cp * 16 + (uint32_t)h; + i++; + } + if (c == 'x') { + out[k++] = (char)cp; + } else { + k += hy_utf8(cp, out + k); + } + } else if (c >= '0' && c <= '7') { + uint32_t v = 0; + for (int d = 0; d < 3 && i + SKIP_ONE < n && s[i] >= '0' && s[i] <= '7'; d++, i++) { + v = v * 8 + (uint32_t)(s[i] - '0'); + } + i--; + out[k++] = (char)v; + } else { + return (size_t)-1; + } + } + return k; +} + +static char *hg_string(const char *s, size_t n, size_t *out_len) { + char *out = cbm_alloc(CBM_MEM_CLASS_OTHER, n * 4 + SKIP_ONE); + if (!out) { + return NULL; + } + size_t k = hg_unquote(s, n, out); + if (k == (size_t)-1) { + cbm_free(CBM_MEM_CLASS_OTHER, out); + return NULL; + } + out[k] = '\0'; + *out_len = k; + return out; +} + +/* float64 as Go's %v writes it (strconv 'g', shortest). */ +static void hg_float(double f, char *out, size_t sz) { + if (f == 0) { + snprintf(out, sz, signbit(f) ? "-0" : "0"); + return; + } + char e[CBM_SZ_64]; + int prec = 0; + for (prec = 0; prec < 17; prec++) { + snprintf(e, sizeof(e), "%.*e", prec, f); + if (strtod(e, NULL) == f) { + break; + } + } + /* e: [-]d[.ddd]e(+|-)XX */ + bool neg = e[0] == '-'; + const char *p = e + (neg ? SKIP_ONE : 0); + char digs[CBM_SZ_32]; + int nd = 0; + for (; *p && *p != 'e'; p++) { + if (isdigit((unsigned char)*p) && nd + SKIP_ONE < (int)sizeof(digs)) { + digs[nd++] = *p; + } + } + while (nd > SKIP_ONE && digs[nd - SKIP_ONE] == '0') { + nd--; + } + digs[nd] = '\0'; + int exp = *p == 'e' ? atoi(p + SKIP_ONE) : 0; + int dp = exp + SKIP_ONE; + size_t k = 0; + if (neg) { + out[k++] = '-'; + } + if (exp < -4 || exp >= 6) { + out[k++] = digs[0]; + if (nd > SKIP_ONE) { + out[k++] = '.'; + for (int i = SKIP_ONE; i < nd && k + 8 < sz; i++) { + out[k++] = digs[i]; + } + } + snprintf(out + k, sz - k, "e%c%02d", exp < 0 ? '-' : '+', exp < 0 ? -exp : exp); + return; + } + if (dp <= 0) { + out[k++] = '0'; + out[k++] = '.'; + for (int i = 0; i < -dp && k + 4 < sz; i++) { + out[k++] = '0'; + } + for (int i = 0; i < nd && k + 4 < sz; i++) { + out[k++] = digs[i]; + } + } else { + for (int i = 0; i < dp && k + 4 < sz; i++) { + out[k++] = i < nd ? digs[i] : '0'; + } + if (nd > dp) { + out[k++] = '.'; + for (int i = dp; i < nd && k + 4 < sz; i++) { + out[k++] = digs[i]; + } + } + } + out[k] = '\0'; +} + +bool cbm_helm_scalar_text(yyjson_mut_val *v, char *out, size_t sz) { + if (yyjson_mut_is_str(v)) { + snprintf(out, sz, "%s", yyjson_mut_get_str(v)); + } else if (yyjson_mut_is_real(v)) { + hg_float(yyjson_mut_get_real(v), out, sz); + } else if (yyjson_mut_is_int(v)) { + snprintf(out, sz, "%lld", + yyjson_mut_is_sint(v) ? (long long)yyjson_mut_get_sint(v) + : (long long)yyjson_mut_get_uint(v)); + } else if (yyjson_mut_is_bool(v)) { + snprintf(out, sz, "%s", yyjson_mut_get_bool(v) ? "true" : "false"); + } else { + return false; + } + return true; +} + +/* ── Runtime values ───────────────────────────────────────────────── */ + +typedef enum { + HV_UNK = 0, + HV_NIL, + HV_BOOL, + HV_INT, + HV_FLOAT, + HV_STR, + HV_JSON, + HV_MAP, + HV_LIST +} hv_kind_t; + +typedef struct hv hv_t; + +typedef struct { + const char **keys; + hv_t *vals; + int n; + bool strict; /* a struct (.Chart, .Release): a field not held is UNKNOWN */ +} hv_map_t; + +typedef struct { + hv_t *items; + int n; +} hv_list_t; + +struct hv { + hv_kind_t k; + bool b; + int64_t i; + double f; + const char *s; + size_t sn; + yyjson_mut_val *j; /* HV_JSON: an object or array of the values */ + hv_map_t *m; + hv_list_t *l; + const char *path; /* the values path it holds; NULL: none */ +}; + +static const hv_t HV_UNKNOWN = {.k = HV_UNK}; + +static hv_t hv_nil(void) { + return (hv_t){.k = HV_NIL}; +} + +static hv_t hv_bool(bool b) { + return (hv_t){.k = HV_BOOL, .b = b}; +} + +static hv_t hv_int(int64_t i) { + return (hv_t){.k = HV_INT, .i = i}; +} + +static hv_t hv_float(double f) { + return (hv_t){.k = HV_FLOAT, .f = f}; +} + +static hv_t hv_str(const char *s, size_t n) { + return (hv_t){.k = HV_STR, .s = s, .sn = n}; +} + +static hv_t hv_json(yyjson_mut_val *v, const char *path) { + hv_t out = {.path = path}; + if (!v || yyjson_mut_is_null(v)) { + out.k = HV_NIL; + } else if (yyjson_mut_is_bool(v)) { + out.k = HV_BOOL; + out.b = yyjson_mut_get_bool(v); + } else if (yyjson_mut_is_int(v)) { + out.k = HV_INT; + out.i = yyjson_mut_is_sint(v) ? yyjson_mut_get_sint(v) : (int64_t)yyjson_mut_get_uint(v); + } else if (yyjson_mut_is_real(v)) { + out.k = HV_FLOAT; + out.f = yyjson_mut_get_real(v); + } else if (yyjson_mut_is_str(v)) { + out.k = HV_STR; + out.s = yyjson_mut_get_str(v); + out.sn = yyjson_mut_get_len(v); + } else if (yyjson_mut_is_obj(v) || yyjson_mut_is_arr(v)) { + out.k = HV_JSON; + out.j = v; + } + return out; +} + +static bool hv_is_map(const hv_t *v) { + return v->k == HV_MAP || (v->k == HV_JSON && yyjson_mut_is_obj(v->j)); +} + +static bool hv_is_list(const hv_t *v) { + return v->k == HV_LIST || (v->k == HV_JSON && yyjson_mut_is_arr(v->j)); +} + +static int hv_len(const hv_t *v) { + switch (v->k) { + case HV_STR: + return (int)v->sn; + case HV_MAP: + return v->m->n; + case HV_LIST: + return v->l->n; + case HV_JSON: + return (int)(yyjson_mut_is_obj(v->j) ? yyjson_mut_obj_size(v->j) + : yyjson_mut_arr_size(v->j)); + default: + return -SKIP_ONE; + } +} + +/* Go template truth (if / with / and / or / not). */ +static bool hv_truth(const hv_t *v) { + switch (v->k) { + case HV_BOOL: + return v->b; + case HV_INT: + return v->i != 0; + case HV_FLOAT: + return v->f != 0; + case HV_STR: + case HV_MAP: + case HV_LIST: + case HV_JSON: + return hv_len(v) > 0; + default: + return false; + } +} + +/* ── Renderer state ───────────────────────────────────────────────── */ + +typedef struct { + const char *name; + size_t nl; + hv_t v; +} hvar_t; + +typedef struct { + char *buf; + size_t n; + size_t cap; + bool unk; +} hsink_t; + +typedef struct { + const char *src; /* the text these nodes are of */ + hv_t dot; + int var_base; /* the template's first variable (`$`) */ + hsink_t *out; /* NULL: the template's own output, which the probes read */ +} hframe_t; + +typedef struct { + CBMArena *a; + yyjson_mut_doc *scratch; + const cbm_helm_defines_t *defs; + hvar_t *vars; + int nvars; + int vcap; + int nesting; + 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 */ + 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; + /* the values path of each values map changed (a set or a merge): a merge + * above one of them reaches what it put there */ + const char **changes; + int nchanges; + int chcap; + /* 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; + cbm_helm_probe_t *probes; + int nprobes; + const uint32_t *lines; /* line start offsets */ + int nlines; + int capture; /* the probe capturing; -1: none */ + hsink_t cap; + const char **ckeys; + int nck; + int ckcap; + CBMHashTable *ckset; /* ckeys as a set */ + TSParser *parser; + TSTree **trees; + int ntrees; + int tcap; +} hst_t; + +/* Is the render over its memory budget (the arena grown since it began)? */ +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_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_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; + } + if (!hst_write(st, n)) { + s->unk = true; + return; + } + if (s->n + n + SKIP_ONE > s->cap) { + size_t cap = (s->cap ? s->cap : CBM_SZ_64) * PAIR_LEN; + while (cap < s->n + n + SKIP_ONE) { + cap *= PAIR_LEN; + } + char *g = cbm_arena_alloc(st->a, cap); + if (!g) { + s->unk = true; + return; + } + if (s->n) { + memcpy(g, s->buf, s->n); + } + s->buf = g; + s->cap = cap; + } + memcpy(s->buf + s->n, p, n); + s->n += n; + s->buf[s->n] = '\0'; +} + +static const char *hs_str(hst_t *st, const hsink_t *s) { + return s->n ? s->buf : cbm_arena_strdup(st->a, ""); +} + +static void hst_record(hst_t *st, const char *path) { + if (st->capture < 0 || !path) { + 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) { + memcpy(g, st->ckeys, (size_t)st->nck * sizeof(*g)); + } + st->ckeys = g; + st->ckcap = cap; + } + cbm_ht_set(st->ckset, path, (void *)(uintptr_t)SKIP_ONE); + st->ckeys[st->nck++] = path; +} + +/* A values path joined with key `k` (its own `.` escaped). */ +static const char *hst_path(hst_t *st, const char *path, const char *k, size_t kl) { + if (!path) { + return NULL; + } + size_t dots = 0; + for (size_t i = 0; i < kl; i++) { + dots += k[i] == '.'; + } + size_t pl = strlen(path); + char *out = cbm_arena_alloc(st->a, pl + kl + dots + PAIR_LEN); + if (!out) { + return NULL; + } + size_t o = 0; + if (pl) { + memcpy(out, path, pl); + o = pl; + out[o++] = '.'; + } + for (size_t i = 0; i < kl; i++) { + if (k[i] == '.') { + out[o++] = '\\'; + } + out[o++] = k[i]; + } + out[o] = '\0'; + return out; +} + +static bool hst_var_push(hst_t *st, const char *name, size_t nl, hv_t v) { + if (st->nvars == st->vcap) { + int cap = st->vcap ? st->vcap * PAIR_LEN : CBM_SZ_32; + hvar_t *g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); + if (!g) { + return false; + } + if (st->nvars) { + memcpy(g, st->vars, (size_t)st->nvars * sizeof(*g)); + } + st->vars = g; + st->vcap = cap; + } + st->vars[st->nvars++] = (hvar_t){name, nl, v}; + return true; +} + +static hvar_t *hst_var(hst_t *st, const hframe_t *fr, const char *name, size_t nl) { + for (int i = st->nvars - SKIP_ONE; i >= fr->var_base; i--) { + if (st->vars[i].nl == nl && memcmp(st->vars[i].name, name, nl) == 0) { + return &st->vars[i]; + } + } + return NULL; +} + +/* Has a set / unset / merge changed map v (or, for a merge, a map holding + * it)? Then what v holds is UNKNOWN. */ +static bool hv_poisoned(const hst_t *st, const hv_t *v) { + const void *map = v->k == HV_MAP ? (const void *)v->m + : v->k == HV_JSON ? (const void *)v->j + : NULL; + if (!map) { + return false; + } + char key[CBM_SZ_32]; + snprintf(key, sizeof(key), "%p", map); + if (st->poison_maps && cbm_ht_get(st->poison_maps, key)) { + return true; + } + if (!st->poison_paths || !v->path) { + return false; + } + /* a merged values path covers what is below it: each prefix of v's path + * that ends at a key ("", "a", "a.b", "a.b[0]" ...; an escaped `\.` is no + * end) is looked up */ + size_t pl = strlen(v->path); + char small[CBM_SZ_256]; + char *buf = pl < sizeof(small) ? small : cbm_alloc(CBM_MEM_CLASS_OTHER, pl + SKIP_ONE); + bool hit = false; + for (size_t i = 0; buf && i <= pl && !hit; i++) { + bool end = i == pl || ((v->path[i] == '.' || v->path[i] == '[') && + !(i > 0 && v->path[i - SKIP_ONE] == '\\')); + if (!end) { + continue; + } + memcpy(buf, v->path, i); + buf[i] = '\0'; + hit = cbm_ht_get(st->poison_paths, buf) != NULL; + } + if (buf != small) { + cbm_free(CBM_MEM_CLASS_OTHER, buf); + } + return hit || !buf; +} + +/* Key `k` into set *set (made when NULL; the key copied into the arena). */ +static bool hst_set_add(hst_t *st, CBMHashTable **set, const char *k) { + if (!*set) { + *set = cbm_ht_create(0); + } + if (!*set) { + return false; + } + if (cbm_ht_get(*set, k)) { + return true; + } + const char *dup = cbm_arena_strdup(st->a, k); + if (!dup) { + return false; + } + cbm_ht_set(*set, dup, (void *)(uintptr_t)SKIP_ONE); + return true; +} + +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, 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); +} + +/* Has a values map at path p or below it been changed? */ +static bool hst_changed_below(hst_t *st, const char *p) { + size_t lp = strlen(p); + for (int i = 0; i < st->nchanges; i++) { + const char *c = st->changes[i]; + if (!hst_step(st) || + (strncmp(c, p, lp) == 0 && (c[lp] == '\0' || c[lp] == '.' || c[lp] == '['))) { + return true; + } + } + return false; +} + +/* Values map at path p changed in place; false with no room to say so. */ +static bool hst_record_change(hst_t *st, const char *p) { + if (st->nchanges == st->chcap) { + int cap = st->chcap ? st->chcap * PAIR_LEN : CBM_SZ_16; + const char **g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); + if (!g) { + return false; + } + if (st->nchanges) { + memcpy(g, st->changes, (size_t)st->nchanges * sizeof(*g)); + } + st->changes = g; + st->chcap = cap; + } + st->changes[st->nchanges++] = p; + return true; +} + +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; + } + 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), a map not + * of the values (whose maps no path covers), or a values map below which + * a change was made (it may hold another's map now) cannot be poisoned + * whole */ + if (deep && (hv_poisoned(st, v) || (v->path && !v->path[0]) || (v->k == HV_JSON && !v->path) || + (v->path && hst_changed_below(st, v->path)))) { + return false; + } + if (deep && v->k == HV_MAP) { + for (int i = 0; i < v->m->n; i++) { + 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; + } + } + } + char key[CBM_SZ_32]; + snprintf(key, sizeof(key), "%p", map); + if (!hst_set_add(st, &st->poison_maps, key) || (v->path && !hst_record_change(st, v->path))) { + return false; + } + return !(deep && v->path) || hst_set_add(st, &st->poison_paths, v->path); +} + +static bool hn_is(TSNode n, const char *t) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), t) == 0; +} + +static const char *hn_text(const hframe_t *fr, TSNode n, size_t *len) { + uint32_t b = ts_node_start_byte(n); + *len = ts_node_end_byte(n) - b; + return fr->src + b; +} + +static TSNode hn_field(TSNode n, const char *f) { + return ts_node_child_by_field_name(n, f, (uint32_t)strlen(f)); +} + +/* The name of a variable node ("" for `$`). */ +static const char *hn_var_name(const hframe_t *fr, TSNode var, size_t *nl) { + TSNode nm = hn_field(var, "name"); + if (ts_node_is_null(nm)) { + *nl = 0; + return ""; + } + return hn_text(fr, nm, nl); +} + +/* ── Printing ─────────────────────────────────────────────────────── */ + +/* Go's %v of a basic value into the arena; NULL for one not modelled. */ +static const char *hv_fmt(hst_t *st, const hv_t *v, size_t *len) { + char buf[CBM_SZ_64]; + switch (v->k) { + case HV_STR: + *len = v->sn; + return v->s; + case HV_BOOL: + *len = v->b ? 4 : 5; + return v->b ? "true" : "false"; + case HV_INT: + snprintf(buf, sizeof(buf), "%lld", (long long)v->i); + break; + case HV_FLOAT: + hg_float(v->f, buf, sizeof(buf)); + break; + case HV_NIL: + *len = 5; + return ""; + default: + return NULL; + } + char *out = cbm_arena_strdup(st->a, buf); + *len = out ? strlen(out) : 0; + return out; +} + +/* Sprig's strval. */ +static bool hv_strval(hst_t *st, const hv_t *v, hv_t *out) { + size_t n = 0; + const char *s = hv_fmt(st, v, &n); + if (!s) { + return false; + } + *out = hv_str(s, n); + return true; +} + +/* ── Output and probes ────────────────────────────────────────────── */ + +/* A capture's scalar with its YAML quoting removed; NULL: none / invalid. */ +static const char *hst_scalar(hst_t *st, const char *s, size_t n) { + while (n && (*s == ' ' || *s == '\t')) { + s++; + n--; + } + while (n && (s[n - SKIP_ONE] == ' ' || s[n - SKIP_ONE] == '\t' || s[n - SKIP_ONE] == '\r')) { + n--; + } + if (n == 0) { + return NULL; + } + if (s[0] == '"' || s[0] == '\'') { + /* the closing quote: \" escapes one in "..", '' one in '..' */ + size_t e = SKIP_ONE; + for (; e < n; e++) { + if (s[0] == '"' && s[e] == '\\') { + e++; + } else if (s[0] == '\'' && s[e] == '\'' && e + SKIP_ONE < n && + s[e + SKIP_ONE] == '\'') { + e++; + } else if (s[e] == s[0]) { + break; + } + } + if (e >= n) { + return NULL; + } + for (size_t r = e + SKIP_ONE; r < n; r++) { + if (s[r] == '#') { + break; + } + if (s[r] != ' ' && s[r] != '\t') { + return NULL; + } + } + yyjson_mut_val *v = cbm_helm_values_parse(st->scratch, s, e + SKIP_ONE); + return v && yyjson_mut_is_str(v) ? cbm_arena_strdup(st->a, yyjson_mut_get_str(v)) : NULL; + } + for (size_t i = 0; i < n; i++) { + if (s[i] == '#' && i > 0 && (s[i - SKIP_ONE] == ' ' || s[i - SKIP_ONE] == '\t')) { + n = i; + break; + } + if (s[i] == ':' && (i + SKIP_ONE == n || s[i + SKIP_ONE] == ' ')) { + return NULL; /* a mapping, no scalar */ + } + } + while (n && (s[n - SKIP_ONE] == ' ' || s[n - SKIP_ONE] == '\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; + } + cbm_helm_probe_t *p = &st->probes[st->capture]; + if (p->n == p->cap) { + /* the captures of a probe grow by doubling: n captures cost O(n) */ + int cap = p->cap ? p->cap * PAIR_LEN : CBM_SZ_4; + const char **gi = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gi)); + const char **gk = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*gk)); + if (gi && gk) { + if (p->n) { + memcpy(gi, p->image, (size_t)p->n * sizeof(*gi)); + memcpy(gk, p->keys, (size_t)p->n * sizeof(*gk)); + } + p->image = gi; + p->keys = gk; + p->cap = cap; + } + } + if (p->n < p->cap) { + bool whole = true; + 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++; + } + /* the keys this capture recorded, one by one: a clear walks the set's + * whole capacity, which one huge capture leaves behind for every later + * one */ + for (int i = 0; st->ckset && i < st->nck; i++) { + (void)cbm_ht_delete(st->ckset, st->ckeys[i]); + } + st->capture = -SKIP_ONE; + st->cap = (hsink_t){0}; + st->nck = 0; +} + +/* Output of the top-level template: into the capture until its newline. */ +static void hst_out(hst_t *st, hframe_t *fr, const char *p, size_t n) { + if (fr->out) { + hs_append(st, fr->out, p, n); + return; + } + if (st->capture < 0) { + return; + } + const char *nl = memchr(p, '\n', n); + hs_append(st, &st->cap, p, nl ? (size_t)(nl - p) : n); + if (nl) { + hst_capture_end(st); + } +} + +static void hst_out_unknown(hst_t *st, hframe_t *fr) { + if (fr->out) { + fr->out->unk = true; + } else if (st->capture >= 0) { + st->cap.unk = true; + } +} + +/* Output of a value as text/template prints it (nil: "", as Helm). */ +static void hst_out_value(hst_t *st, hframe_t *fr, const hv_t *v) { + if (v->k == HV_NIL) { + return; + } + size_t n = 0; + const char *s = hv_fmt(st, v, &n); + if (!s) { + hst_out_unknown(st, fr); + return; + } + hst_out(st, fr, s, n); +} + +static bool hn_ws(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'; +} + +/* A text node, trimmed by an adjacent `-}}` / `{{-`; at the top level each + * probe whose `image:` key it holds starts its capture after the key. */ +static void hst_text(hst_t *st, hframe_t *fr, TSNode n) { + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + /* the markers are lexical -- `{{- ` / ` -}}` beside the text, whichever + * action (of whatever nesting) they open or close */ + if (b >= 4 && strncmp(fr->src + b - 3, "-}}", 3) == 0 && hn_ws(fr->src[b - 4])) { + while (b < e && hn_ws(fr->src[b])) { + b++; + } + } + if (strncmp(fr->src + e, "{{-", 3) == 0 && hn_ws(fr->src[e + 3])) { + while (e > b && hn_ws(fr->src[e - SKIP_ONE])) { + e--; + } + } + if (fr->out || fr->src != st->top_src) { + hst_out(st, fr, fr->src + b, e - b); + return; + } + uint32_t at = b; + for (;;) { + /* the next probe key in [at, e) */ + uint32_t key = e; + int which = -SKIP_ONE; + for (int i = 0; i < st->nprobes; i++) { + int line = st->probes[i].line; + if (line < SKIP_ONE || line > st->nlines) { + continue; + } + uint32_t k = st->lines[line - SKIP_ONE]; + while (k < st->top_len && (st->top_src[k] == ' ' || st->top_src[k] == '\t')) { + k++; + } + if (k < st->top_len && st->top_src[k] == '-' && k + SKIP_ONE < st->top_len && + st->top_src[k + SKIP_ONE] == ' ') { + k++; + while (k < st->top_len && st->top_src[k] == ' ') { + k++; + } + } + if (k >= at && k < key && k + strlen("image:") <= e && + memcmp(st->top_src + k, "image:", strlen("image:")) == 0) { + key = k; + which = i; + } + } + if (which < 0) { + hst_out(st, fr, fr->src + at, e - at); + return; + } + uint32_t after = key + (uint32_t)strlen("image:"); + hst_out(st, fr, fr->src + at, after - at); + hst_capture_end(st); + st->capture = which; + st->cap = (hsink_t){0}; + st->nck = 0; + at = after; + } +} + +/* ── Evaluation ───────────────────────────────────────────────────── */ + +static hv_t he_eval(hst_t *st, hframe_t *fr, TSNode n); +static void he_items(hst_t *st, hframe_t *fr, TSNode parent, const char *field); +static hv_t he_render_define(hst_t *st, const char *name, hv_t arg, hsink_t *into); + +/* Field `name` of value v. */ +static hv_t he_field(hst_t *st, const hv_t *v, const char *name, size_t nl) { + if (hv_poisoned(st, v)) { + return HV_UNKNOWN; + } + if (v->k == HV_MAP) { + for (int i = 0; i < v->m->n; i++) { + if (strlen(v->m->keys[i]) == nl && memcmp(v->m->keys[i], name, nl) == 0) { + hst_record(st, v->m->vals[i].path); + return v->m->vals[i]; + } + } + return v->m->strict ? HV_UNKNOWN : hv_nil(); + } + if (v->k == HV_JSON && yyjson_mut_is_obj(v->j)) { + const char *path = hst_path(st, v->path, name, nl); + yyjson_mut_val *c = yyjson_mut_obj_getn(v->j, name, nl); + hst_record(st, path); + return hv_json(c, path); + } + return HV_UNKNOWN; /* a field of nil, of a scalar, of a list: an error */ +} + +static hv_t he_index1(hst_t *st, const hv_t *v, const hv_t *k) { + if (hv_poisoned(st, v)) { + return HV_UNKNOWN; + } + if (hv_is_map(v)) { + if (k->k != HV_STR) { + return HV_UNKNOWN; + } + return he_field(st, v, k->s, k->sn); + } + if (hv_is_list(v)) { + if (k->k != HV_INT) { + return HV_UNKNOWN; + } + if (v->k == HV_LIST) { + return k->i >= 0 && k->i < v->l->n ? v->l->items[k->i] : HV_UNKNOWN; + } + yyjson_mut_val *c = k->i >= 0 ? yyjson_mut_arr_get(v->j, (size_t)k->i) : NULL; + if (!c) { + return HV_UNKNOWN; + } + const char *path = + v->path ? cbm_arena_sprintf(st->a, "%s[%lld]", v->path, (long long)k->i) : NULL; + hst_record(st, path); + return hv_json(c, path); + } + return v->k == HV_NIL ? hv_nil() : HV_UNKNOWN; +} + +/* Sprig empty. */ +static bool hv_empty(const hv_t *v) { + return !hv_truth(v); +} + +static bool hv_eq(const hv_t *a, const hv_t *b, bool *ok) { + *ok = true; + if (a->k == HV_STR && b->k == HV_STR) { + return a->sn == b->sn && memcmp(a->s, b->s, a->sn) == 0; + } + if (a->k == HV_BOOL && b->k == HV_BOOL) { + return a->b == b->b; + } + if (a->k == HV_INT && b->k == HV_INT) { + return a->i == b->i; + } + if (a->k == HV_FLOAT && b->k == HV_FLOAT) { + return a->f == b->f; + } + *ok = false; /* incompatible kinds: an error in Go */ + return false; +} + +static hv_t hv_mklist(hst_t *st, const hv_t *items, int n) { + hv_list_t *l = cbm_arena_alloc(st->a, sizeof(*l)); + hv_t *v = n ? cbm_arena_alloc(st->a, (size_t)n * sizeof(*v)) : NULL; + if (!l || (n && !v)) { + return HV_UNKNOWN; + } + if (n) { + memcpy(v, items, (size_t)n * sizeof(*v)); + } + *l = (hv_list_t){v, n}; + return (hv_t){.k = HV_LIST, .l = l}; +} + +/* The items of a list value into an arena array; -1 when it is none. */ +static int hv_items(hst_t *st, const hv_t *v, hv_t **out) { + if (v->k == HV_LIST) { + *out = v->l->items; + return v->l->n; + } + if (v->k == HV_JSON && yyjson_mut_is_arr(v->j)) { + int n = (int)yyjson_mut_arr_size(v->j); + hv_t *items = n ? cbm_arena_alloc(st->a, (size_t)n * sizeof(*items)) : NULL; + if (n && !items) { + return -SKIP_ONE; + } + size_t idx, max; + yyjson_mut_val *it; + yyjson_mut_arr_foreach(v->j, idx, max, it) { + const char *path = v->path ? cbm_arena_sprintf(st->a, "%s[%zu]", v->path, idx) : NULL; + items[idx] = hv_json(it, path); + } + *out = items; + return n; + } + if (v->k == HV_NIL) { + *out = NULL; + return 0; + } + return -SKIP_ONE; +} + +static int he_key_cmp(const void *x, const void *y) { + return strcmp(*(const char *const *)x, *(const char *const *)y); +} + +/* A map's keys, sorted (Go's order for range), into an arena array. */ +static int hv_keys(hst_t *st, const hv_t *v, const char ***out) { + int n = hv_len(v); + const char **keys = n > 0 ? cbm_arena_alloc(st->a, (size_t)n * sizeof(*keys)) : NULL; + if (n > 0 && !keys) { + return -SKIP_ONE; + } + if (v->k == HV_MAP) { + for (int i = 0; i < n; i++) { + keys[i] = v->m->keys[i]; + } + } else { + size_t idx, max; + yyjson_mut_val *k, *val; + yyjson_mut_obj_foreach(v->j, idx, max, k, val) { + (void)val; + keys[idx] = yyjson_mut_get_str(k); + } + } + if (n > SKIP_ONE) { + qsort(keys, (size_t)n, sizeof(*keys), he_key_cmp); + } + *out = keys; + return n; +} + +static bool hv_to_int(const hv_t *v, int64_t *out) { + switch (v->k) { + case HV_INT: + *out = v->i; + return true; + case HV_FLOAT: + *out = (int64_t)v->f; + return true; + case HV_BOOL: + *out = v->b ? SKIP_ONE : 0; + return true; + case HV_NIL: + *out = 0; + return true; + case HV_STR: { + char buf[CBM_SZ_64]; + if (v->sn == 0 || v->sn >= sizeof(buf)) { + *out = 0; + return v->sn == 0; + } + memcpy(buf, v->s, v->sn); + buf[v->sn] = '\0'; + char *end = NULL; + long long i = strtoll(buf, &end, 0); + if (end && !*end) { + *out = i; + return true; + } + double d = strtod(buf, &end); + *out = end && !*end ? (int64_t)d : 0; + return true; + } + default: + return false; + } +} + +/* Go's %q of s. */ +static const char *hg_quote(hst_t *st, const char *s, size_t n, char q) { + char *out = cbm_arena_alloc(st->a, n * 4 + 3); + if (!out) { + return NULL; + } + size_t k = 0; + out[k++] = q; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (q == '"' && (c == '"' || c == '\\')) { + out[k++] = '\\'; + out[k++] = (char)c; + } else if (q == '"' && c < 0x20) { + static const char HEX[] = "0123456789abcdef"; + const char *named = c == '\n' ? "\\n" : c == '\t' ? "\\t" : c == '\r' ? "\\r" : NULL; + if (named) { + out[k++] = named[0]; + out[k++] = named[1]; + } else { + out[k++] = '\\'; + out[k++] = 'x'; + out[k++] = HEX[c >> 4]; + out[k++] = HEX[c & 0xF]; + } + } else { + out[k++] = (char)c; + } + } + out[k++] = q; + out[k] = '\0'; + return out; +} + +/* fmt.Sprintf for the verbs %s %v %d %q %% without flags; NULL otherwise. */ +static const char *he_printf(hst_t *st, const hv_t *args, int n) { + if (n < SKIP_ONE || args[0].k != HV_STR) { + return NULL; + } + hsink_t out = {0}; + int ai = SKIP_ONE; + const char *f = args[0].s; + size_t fl = args[0].sn; + for (size_t i = 0; i < fl; i++) { + if (f[i] != '%') { + hs_append(st, &out, f + i, SKIP_ONE); + continue; + } + if (++i >= fl) { + return NULL; + } + char verb = f[i]; + if (verb == '%') { + hs_append(st, &out, "%", SKIP_ONE); + continue; + } + if (ai >= n) { + return NULL; + } + const hv_t *a = &args[ai++]; + size_t sl = 0; + const char *s = NULL; + if (verb == 's' && a->k == HV_STR) { + s = a->s; + sl = a->sn; + } else if (verb == 'v') { + s = hv_fmt(st, a, &sl); + } else if (verb == 'd' && a->k == HV_INT) { + s = hv_fmt(st, a, &sl); + } else if (verb == 'q' && a->k == HV_STR) { + s = hg_quote(st, a->s, a->sn, '"'); + sl = s ? strlen(s) : 0; + } + if (!s) { + return NULL; /* %!s(float64=...) and the like: no image */ + } + hs_append(st, &out, s, sl); + } + if (ai != n || out.unk) { + return NULL; + } + return hs_str(st, &out); +} + +static hv_t he_strfn(hst_t *st, const char *fn, const hv_t *a, int n) { + /* Sprig string functions take the subject last. */ + hv_t s; + if (n < SKIP_ONE || !hv_strval(st, &a[n - SKIP_ONE], &s)) { + return HV_UNKNOWN; + } + if (strcmp(fn, "trim") == 0 && n == SKIP_ONE) { + size_t b = 0; + size_t e = s.sn; + while (b < e && hn_ws(s.s[b])) { + b++; + } + while (e > b && hn_ws(s.s[e - SKIP_ONE])) { + e--; + } + return hv_str(s.s + b, e - b); + } + if ((strcmp(fn, "lower") == 0 || strcmp(fn, "upper") == 0) && n == SKIP_ONE) { + char *o = cbm_arena_strndup(st->a, s.s, s.sn); + for (size_t i = 0; o && i < s.sn; i++) { + o[i] = + (char)(fn[0] == 'l' ? tolower((unsigned char)o[i]) : toupper((unsigned char)o[i])); + } + return o ? hv_str(o, s.sn) : HV_UNKNOWN; + } + if (n != PAIR_LEN && strcmp(fn, "replace") != 0) { + return HV_UNKNOWN; + } + hv_t p; + if (!hv_strval(st, &a[0], &p)) { + return HV_UNKNOWN; + } + if (strcmp(fn, "trimPrefix") == 0) { + bool has = p.sn <= s.sn && memcmp(s.s, p.s, p.sn) == 0; + return has ? hv_str(s.s + p.sn, s.sn - p.sn) : s; + } + if (strcmp(fn, "trimSuffix") == 0) { + bool has = p.sn <= s.sn && memcmp(s.s + s.sn - p.sn, p.s, p.sn) == 0; + return has ? hv_str(s.s, s.sn - p.sn) : s; + } + if (strcmp(fn, "trimAll") == 0) { + size_t b = 0; + size_t e = s.sn; + while (b < e && memchr(p.s, s.s[b], p.sn)) { + b++; + } + while (e > b && memchr(p.s, s.s[e - SKIP_ONE], p.sn)) { + e--; + } + return hv_str(s.s + b, e - b); + } + if (strcmp(fn, "contains") == 0 || strcmp(fn, "hasPrefix") == 0 || + strcmp(fn, "hasSuffix") == 0) { + if (p.sn > s.sn) { + return hv_bool(false); + } + if (fn[0] == 'c') { + char *hs = cbm_arena_strndup(st->a, s.s, s.sn); + char *ns = cbm_arena_strndup(st->a, p.s, p.sn); + return hs && ns ? hv_bool(strstr(hs, ns) != NULL) : HV_UNKNOWN; + } + const char *at = fn[3] == 'P' ? s.s : s.s + s.sn - p.sn; + return hv_bool(memcmp(at, p.s, p.sn) == 0); + } + if (strcmp(fn, "trunc") == 0) { + int64_t c = 0; + if (!hv_to_int(&a[0], &c)) { + return HV_UNKNOWN; + } + if (c >= 0) { + return hv_str(s.s, (size_t)c < s.sn ? (size_t)c : s.sn); + } + size_t keep = (size_t)(-c) < s.sn ? (size_t)(-c) : s.sn; + return hv_str(s.s + s.sn - keep, keep); + } + if (strcmp(fn, "replace") == 0 && n == 3) { + hv_t to; + if (!hv_strval(st, &a[SKIP_ONE], &to) || p.sn == 0) { + return HV_UNKNOWN; + } + hsink_t out = {0}; + for (size_t i = 0; i < s.sn;) { + if (i + p.sn <= s.sn && memcmp(s.s + i, p.s, p.sn) == 0) { + hs_append(st, &out, to.s, to.sn); + i += p.sn; + } else { + hs_append(st, &out, s.s + i, SKIP_ONE); + i++; + } + } + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + return HV_UNKNOWN; +} + +/* Sprig functions that change their argument in place: set / unset / merge + * a map, sortAlpha a []string (splitList, keys, regexSplit give one; a list + * is never poisoned, so it always counts as a change not run). Every other + * list function copies its argument (push, append, prepend, reverse, uniq, + * without, compact, rest, initial, concat, chunk) or reads it (first, last, + * has, slice -- which shares it but changes nothing). */ +static bool he_mutator(const char *s, size_t n) { + static const char *const MUTATORS[] = { + "set", "unset", "merge", "mergeOverwrite", "mustMerge", "mustMergeOverwrite", + "sortAlpha", NULL}; + return hy_in(s, n, MUTATORS); +} + +static bool he_define_may_mutate(hst_t *st, const char *name, int depth); + +/* The define a call / action names with a string literal into the arena; + * NULL (and *computed) when the name is no literal. */ +static const char *he_called_define(hst_t *st, const char *src, TSNode c, bool *computed) { + TSNode nm = {0}; + *computed = false; + if (hn_is(c, "function_call")) { + TSNode fn = hn_field(c, "function"); + TSNode args = hn_field(c, "arguments"); + uint32_t fb = ts_node_is_null(fn) ? 0 : ts_node_start_byte(fn); + size_t fl = ts_node_is_null(fn) ? 0 : ts_node_end_byte(fn) - fb; + if (fl != strlen("include") || strncmp(src + fb, "include", fl) != 0) { + return NULL; + } + nm = ts_node_is_null(args) ? (TSNode){0} : ts_node_named_child(args, 0); + } else { + nm = hn_field(c, "name"); + } + if (!hn_is(nm, "interpreted_string_literal") && !hn_is(nm, "raw_string_literal")) { + *computed = true; + return NULL; + } + uint32_t b = ts_node_start_byte(nm); + size_t l = ts_node_end_byte(nm) - b; + char *name = cbm_arena_alloc(st->a, l * 4 + SKIP_ONE); + size_t k = name ? hg_unquote(src + b, l, name) : (size_t)-1; + if (k == (size_t)-1) { + *computed = true; + return NULL; + } + name[k] = '\0'; + return name; +} + +/* 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) { + TSNode fn = hn_is(c, "identifier") ? c : hn_field(c, "function"); + if (ts_node_is_null(fn)) { + return NULL; + } + uint32_t b = ts_node_start_byte(fn); + *n = ts_node_end_byte(fn) - 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; +} + +/* 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 || ts_node_has_error(n)) { + return true; /* a form the grammar rejects (Go may not) may hide a change */ + } + TSTreeCursor cur = ts_tree_cursor_new(n); + bool found = false; + bool more = true; + while (more && !found && hst_step(st)) { + TSNode c = ts_tree_cursor_current_node(&cur); + const char *t = ts_node_type(c); + size_t fl = 0; + const char *fs = NULL; + 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); + found = computed || (name && he_define_may_mutate(st, name, depth + SKIP_ONE)); + } + if (found || (strcmp(t, "variable") != 0 && 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 || st->stop; +} + +/* Memo states of he_define_may_mutate / he_file_may_mutate. */ +enum { HM_NO = 1, HM_YES = 2, HM_BUSY = 3, HM_STALE = 4 }; + +/* 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) { + 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 + * a wrong no -- a yes is final. */ +static bool he_define_may_mutate(hst_t *st, const char *name, int depth) { + if (!st->mutates) { + st->mutates = cbm_ht_create(0); + } + uintptr_t memo = st->mutates ? (uintptr_t)cbm_ht_get(st->mutates, name) : 0; + if (memo == HM_YES || memo == HM_NO) { + return memo == HM_YES; + } + const hd_def_t *d = hd_find(st->defs, name); + if (!d) { + /* 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. Kept by + * name once final, so an include in a loop asks once */ + const hd_file_t *f = he_template_file(st, name); + bool m = f ? he_file_may_mutate(st, f, depth) : true; + bool final = + m || (st->file_mutates && (uintptr_t)cbm_ht_get(st->file_mutates, f->rel) == HM_NO); + const char *key = final && st->mutates ? cbm_arena_strdup(st->a, name) : NULL; + if (key) { + cbm_ht_set(st->mutates, key, (void *)(uintptr_t)(m ? HM_YES : HM_NO)); + } + return m; + } + if (memo == HM_BUSY) { + st->busy_hits++; + return false; + } + const char *key = memo ? cbm_ht_get_key(st->mutates, name) : cbm_arena_strdup(st->a, name); + if (st->mutates && key) { + 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); + 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; + cbm_ht_set(st->mutates, key, (void *)res); + } + return m; +} + +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 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; + } + } + if (strcmp(fn, "include") == 0 && n == PAIR_LEN && a[0].k == HV_STR) { + hsink_t out = {0}; + char *name = cbm_arena_strndup(st->a, a[0].s, a[0].sn); + hv_t r = name ? he_render_define(st, name, a[1], &out) : HV_UNKNOWN; + return r.k == HV_UNK || out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.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); + TSTree *t = src ? ts_parser_parse_string(st->parser, NULL, src, (uint32_t)a[0].sn) : NULL; + if (!t) { + return HV_UNKNOWN; + } + if (st->ntrees == st->tcap) { + int cap = st->tcap ? st->tcap * PAIR_LEN : CBM_SZ_16; + TSTree **g = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*g)); + if (!g) { + ts_tree_delete(t); + return HV_UNKNOWN; + } + if (st->ntrees) { + memcpy(g, st->trees, (size_t)st->ntrees * sizeof(*g)); + } + st->trees = g; + st->tcap = cap; + } + st->trees[st->ntrees++] = t; + 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}; + int mark = st->nvars; + hframe_t sub = {.src = src, .dot = a[1], .var_base = st->nvars, .out = &out}; + hst_var_push(st, "", 0, a[1]); + he_items(st, &sub, root, NULL); + st->nvars = mark; + st->nesting--; + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + if (strcmp(fn, "default") == 0 && n >= SKIP_ONE) { + return n == SKIP_ONE || hv_empty(&a[SKIP_ONE]) ? a[0] : a[SKIP_ONE]; + } + if (strcmp(fn, "coalesce") == 0) { + for (int i = 0; i < n; i++) { + if (!hv_empty(&a[i])) { + return a[i]; + } + } + return hv_nil(); + } + if (strcmp(fn, "ternary") == 0 && n == 3) { + return a[2].k == HV_BOOL ? (a[2].b ? a[0] : a[SKIP_ONE]) : HV_UNKNOWN; + } + if (strcmp(fn, "empty") == 0 && n == SKIP_ONE) { + return hv_bool(hv_empty(&a[0])); + } + if (strcmp(fn, "not") == 0 && n == SKIP_ONE) { + return hv_bool(!hv_truth(&a[0])); + } + if ((strcmp(fn, "eq") == 0 && n >= PAIR_LEN) || (strcmp(fn, "ne") == 0 && n == PAIR_LEN)) { + bool any = false; + for (int i = SKIP_ONE; i < n; i++) { + bool ok = false; + any = any || hv_eq(&a[0], &a[i], &ok); + if (!ok) { + return HV_UNKNOWN; + } + } + return hv_bool(fn[0] == 'e' ? any : !any); + } + if (strcmp(fn, "required") == 0 && n == PAIR_LEN) { + bool missing = a[1].k == HV_NIL || (a[1].k == HV_STR && a[1].sn == 0); + return missing ? HV_UNKNOWN : a[1]; + } + if (strcmp(fn, "printf") == 0) { + const char *s = he_printf(st, a, n); + return s ? hv_str(s, strlen(s)) : HV_UNKNOWN; + } + if (strcmp(fn, "print") == 0 || strcmp(fn, "cat") == 0) { + hsink_t out = {0}; + bool prev_str = true; + int written = 0; + for (int i = 0; i < n; i++) { + if (fn[0] == 'c' && a[i].k == HV_NIL) { + continue; + } + size_t sl = 0; + const char *s = hv_fmt(st, &a[i], &sl); + if (!s) { + return HV_UNKNOWN; + } + bool is_str = a[i].k == HV_STR; + /* fmt.Sprint spaces two operands neither of which is a string; cat always */ + if (written && (fn[0] == 'c' || (!is_str && !prev_str))) { + hs_append(st, &out, " ", SKIP_ONE); + } + hs_append(st, &out, s, sl); + prev_str = is_str; + written++; + } + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + if (strcmp(fn, "quote") == 0 || strcmp(fn, "squote") == 0) { + hsink_t out = {0}; + int written = 0; + for (int i = 0; i < n; i++) { + hv_t s; + if (a[i].k == HV_NIL) { + continue; + } + if (!hv_strval(st, &a[i], &s)) { + return HV_UNKNOWN; + } + const char *q = fn[0] == 'q' ? hg_quote(st, s.s, s.sn, '"') + : cbm_arena_sprintf(st->a, "'%.*s'", (int)s.sn, s.s); + if (!q) { + return HV_UNKNOWN; + } + if (written++) { + hs_append(st, &out, " ", SKIP_ONE); + } + hs_append(st, &out, q, strlen(q)); + } + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + if (strcmp(fn, "toString") == 0 && n == SKIP_ONE) { + hv_t s; + return hv_strval(st, &a[0], &s) ? s : HV_UNKNOWN; + } + if ((strcmp(fn, "int") == 0 || strcmp(fn, "int64") == 0 || strcmp(fn, "toInt") == 0) && + n == SKIP_ONE) { + int64_t i = 0; + return hv_to_int(&a[0], &i) ? hv_int(i) : HV_UNKNOWN; + } + if (strcmp(fn, "float64") == 0 && n == SKIP_ONE) { + int64_t i = 0; + return a[0].k == HV_FLOAT ? a[0] : hv_to_int(&a[0], &i) ? hv_float((double)i) : HV_UNKNOWN; + } + if (strcmp(fn, "len") == 0 && n == SKIP_ONE) { + int l = hv_len(&a[0]); + return l >= 0 ? hv_int(l) : HV_UNKNOWN; + } + if (strcmp(fn, "dict") == 0) { + hv_map_t *m = cbm_arena_calloc(st->a, sizeof(*m)); + int pairs = (n + SKIP_ONE) / PAIR_LEN; + if (!m) { + return HV_UNKNOWN; + } + m->keys = pairs ? cbm_arena_alloc(st->a, (size_t)pairs * sizeof(*m->keys)) : NULL; + m->vals = pairs ? cbm_arena_alloc(st->a, (size_t)pairs * sizeof(*m->vals)) : NULL; + if (pairs && (!m->keys || !m->vals)) { + return HV_UNKNOWN; + } + for (int i = 0; i + SKIP_ONE <= n; i += PAIR_LEN) { + hv_t k; + if (!hv_strval(st, &a[i], &k)) { + return HV_UNKNOWN; + } + const char *key = cbm_arena_strndup(st->a, k.s, k.sn); + hv_t val = i + SKIP_ONE < n ? a[i + SKIP_ONE] : hv_str("", 0); + int at = m->n; + for (int j = 0; j < m->n; j++) { + if (strcmp(m->keys[j], key) == 0) { + at = j; + } + } + m->keys[at] = key; + m->vals[at] = val; + m->n += at == m->n; + } + return (hv_t){.k = HV_MAP, .m = m}; + } + if (strcmp(fn, "list") == 0) { + return hv_mklist(st, a, n); + } + if (strcmp(fn, "get") == 0 && n == PAIR_LEN) { + if (!hv_is_map(&a[0]) || a[1].k != HV_STR) { + return HV_UNKNOWN; + } + hv_t v = he_field(st, &a[0], a[1].s, a[1].sn); + return v.k == HV_NIL ? hv_str("", 0) : v; + } + if (strcmp(fn, "hasKey") == 0 && n == PAIR_LEN) { + if (!hv_is_map(&a[0]) || a[1].k != HV_STR) { + return HV_UNKNOWN; + } + if (a[0].k == HV_MAP) { + for (int i = 0; i < a[0].m->n; i++) { + if (strlen(a[0].m->keys[i]) == a[1].sn && + memcmp(a[0].m->keys[i], a[1].s, a[1].sn) == 0) { + return hv_bool(true); + } + } + return hv_bool(false); + } + hst_record(st, hst_path(st, a[0].path, a[1].s, a[1].sn)); + return hv_bool(yyjson_mut_obj_getn(a[0].j, a[1].s, a[1].sn) != NULL); + } + if (strcmp(fn, "index") == 0 && n >= SKIP_ONE) { + hv_t v = a[0]; + for (int i = SKIP_ONE; i < n && v.k != HV_UNK; i++) { + v = he_index1(st, &v, &a[i]); + } + return v; + } + if ((strcmp(fn, "first") == 0 || strcmp(fn, "last") == 0) && n == SKIP_ONE) { + hv_t *items = NULL; + int c = hv_items(st, &a[0], &items); + if (c < 0) { + return HV_UNKNOWN; + } + return c == 0 ? hv_nil() : items[fn[0] == 'f' ? 0 : c - SKIP_ONE]; + } + if (strcmp(fn, "join") == 0 && n == PAIR_LEN) { + hv_t *items = NULL; + int c = hv_items(st, &a[1], &items); + hv_t sep; + if (c < 0 || !hv_strval(st, &a[0], &sep)) { + return HV_UNKNOWN; + } + hsink_t out = {0}; + for (int i = 0; i < c; i++) { + hv_t s; + if (!hv_strval(st, &items[i], &s)) { + return HV_UNKNOWN; + } + if (i) { + hs_append(st, &out, sep.s, sep.sn); + } + hs_append(st, &out, s.s, s.sn); + } + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + if (strcmp(fn, "splitList") == 0 && n == PAIR_LEN && a[0].k == HV_STR && a[1].k == HV_STR && + a[0].sn > 0) { + int cap = SKIP_ONE; + for (size_t i = 0; i + a[0].sn <= a[1].sn; i++) { + cap += memcmp(a[1].s + i, a[0].s, a[0].sn) == 0; + } + hv_t *items = cbm_arena_alloc(st->a, (size_t)cap * sizeof(*items)); + if (!items) { + return HV_UNKNOWN; + } + int c = 0; + size_t from = 0; + for (size_t i = 0; i + a[0].sn <= a[1].sn;) { + if (memcmp(a[1].s + i, a[0].s, a[0].sn) == 0) { + items[c++] = hv_str(a[1].s + from, i - from); + i += a[0].sn; + from = i; + } else { + i++; + } + } + items[c++] = hv_str(a[1].s + from, a[1].sn - from); + return hv_mklist(st, items, c); + } + if ((strcmp(fn, "nindent") == 0 || strcmp(fn, "indent") == 0) && n == PAIR_LEN) { + int64_t w = 0; + hv_t s; + if (!hv_to_int(&a[0], &w) || w < 0 || !hv_strval(st, &a[1], &s)) { + return HV_UNKNOWN; + } + /* every line gets w spaces: a width whose output cannot fit stops here */ + size_t lines = SKIP_ONE; + for (size_t i = 0; i < s.sn; i++) { + lines += s.s[i] == '\n'; + } + if ((uint64_t)w > HELM_MAX_OUTPUT / lines) { + hst_trip(st, "output"); + return HV_UNKNOWN; + } + hsink_t out = {0}; + if (fn[0] == 'n') { + hs_append(st, &out, "\n", SKIP_ONE); + } + for (int64_t i = 0; i < w && !out.unk; i++) { + hs_append(st, &out, " ", SKIP_ONE); + } + for (size_t i = 0; i < s.sn && !out.unk; i++) { + hs_append(st, &out, s.s + i, SKIP_ONE); + for (int64_t k = 0; s.s[i] == '\n' && k < w && !out.unk; k++) { + hs_append(st, &out, " ", SKIP_ONE); + } + } + return out.unk ? HV_UNKNOWN : hv_str(hs_str(st, &out), out.n); + } + if ((strcmp(fn, "kindIs") == 0 && n == PAIR_LEN) || (strcmp(fn, "kindOf") == 0 && n == 1)) { + const hv_t *v = &a[n - SKIP_ONE]; + const char *kind = v->k == HV_NIL ? "invalid" + : v->k == HV_BOOL ? "bool" + : v->k == HV_INT ? (v->path ? "int64" : "int") + : v->k == HV_FLOAT ? "float64" + : v->k == HV_STR ? "string" + : hv_is_map(v) ? "map" + : hv_is_list(v) ? "slice" + : NULL; + if (!kind) { + return HV_UNKNOWN; + } + if (fn[4] == 'O') { + return hv_str(kind, strlen(kind)); + } + return a[0].k == HV_STR + ? hv_bool(strlen(kind) == a[0].sn && memcmp(kind, a[0].s, a[0].sn) == 0) + : HV_UNKNOWN; + } + if (strcmp(fn, "dig") == 0 && n >= 3) { + hv_t v = a[n - SKIP_ONE]; + for (int i = 0; i < n - PAIR_LEN; i++) { + if (!hv_is_map(&v) || a[i].k != HV_STR) { + return HV_UNKNOWN; + } + v = he_field(st, &v, a[i].s, a[i].sn); + if (v.k == HV_NIL) { + return a[n - PAIR_LEN]; + } + } + return v; + } + if (strcmp(fn, "trim") == 0 || strcmp(fn, "trimPrefix") == 0 || strcmp(fn, "trimSuffix") == 0 || + strcmp(fn, "trimAll") == 0 || strcmp(fn, "lower") == 0 || strcmp(fn, "upper") == 0 || + strcmp(fn, "replace") == 0 || strcmp(fn, "trunc") == 0 || strcmp(fn, "contains") == 0 || + strcmp(fn, "hasPrefix") == 0 || strcmp(fn, "hasSuffix") == 0) { + return he_strfn(st, fn, a, n); + } + (void)fr; + return HV_UNKNOWN; /* not modelled (toYaml, lookup, regex, semver, merge, ...) */ +} + +/* A function call node -- `fn args...` or a bare `fn` -- with `piped` (may + * be NULL) as its last argument. and / or evaluate lazily. */ +static hv_t he_call(hst_t *st, hframe_t *fr, TSNode call, const hv_t *piped) { + TSNode fnode = hn_is(call, "identifier") ? call : hn_field(call, "function"); + if (ts_node_is_null(fnode)) { + return HV_UNKNOWN; + } + size_t fl = 0; + const char *ft = hn_text(fr, fnode, &fl); + char fn[CBM_SZ_64]; + if (fl >= sizeof(fn)) { + return HV_UNKNOWN; + } + memcpy(fn, ft, fl); + fn[fl] = '\0'; + 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 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; + for (uint32_t i = SKIP_ONE; i < na; i++) { + (void)he_eval(st, fr, ts_node_named_child(args, i)); /* what it runs */ + } + if (!hv_is_map(&target) || !hst_poison(st, &target, fn[0] == 'm')) { + hst_taint(st, fn); + } + return HV_UNKNOWN; /* the changed map */ + } + bool lazy = strcmp(fn, "and") == 0 || strcmp(fn, "or") == 0; + if (lazy) { + hv_t v = HV_UNKNOWN; + uint32_t total = na + (piped ? SKIP_ONE : 0); + for (uint32_t i = 0; i < total; i++) { + v = i < na ? he_eval(st, fr, ts_node_named_child(args, i)) : *piped; + if (v.k == HV_UNK || hv_poisoned(st, &v)) { + /* its truth is not known (a poisoned map's either): whether + * the operands after it run is not, nor a change they make */ + for (uint32_t j = i + SKIP_ONE; j < na; j++) { + if (he_may_mutate(st, fr->src, ts_node_named_child(args, j), st->nesting)) { + hst_taint(st, fn); + break; + } + } + return HV_UNKNOWN; + } + bool t = hv_truth(&v); + if ((fn[0] == 'a' && !t) || (fn[0] == 'o' && t)) { + return v; + } + } + return total ? v : HV_UNKNOWN; + } + int n = (int)na + (piped ? SKIP_ONE : 0); + hv_t *a = n ? cbm_arena_alloc(st->a, (size_t)n * sizeof(*a)) : NULL; + if (n && !a) { + return HV_UNKNOWN; + } + for (uint32_t i = 0; i < na; i++) { + a[i] = he_eval(st, fr, ts_node_named_child(args, i)); + } + if (piped) { + a[na] = *piped; + } + return he_apply_fn(st, fr, fn, a, n); +} + +static hv_t he_literal_int(hframe_t *fr, TSNode n) { + size_t l = 0; + const char *t = hn_text(fr, n, &l); + char buf[CBM_SZ_64]; + size_t k = 0; + for (size_t i = 0; i < l && k + SKIP_ONE < sizeof(buf); i++) { + if (t[i] != '_') { + buf[k++] = t[i]; + } + } + buf[k] = '\0'; + int base = 10; + const char *p = buf; + if (p[0] == '0' && (p[1] == 'o' || p[1] == 'O')) { + base = 8; + p += PAIR_LEN; + } else if (p[0] == '0' && (p[1] == 'b' || p[1] == 'B')) { + base = 2; + p += PAIR_LEN; + } else if (p[0] == '0' && (p[1] == 'x' || p[1] == 'X')) { + base = 16; + p += PAIR_LEN; + } else if (p[0] == '0' && p[1]) { + base = 8; + p++; + } + char *end = NULL; + long long v = strtoll(p, &end, base); + return end && !*end ? hv_int(v) : HV_UNKNOWN; +} + +static hv_t he_eval(hst_t *st, hframe_t *fr, TSNode n) { + if (ts_node_is_null(n) || ts_node_has_error(n) || ts_node_is_missing(n) || !hst_step(st)) { + return HV_UNKNOWN; + } + const char *t = ts_node_type(n); + size_t l = 0; + if (strcmp(t, "dot") == 0) { + return fr->dot; + } + if (strcmp(t, "field") == 0) { + TSNode nm = hn_field(n, "name"); + if (ts_node_is_null(nm)) { + return HV_UNKNOWN; + } + const char *name = hn_text(fr, nm, &l); + return he_field(st, &fr->dot, name, l); + } + if (strcmp(t, "variable") == 0) { + const char *name = hn_var_name(fr, n, &l); + hvar_t *v = hst_var(st, fr, name, l); + return v ? v->v : HV_UNKNOWN; + } + if (strcmp(t, "selector_expression") == 0) { + TSNode op = hn_field(n, "operand"); + TSNode fd = hn_field(n, "field"); + if (ts_node_is_null(fd)) { + return HV_UNKNOWN; + } + hv_t base = he_eval(st, fr, op); + if (base.k == HV_UNK) { + return HV_UNKNOWN; + } + const char *name = hn_text(fr, fd, &l); + return he_field(st, &base, name, l); + } + if (strcmp(t, "chained_pipeline") == 0) { + uint32_t c = ts_node_named_child_count(n); + if (c == 0) { + return HV_UNKNOWN; + } + hv_t v = he_eval(st, fr, ts_node_named_child(n, 0)); + for (uint32_t i = SKIP_ONE; i < c && v.k != HV_UNK; i++) { + TSNode stage = ts_node_named_child(n, i); + if (!hn_is(stage, "function_call") && !hn_is(stage, "identifier")) { + return HV_UNKNOWN; + } + v = he_call(st, fr, stage, &v); + } + return v; + } + if (strcmp(t, "function_call") == 0 || strcmp(t, "identifier") == 0) { + return he_call(st, fr, n, NULL); + } + if (strcmp(t, "parenthesized_pipeline") == 0) { + return ts_node_named_child_count(n) == SKIP_ONE ? he_eval(st, fr, ts_node_named_child(n, 0)) + : HV_UNKNOWN; + } + if (strcmp(t, "interpreted_string_literal") == 0 || strcmp(t, "raw_string_literal") == 0) { + const char *s = hn_text(fr, n, &l); + char *out = cbm_arena_alloc(st->a, l * 4 + SKIP_ONE); + size_t k = out ? hg_unquote(s, l, out) : (size_t)-1; + if (k == (size_t)-1) { + return HV_UNKNOWN; + } + out[k] = '\0'; + return hv_str(out, k); + } + if (strcmp(t, "int_literal") == 0) { + return he_literal_int(fr, n); + } + if (strcmp(t, "float_literal") == 0) { + const char *s = hn_text(fr, n, &l); + char *buf = cbm_arena_strndup(st->a, s, l); + return buf ? hv_float(strtod(buf, NULL)) : HV_UNKNOWN; + } + if (strcmp(t, "true") == 0 || strcmp(t, "false") == 0) { + return hv_bool(t[0] == 't'); + } + if (strcmp(t, "nil") == 0) { + return hv_nil(); + } + if (strcmp(t, "variable_definition") == 0 || strcmp(t, "assignment") == 0) { + TSNode var = hn_field(n, "variable"); + hv_t v = he_eval(st, fr, hn_field(n, "value")); + const char *name = ts_node_is_null(var) ? NULL : hn_var_name(fr, var, &l); + if (!name) { + return HV_UNKNOWN; + } + if (t[0] == 'v') { + hst_var_push(st, name, l, v); + } else { + hvar_t *cur = hst_var(st, fr, name, l); + if (!cur) { + return HV_UNKNOWN; + } + cur->v = v; + } + return v; + } + return HV_UNKNOWN; /* method calls, rune / imaginary literals, ... */ +} + +/* ── Execution ────────────────────────────────────────────────────── */ + +static void he_item(hst_t *st, hframe_t *fr, TSNode c); + +/* The named children of `parent` in field `field` (NULL: every one). */ +static void he_items(hst_t *st, hframe_t *fr, TSNode parent, const char *field) { + uint32_t cc = ts_node_child_count(parent); + for (uint32_t i = 0; i < cc && !st->brk && !st->cont && hst_step(st); i++) { + TSNode c = ts_node_child(parent, i); + if (!ts_node_is_named(c)) { + continue; + } + const char *f = ts_node_field_name_for_child(parent, i); + if (field ? !(f && strcmp(f, field) == 0) : f != NULL) { + continue; + } + he_item(st, fr, c); + } +} + +/* 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; 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, "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) { + v->v = HV_UNKNOWN; + } + } 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"); + for (TSNode p = c; !own && !ts_node_eq(p, n) && !ts_node_is_null(p);) { + p = ts_node_parent(p); + own = hn_is(p, "range_action"); + } + if (!own) { + leave |= t[0] == 'b' ? HL_BREAK : HL_CONTINUE; + } + } + if (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 leave; +} + +/* The children of n but what was run -- its collection, its conditions up + * to `ran` (the one run; an else if / else with after it would be) -- each: + * 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, TSNode ran, const char *only, + bool in_range) { + int leave = HL_NONE; + bool mutates = false; + bool after = false; /* past `ran` */ + 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); + bool cond = f && strcmp(f, "condition") == 0; + bool was_run = cond && !after; + after = after || (cond && ts_node_eq(c, ran)); + if (!ts_node_is_named(c) || hn_is(c, "range_variable_definition") || was_run || + (f && strcmp(f, "range") == 0) || (only && !(f && strcmp(f, only) == 0))) { + continue; + } + leave |= he_scan_skipped(st, fr, c, in_range); + mutates = mutates || he_may_mutate(st, fr->src, c, st->nesting); + } + if (mutates) { + hst_taint(st, "skipped"); + } + return leave; +} + +/* The bodies of if / with / range n, not run because its condition `ran` or + * its collection (a null `ran`) is UNKNOWN (that one was run): their output, + * the variables they -- and the conditions after `ran` -- would assign and a + * change they would make are UNKNOWN. A break / continue in them may or may + * not leave the loop around: what the rest of that loop's body would assign + * or change is UNKNOWN too, and a break leaves it now (its later iterations + * are not known), a continue this iteration. */ +static void he_skipped(hst_t *st, hframe_t *fr, TSNode n, TSNode ran) { + hst_out_unknown(st, fr); + bool range = hn_is(n, "range_action"); + int leave = he_spoil_parts(st, fr, n, ran, NULL, range); + if (leave == HL_NONE) { + return; + } + TSNode loop = ts_node_parent(n); + for (int depth = 0; + !ts_node_is_null(loop) && !hn_is(loop, "range_action") && depth < HELM_MAX_NESTING; + depth++) { + loop = ts_node_parent(loop); + } + if (hn_is(loop, "range_action")) { + (void)he_spoil_parts(st, fr, loop, (TSNode){0}, "body", true); + } + if (leave & HL_BREAK) { + st->brk = true; + } else { + st->cont = true; + } +} + +/* if / else if / else, and with / else with: the first branch whose + * condition holds runs; an UNKNOWN condition runs none. */ +static void he_branches(hst_t *st, hframe_t *fr, TSNode n, bool with) { + uint32_t cc = ts_node_child_count(n); + bool taken = false; + bool stop = false; + bool active = false; + int mark = st->nvars; + hframe_t sub = *fr; + for (uint32_t i = 0; i < cc && !st->brk && !st->cont; i++) { + TSNode c = ts_node_child(n, i); + if (!ts_node_is_named(c)) { + continue; + } + const char *f = ts_node_field_name_for_child(n, i); + if (!f) { + continue; + } + if (strcmp(f, "condition") == 0) { + if (taken || stop) { + active = false; + continue; + } + hv_t v = he_eval(st, fr, c); + if (v.k == HV_UNK || hv_poisoned(st, &v)) { + stop = true; + active = false; + he_skipped(st, fr, n, c); /* which branch renders is unknown */ + continue; + } + active = hv_truth(&v); + taken = active; + sub = *fr; + if (with && active) { + sub.dot = v; + } + } else if (strcmp(f, "consequence") == 0 || strcmp(f, "option") == 0) { + if (active) { + he_item(st, &sub, c); + } + } else if (strcmp(f, "alternative") == 0) { + if (!taken && !stop) { + he_item(st, fr, c); + } + } + } + st->nvars = mark; +} + +static void he_range(hst_t *st, hframe_t *fr, TSNode n) { + TSNode def = {0}; + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + if (hn_is(ts_node_named_child(n, i), "range_variable_definition")) { + def = ts_node_named_child(n, i); + } + } + TSNode expr = ts_node_is_null(def) ? hn_field(n, "range") : hn_field(def, "range"); + TSNode ivar = ts_node_is_null(def) ? (TSNode){0} : hn_field(def, "index"); + TSNode evar = ts_node_is_null(def) ? (TSNode){0} : hn_field(def, "element"); + if (!ts_node_is_null(ivar) && ts_node_is_null(evar)) { + evar = ivar; /* range $x := ...: the element */ + ivar = (TSNode){0}; + } + hv_t coll = he_eval(st, fr, expr); + if (coll.k == HV_UNK || hv_poisoned(st, &coll)) { + he_skipped(st, fr, n, (TSNode){0}); + return; + } + hv_t *items = NULL; + const char **keys = NULL; + int64_t count = 0; + bool is_map = hv_is_map(&coll); + if (is_map) { + count = hv_keys(st, &coll, &keys); + } else if (coll.k == HV_INT) { + count = coll.i > 0 ? coll.i : 0; + } else { + count = hv_items(st, &coll, &items); + } + if (count < 0) { + hst_out_unknown(st, fr); + return; + } + size_t il = 0; + size_t el = 0; + const char *iname = ts_node_is_null(ivar) ? NULL : hn_var_name(fr, ivar, &il); + const char *ename = ts_node_is_null(evar) ? NULL : hn_var_name(fr, evar, &el); + for (int64_t k = 0; k < count && hst_step(st); k++) { + int mark = st->nvars; + hv_t elem; + hv_t index; + if (is_map) { + elem = he_field(st, &coll, keys[k], strlen(keys[k])); + index = hv_str(keys[k], strlen(keys[k])); + } else if (coll.k == HV_INT) { + elem = hv_int(k); + index = hv_int(k); + } else { + elem = items[k]; + index = hv_int(k); + } + if (iname) { + hst_var_push(st, iname, il, index); + } + if (ename) { + hst_var_push(st, ename, el, elem); + } + hframe_t sub = *fr; + sub.dot = elem; + he_items(st, &sub, n, "body"); + st->nvars = mark; + st->cont = false; + if (st->brk) { + st->brk = false; + break; + } + } + if (count == 0) { + he_items(st, fr, n, "alternative"); + } +} + +/* `template "name" arg` / a block: the define rendered into the output. */ +static void he_template(hst_t *st, hframe_t *fr, TSNode n) { + TSNode nm = hn_field(n, "name"); + TSNode arg = hn_field(n, "argument"); + size_t l = 0; + const char *s = ts_node_is_null(nm) ? NULL : hn_text(fr, nm, &l); + char *name = s ? cbm_arena_alloc(st->a, l * 4 + SKIP_ONE) : NULL; + size_t k = name ? hg_unquote(s, l, name) : (size_t)-1; + if (k == (size_t)-1) { + hst_out_unknown(st, fr); + return; + } + name[k] = '\0'; + 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 of maps it may make is missed */ + if (he_define_may_mutate(st, name, st->nesting)) { + hst_taint(st, "template"); + } + return; + } + if (fr->out) { + if (he_render_define(st, name, a, fr->out).k == HV_UNK) { + fr->out->unk = true; + } + return; + } + /* at the top level the define's text is output that probes may read */ + hsink_t out = {0}; + if (he_render_define(st, name, a, &out).k == HV_UNK || out.unk) { + hst_out_unknown(st, fr); + return; + } + hst_out(st, fr, out.buf ? out.buf : "", out.n); +} + +static void he_item(hst_t *st, hframe_t *fr, TSNode c) { + const char *t = ts_node_type(c); + if (strcmp(t, "text") == 0) { + hst_text(st, fr, c); + } else if (strcmp(t, "comment") == 0 || strcmp(t, "define_action") == 0) { + return; + } else if (strcmp(t, "if_action") == 0) { + he_branches(st, fr, c, false); + } else if (strcmp(t, "with_action") == 0) { + he_branches(st, fr, c, true); + } else if (strcmp(t, "range_action") == 0) { + he_range(st, fr, c); + } else if (strcmp(t, "template_action") == 0 || strcmp(t, "block_action") == 0) { + he_template(st, fr, c); + } else if (strcmp(t, "break_action") == 0) { + st->brk = true; + } else if (strcmp(t, "continue_action") == 0) { + st->cont = true; + } else if (strcmp(t, "variable_definition") == 0 || strcmp(t, "assignment") == 0) { + (void)he_eval(st, fr, c); + } else { + hv_t v = he_eval(st, fr, c); + if (v.k == HV_UNK) { + hst_out_unknown(st, fr); + } else { + hst_out_value(st, fr, &v); + } + } +} + +/* 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) { + /* 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; + } + if (ts_node_has_error(d->node)) { + hst_taint(st, "parse"); /* a form the grammar rejects may hide a change */ + } + int mark = st->nvars; + hframe_t sub = {.src = d->file->src, .dot = arg, .var_base = st->nvars, .out = into}; + hst_var_push(st, "", 0, arg); + bool brk = st->brk; + bool cont = st->cont; + st->brk = false; + st->cont = false; + he_items(st, &sub, d->node, "body"); + st->brk = brk; + st->cont = cont; + st->nvars = mark; + st->nesting--; + return into->unk ? HV_UNKNOWN : hv_nil(); +} + +/* ── Entry ────────────────────────────────────────────────────────── */ + +static hv_map_t *hv_struct(hst_t *st, int n) { + hv_map_t *m = cbm_arena_calloc(st->a, sizeof(*m)); + if (m) { + m->keys = cbm_arena_alloc(st->a, (size_t)n * sizeof(*m->keys)); + m->vals = cbm_arena_alloc(st->a, (size_t)n * sizeof(*m->vals)); + m->strict = true; + if (!m->keys || !m->vals) { + return NULL; + } + } + return m; +} + +static void hv_struct_set(hv_map_t *m, const char *k, hv_t v) { + m->keys[m->n] = k; + m->vals[m->n++] = v; +} + +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) { + for (int i = 0; i < nprobes; i++) { + probes[i].n = 0; + probes[i].image = NULL; + probes[i].keys = NULL; + probes[i].cap = 0; + } + if (!src || nprobes == 0) { + return true; + } + /* 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(&work)}; + st.scratch = yyjson_mut_doc_new(cbm_json_writer_allocator()); + st.parser = ts_parser_new(); + if (!st.scratch || !st.parser || + !ts_parser_set_language(st.parser, cbm_ts_language(CBM_LANG_GOTEMPLATE))) { + if (st.parser) { + ts_parser_delete(st.parser); + } + 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(&work, (size_t)nl * sizeof(*lines)); + TSTree *tree = lines ? ts_parser_parse_string(st.parser, NULL, src, (uint32_t)len) : NULL; + if (tree) { + /* a template that does not parse may hide a change (Go may take a + * form the grammar rejects): no image of it is known */ + if (ts_node_has_error(ts_tree_root_node(tree))) { + hst_taint(&st, "parse"); + } + int k = 0; + lines[k++] = 0; + for (size_t i = 0; i < len; i++) { + if (src[i] == '\n' && k < nl) { + lines[k++] = (uint32_t)(i + SKIP_ONE); + } + } + st.lines = lines; + st.nlines = k; + 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 *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)); + hv_struct_set(ch, "AppVersion", hv_cstr(chart ? chart->app_version : NULL)); + hv_struct_set(rl, "Service", hv_cstr("Helm")); + 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); + he_items(&st, &fr, ts_tree_root_node(tree), NULL); + hst_capture_end(&st); + } + ts_tree_delete(tree); + } + for (int i = 0; i < st.ntrees; i++) { + ts_tree_delete(st.trees[i]); + } + ts_parser_delete(st.parser); + yyjson_mut_doc_free(st.scratch); + 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 */ + for (int i = 0; i < nprobes; i++) { + for (int c = 0; c < probes[i].n; c++) { + probes[i].image[c] = NULL; + probes[i].keys[c] = NULL; + } + } + cbm_log_warn("helm.render.budget", "file", rel ? rel : "", "reason", + 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 new file mode 100644 index 0000000000..e2136b5e81 --- /dev/null +++ b/src/pipeline/helm_template.h @@ -0,0 +1,93 @@ +/* + * helm_template.h -- Helm values and the rendering of a chart template's + * image references (pass_k8s.c). Internal to the pipeline. + * + * Values are JSON (yyjson mutable values), as Helm holds them after its + * YAML -> JSON step: go-yaml v2 scalars (YAML 1.1 booleans), every number a + * float64. A template renders with Go text/template semantics and the part + * of Sprig image expressions use; anything else -- a function not modelled, + * .Release / .Capabilities / lookup, a missing helper, a parse error on the + * way -- is UNKNOWN, and an image that reads an UNKNOWN is no image. Nothing + * is guessed: a value is what Helm would render, or none. + */ +#ifndef CBM_HELM_TEMPLATE_H +#define CBM_HELM_TEMPLATE_H + +#include "foundation/arena.h" + +#include "tree_sitter/api.h" +#include + +#include +#include + +/* The first document of YAML text src as a value of doc (Helm's scalar + * types); NULL when it does not parse. An empty document is an empty map. */ +yyjson_mut_val *cbm_helm_values_parse(yyjson_mut_doc *doc, const char *src, size_t len); + +/* The YAML node `node` of text `src` (already parsed, e.g. a HelmRelease's + * spec.values) as a value of doc; NULL when it holds an unknown part. */ +yyjson_mut_val *cbm_helm_values_node(yyjson_mut_doc *doc, const char *src, TSNode node); + +/* Helm's coalesce: a deep copy of `base` with `over` merged in -- maps merge + * key by key, any other value replaces, a null deletes. Either may be NULL. */ +yyjson_mut_val *cbm_helm_values_coalesce(yyjson_mut_doc *doc, yyjson_mut_val *base, + yyjson_mut_val *over); + +/* Helm --set NAME=VALUE into map `root`: NAME's `.` separates keys (`\.` + * escapes one), `[n]` indexes a list; VALUE is typed like --set (int, bool, + * null, else string) unless `as_string`. False when NAME is malformed. */ +bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *name, + const char *value, bool as_string); + +/* A values scalar as a template prints it (a number like Go's %v: 1.10 -> + * 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 -- 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 + * the charts below it share one namespace; for one name the define of the + * file nearest the family root wins (Helm loads deeper files first), then + * the alphabetically first path. */ +typedef struct cbm_helm_defines cbm_helm_defines_t; + +cbm_helm_defines_t *cbm_helm_defines_new(void); +/* Register the defines of template file `rel` (text src, len). The text is + * copied; its parse lives as long as the set. */ +void cbm_helm_defines_add(cbm_helm_defines_t *d, const char *rel, const char *src, size_t len); +void cbm_helm_defines_free(cbm_helm_defines_t *d); + +/* A probe: the `image:` key on a template line. Each time rendering passes + * that line it captures the scalar written after the key. */ +typedef struct { + int line; /* 1-based line of the key in the template */ + int n; /* captures */ + int cap; /* room in image / keys (the render's own) */ + const char **image; /* the rendered scalar, YAML quoting removed; NULL: UNKNOWN */ + const char **keys; /* JSON array of the values paths it read; NULL: none */ +} cbm_helm_probe_t; + +/* 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, 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); + +#endif /* CBM_HELM_TEMPLATE_H */ diff --git a/src/pipeline/pass_ci.c b/src/pipeline/pass_ci.c index 2fc9e09128..486b1a5b26 100644 --- a/src/pipeline/pass_ci.c +++ b/src/pipeline/pass_ci.c @@ -25,6 +25,7 @@ */ #include "foundation/constants.h" #include "foundation/compat.h" +#include "foundation/str_util.h" /* cbm_json_escape */ #include "pipeline/pipeline_internal.h" #include "cbm.h" #include "helpers.h" /* cbm_env_address */ @@ -783,9 +784,33 @@ static const char *ci_env_lookup(const ci_env_t *env, const char *name, size_t n return NULL; } +/* JSON array `list` (NULL: empty) with identifier `name` appended once. */ +static const char *ci_names_add(CBMArena *a, const char *list, const char *name, size_t nl) { + if (!list) { + return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name); + } + char quoted[CBM_SZ_256]; + snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name); + if (strstr(list, quoted)) { + return list; + } + return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted); +} + +static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env, + const char **dflt_of, const char **ovr_of); + /* `s` with ${X} / ${X:-d} / ${X-d} / $X expanded from env; NULL when a * variable has no value (and no default). */ static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) { + return ci_interp_marks(a, s, env, NULL, NULL); +} + +/* ci_interp; `dflt_of` (may be NULL) gains, as a JSON array, the variables + * whose inline default was used, `ovr_of` those with an inline default that + * env set instead. */ +static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env, + const char **dflt_of, const char **ovr_of) { if (!s || !strchr(s, '$')) { return s; } @@ -839,6 +864,11 @@ static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) { } use = dflt; ul = dl; + if (dflt_of) { + *dflt_of = ci_names_add(a, *dflt_of, name, nl); + } + } else if (dflt && ovr_of) { + *ovr_of = ci_names_add(a, *ovr_of, name, nl); } if (len + ul + SKIP_ONE > cap) { return NULL; @@ -1054,7 +1084,53 @@ static int ci_compose_env(const ci_t *c, TSNode svc, const ci_env_t *dotenv, con return k; } +/* A compose build's `args` (a mapping, or a list of "K=V" items), their + * values interpolated, as a JSON object; NULL: none. An argument without a + * value (the environment's), with an unknown one or with a secret (by name or + * value -- never kept in the graph, never an image) is left out. */ +static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_env_t *env) { + TSNode args = ts_node_is_null(build) ? build : ci_get(c, build, "args"); + if (ts_node_is_null(args)) { + return NULL; + } + int n = 0; + TSNode *items = ci_items(c, args, &n); + ci_pair_t *pairs = items ? NULL : ci_pairs(c, args, &n); + const char *json = NULL; + for (int i = 0; i < n; i++) { + const char *key = NULL; + const char *raw = NULL; + if (items) { + const char *s = ci_scalar(c, items[i], NULL); + const char *eq = s ? strchr(s, '=') : NULL; + key = eq ? cbm_arena_strndup(c->a, s, (size_t)(eq - s)) : NULL; + raw = eq ? eq + SKIP_ONE : NULL; + } else { + key = pairs[i].key; + raw = ts_node_is_null(pairs[i].value) ? NULL : ci_scalar(c, pairs[i].value, NULL); + } + const char *value = raw ? ci_interp(c->a, raw, env) : NULL; + if (!key || !key[0] || !value || cbm_is_secret_binding(key, value)) { + continue; + } + /* JSON escaping writes at most 6 bytes a byte: nothing is cut */ + int kn = (int)(strlen(key) * 6 + SKIP_ONE); + int vn = (int)(strlen(value) * 6 + SKIP_ONE); + char *ek = cbm_arena_alloc(c->a, (size_t)kn); + char *ev = cbm_arena_alloc(c->a, (size_t)vn); + if (!ek || !ev) { + return NULL; + } + cbm_json_escape(ek, kn, key); + cbm_json_escape(ev, vn, value); + json = cbm_arena_sprintf(c->a, "%s%s\"%s\":\"%s\"", json ? json : "{", json ? "," : "", ek, + ev); + } + return json ? cbm_arena_sprintf(c->a, "%s}", json) : NULL; +} + static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env_t *env) { + static const ci_env_t no_env = {0}; int ns = 0; ci_pair_t *svcs = ci_pairs(c, ci_get(c, root, "services"), &ns); for (int i = 0; i < ns; i++) { @@ -1064,16 +1140,27 @@ static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env } int row = 0; const char *raw = ci_scalar(c, ci_get(c, svc, "image"), &row); - const char *image = raw ? ci_interp(c->a, raw, env) : NULL; - const char *df = ci_compose_dockerfile(c, ci_get(c, svc, "build"), dir, env); + const char *dflt_of = NULL; + const char *ovr_of = NULL; + const char *image = raw ? ci_interp_marks(c->a, raw, env, &dflt_of, &ovr_of) : NULL; + /* With .env replacing a default: what the defaults alone would name. */ + const char *default_image = image && ovr_of ? ci_interp(c->a, raw, &no_env) : NULL; + TSNode build = ci_get(c, svc, "build"); + const char *df = ci_compose_dockerfile(c, build, dir, env); if (c->site_fn && image && df) { - cbm_build_site_t site = { - .via = "compose", .image = image, .dockerfile = df, .line = row + SKIP_ONE}; + cbm_build_site_t site = {.via = "compose", + .image = image, + .dockerfile = df, + .build_args = ci_compose_build_args(c, build, env), + .line = row + SKIP_ONE}; c->site_fn(&site, c->ud); } if (c->service_fn) { cbm_compose_service_t s = {.name = svcs[i].key, .image = image, + .image_default_of = image ? dflt_of : NULL, + .image_override_of = image ? ovr_of : NULL, + .default_image = default_image, .dockerfile = df, .line = (int)ts_node_start_point(svc).row, .end_line = (int)ts_node_end_point(svc).row + SKIP_ONE}; diff --git a/src/pipeline/pass_docker.c b/src/pipeline/pass_docker.c index f5f3038663..5d2c2840d1 100644 --- a/src/pipeline/pass_docker.c +++ b/src/pipeline/pass_docker.c @@ -108,12 +108,31 @@ typedef struct { CBMHashTable *dirs; /* every directory holding a file (not the root) */ } dk_index_t; +/* Where a value came from: the ARGs whose default (`dflt`) or build argument + * (`ovr`) supplied part of it, as '\x1f'-separated names; NULL: none. */ +typedef struct { + const char *dflt; + const char *ovr; +} dk_prov_t; + typedef struct dk_var { const char *name; const char *value; /* NULL: declared without a value */ + bool arg; /* an ARG: a build argument can set it */ + dk_prov_t prov; struct dk_var *next; } dk_var_t; +/* An image reference of FROM / COPY --from, linked once the file is read. */ +typedef struct { + int64_t img; + const char *ref; + const char *via; + int stage; + int line; + dk_prov_t prov; +} dk_ref_t; + typedef struct { cbm_pipeline_ctx_t *ctx; CBMArena *a; @@ -131,6 +150,14 @@ typedef struct { dk_entry_t entry; /* the current stage's ENTRYPOINT */ dk_entry_t cmd; /* the current stage's CMD */ const dk_index_t *ix; + dk_ref_t *refs; /* FROM / COPY --from references, in file order */ + int nrefs; + int refs_cap; + /* A build's view (dk_build_args): its build arguments set the ARGs they + * name, and the references they change link from the Image it builds. */ + const dk_var_t *build_args; + int64_t built; /* 0: the Dockerfile's own view */ + const char *build_props; /* the build's tool / file / build_line members */ } dk_t; static bool dk_type(TSNode n, const char *t) { @@ -172,14 +199,72 @@ static const dk_var_t *dk_find(const dk_var_t *v, const char *name, size_t nl) { return NULL; } -static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value) { +static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value, bool arg, + dk_prov_t prov) { dk_var_t *v = cbm_arena_alloc(d->a, sizeof(*v)); if (v) { - *v = (dk_var_t){.name = name, .value = value, .next = *list}; + *v = (dk_var_t){.name = name, .value = value, .arg = arg, .prov = prov, .next = *list}; *list = v; } } +enum { DK_NAME_SEP = '\x1f' }; + +static bool dk_names_has(const char *list, const char *name, size_t nl) { + for (const char *p = list; p && *p;) { + size_t l = strcspn(p, "\x1f"); + if (l == nl && strncmp(p, name, nl) == 0) { + return true; + } + p += l; + p += *p ? SKIP_ONE : 0; + } + return false; +} + +/* `list` with `name` appended unless it holds it already. */ +static const char *dk_names_add(dk_t *d, const char *list, const char *name, size_t nl) { + if (dk_names_has(list, name, nl)) { + return list; + } + return list && list[0] ? cbm_arena_sprintf(d->a, "%s%c%.*s", list, DK_NAME_SEP, (int)nl, name) + : cbm_arena_strndup(d->a, name, nl); +} + +static const char *dk_names_union(dk_t *d, const char *a, const char *b) { + for (const char *p = b; p && *p;) { + size_t l = strcspn(p, "\x1f"); + a = dk_names_add(d, a, p, l); + p += l; + p += *p ? SKIP_ONE : 0; + } + return a; +} + +static void dk_prov_merge(dk_t *d, dk_prov_t *into, dk_prov_t from) { + into->dflt = dk_names_union(d, into->dflt, from.dflt); + into->ovr = dk_names_union(d, into->ovr, from.ovr); +} + +/* `,"key":["A","B"]` for a non-empty name list, else "". */ +static const char *dk_names_member(dk_t *d, const char *key, const char *list) { + if (!list || !list[0]) { + return ""; + } + const char *out = cbm_arena_sprintf(d->a, ",\"%s\":[", key); + for (const char *p = list; out && *p;) { + size_t l = strcspn(p, "\x1f"); + char esc[CBM_SZ_256]; + char name[CBM_SZ_256]; + snprintf(name, sizeof(name), "%.*s", (int)l, p); + cbm_json_escape(esc, (int)sizeof(esc), name); + out = cbm_arena_sprintf(d->a, "%s%s\"%s\"", out, p == list ? "" : ",", esc); + p += l; + p += *p ? SKIP_ONE : 0; + } + return out ? cbm_arena_sprintf(d->a, "%s]", out) : ""; +} + /* ASCII case-insensitive equality (stage names, `scratch`). */ static bool dk_ieq(const char *a, const char *b) { for (; *a && *b; a++, b++) { @@ -195,8 +280,10 @@ static bool dk_name_char(char c) { } /* `text` with $X / ${X} / ${X:-d} / ${X-d} expanded from `vars`; NULL when a - * reference has no value. */ -static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) { + * reference has no value. `prov` (may be NULL) gains the ARGs whose default + * or build argument supplied a value -- an inline `${X:-d}` default counts + * only for a declared ARG X, the one thing a build argument can set. */ +static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text, dk_prov_t *prov) { size_t cap = strlen(text) + SKIP_ONE; size_t len = 0; char *out = cbm_arena_alloc(d->a, cap); @@ -249,6 +336,11 @@ static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) { return NULL; } const char *from = val ? val : dflt; + if (prov && v && val) { + dk_prov_merge(d, prov, v->prov); + } else if (prov && v && v->arg) { + prov->dflt = dk_names_add(d, prov->dflt, name, nl); + } if (len + vl + SKIP_ONE > cap) { size_t ncap = (len + vl + SKIP_ONE) * PAIR_LEN; char *grown = cbm_arena_alloc(d->a, ncap); @@ -370,6 +462,139 @@ static int dk_stage_of(const dk_t *d, const char *ref) { return found; } +/* A path component: [a-z0-9]+ joined by `.`, `_`, `__` or `-`+. */ +static bool dk_ref_component(const char *s, size_t n) { + if (n == 0 || !isalnum((unsigned char)s[0]) || !isalnum((unsigned char)s[n - SKIP_ONE])) { + return false; + } + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (isupper(c)) { + return false; + } + if (isalnum(c)) { + continue; + } + size_t run = SKIP_ONE; + while (i + run < n && s[i + run] == c) { + run++; + } + if (!((c == '.' && run == SKIP_ONE) || (c == '_' && run <= PAIR_LEN) || c == '-')) { + return false; + } + i += run - SKIP_ONE; + } + return true; +} + +/* A registry host: DNS labels (`.`-joined), or an IPv6 literal, with an + * optional :port. */ +static bool dk_ref_domain(const char *s, size_t n) { + const char *colon = memchr(s, ':', n); + if (n && s[0] == '[') { + const char *close = memchr(s, ']', n); + colon = close && (size_t)(close - s) + SKIP_ONE < n ? close + SKIP_ONE : NULL; + if (!close) { + return false; + } + } + size_t hl = colon ? (size_t)(colon - s) : n; + if (hl == 0) { + return false; + } + if (s[0] != '[') { + for (size_t i = 0; i < hl; i++) { + unsigned char c = (unsigned char)s[i]; + if (!isalnum(c) && c != '.' && c != '-') { + return false; + } + } + } + for (size_t i = hl + SKIP_ONE; colon && i < n; i++) { + if (!isdigit((unsigned char)s[i])) { + return false; + } + } + return !colon || hl + SKIP_ONE < n; +} + +bool cbm_image_ref_valid(const char *ref) { + if (!ref || !ref[0]) { + return false; + } + size_t n = strlen(ref); + const char *at = strchr(ref, '@'); + if (at) { + const char *colon = strchr(at, ':'); + if (!colon || colon == at + SKIP_ONE || strlen(colon + SKIP_ONE) < CBM_SZ_32) { + return false; + } + for (const char *p = at + SKIP_ONE; p < colon; p++) { + if (!isalnum((unsigned char)*p) && !strchr("+.-_", *p)) { + return false; + } + } + for (const char *p = colon + SKIP_ONE; *p; p++) { + if (!isxdigit((unsigned char)*p)) { + return false; + } + } + n = (size_t)(at - ref); + } + /* the tag: after the last ':' that follows the last '/' */ + size_t name_end = n; + for (size_t i = n; i > 0; i--) { + if (ref[i - SKIP_ONE] == '/') { + break; + } + if (ref[i - SKIP_ONE] == ':') { + name_end = i - SKIP_ONE; + break; + } + } + if (name_end < n) { + size_t tl = n - name_end - SKIP_ONE; + const char *tag = ref + name_end + SKIP_ONE; + if (tl == 0 || tl > CBM_SZ_128 || !(isalnum((unsigned char)tag[0]) || tag[0] == '_')) { + return false; + } + for (size_t i = 0; i < tl; i++) { + if (!isalnum((unsigned char)tag[i]) && !strchr("_.-", tag[i])) { + return false; + } + } + } + const char *p = ref; + const char *end = ref + name_end; + const char *slash = memchr(p, '/', (size_t)(end - p)); + if (slash) { + size_t fl = (size_t)(slash - p); + bool host = memchr(p, '.', fl) || memchr(p, ':', fl) || + (fl == strlen("localhost") && memcmp(p, "localhost", fl) == 0); + if (host) { + if (!dk_ref_domain(p, fl)) { + return false; + } + p = slash + SKIP_ONE; + } + } + if (p >= end) { + return false; + } + while (p < end) { + const char *s = memchr(p, '/', (size_t)(end - p)); + size_t cl = s ? (size_t)(s - p) : (size_t)(end - p); + if (!dk_ref_component(p, cl)) { + return false; + } + p += cl + (s ? SKIP_ONE : 0); + if (s && p >= end) { + return false; + } + } + return true; +} + int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref) { char repo[CBM_SZ_512]; char reg[CBM_SZ_512]; @@ -438,19 +663,101 @@ int64_t cbm_pipeline_env_setter(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t * return id; } -/* DEPENDS_ON from the Dockerfile to the Image of resolved reference `ref`. */ -static void dk_link(dk_t *d, const char *ref, const char *via, int line) { +/* Record the Image of resolved reference `ref`, linked by dk_refs_link. */ +static void dk_link(dk_t *d, const char *ref, const char *via, int line, dk_prov_t prov) { int64_t img = cbm_pipeline_image_node(d->ctx, ref); if (img <= 0) { return; } - char esc_ref[CBM_SZ_1K]; - cbm_json_escape(esc_ref, (int)sizeof(esc_ref), ref); - char props[CBM_SZ_2K]; - snprintf(props, sizeof(props), "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d}", - via, esc_ref, d->stage, line); - cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, img, "DEPENDS_ON", props); - d->edges++; + if (d->nrefs == d->refs_cap) { + int cap = d->refs_cap ? d->refs_cap * PAIR_LEN : DK_MAX_STAGES; + dk_ref_t *grown = cbm_arena_alloc(d->a, (size_t)cap * sizeof(*grown)); + if (!grown) { + return; + } + if (d->nrefs) { + memcpy(grown, d->refs, (size_t)d->nrefs * sizeof(*grown)); + } + d->refs = grown; + d->refs_cap = cap; + } + d->refs[d->nrefs++] = (dk_ref_t){img, ref, via, d->stage, line, prov}; +} + +/* The names a default supplies to the references of Image `img`: their union, + * or NULL when any of them names it without a default (a literal reference + * wins -- the edges to one Image are one edge, so every one of them carries + * the same mark). */ +static const char *dk_refs_defaults(dk_t *d, int64_t img) { + const char *names = NULL; + for (int i = 0; i < d->nrefs; i++) { + if (d->refs[i].img != img) { + continue; + } + if (!d->refs[i].prov.dflt) { + return NULL; + } + names = dk_names_union(d, names, d->refs[i].prov.dflt); + } + return names; +} + +/* The Dockerfile's own view: DEPENDS_ON from its Module to each Image FROM / + * COPY --from names {via, image, stage, line[, default_of: the ARGs whose + * defaults named it]}. */ +static void dk_refs_link(dk_t *d) { + for (int i = 0; i < d->nrefs; i++) { + const dk_ref_t *r = &d->refs[i]; + char esc_ref[CBM_SZ_1K]; + cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref); + const char *dflt = dk_names_member(d, "default_of", dk_refs_defaults(d, r->img)); + const char *props = + cbm_arena_sprintf(d->a, "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d%s}", + r->via, esc_ref, r->stage, r->line, dflt); + if (props) { + cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, r->img, "DEPENDS_ON", props); + d->edges++; + } + } +} + +/* A build's view: each Image a reference the build's arguments changed names + * -- the first reference to it, its build arguments unioned -- + * DEPENDS_ON from the Image the build makes {via, image, override_of[, + * default_of], dockerfile, stage, line, tool, file, build_line}. A reference + * no build argument reached is the Dockerfile's own edge already. */ +static void dk_refs_link_build(dk_t *d) { + for (int i = 0; i < d->nrefs; i++) { + const dk_ref_t *r = &d->refs[i]; + bool first = r->prov.ovr != NULL && r->img != d->built; + for (int j = 0; first && j < i; j++) { + first = !(d->refs[j].img == r->img && d->refs[j].prov.ovr); + } + if (!first) { + continue; + } + dk_prov_t all = {0}; + for (int j = i; j < d->nrefs; j++) { + if (d->refs[j].img == r->img && d->refs[j].prov.ovr) { + dk_prov_merge(d, &all, d->refs[j].prov); + } + } + char esc_ref[CBM_SZ_1K]; + char esc_file[CBM_SZ_1K]; + cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref); + cbm_json_escape(esc_file, (int)sizeof(esc_file), d->file); + const char *props = cbm_arena_sprintf( + d->a, + "{\"via\":\"%s\",\"image\":\"%s\"%s%s,\"dockerfile\":\"%s\",\"stage\":%d," + "\"line\":%d,%s}", + r->via, esc_ref, dk_names_member(d, "override_of", all.ovr), + dk_names_member(d, "default_of", all.dflt), esc_file, r->stage, r->line, + d->build_props); + if (props) { + cbm_gbuf_insert_edge(d->ctx->gbuf, d->built, r->img, "DEPENDS_ON", props); + d->edges++; + } + } } static int dk_line(TSNode n) { @@ -467,19 +774,33 @@ static void dk_arg(dk_t *d, TSNode ins) { if (!name) { return; } - if (d->stage < 0) { - dk_set(d, &d->global, name, ts_node_is_null(df) ? NULL : dk_value(d, df)); + size_t nl = strlen(name); + dk_var_t **list = d->stage < 0 ? &d->global : &d->local; + /* A build argument sets the ARG it names, in every stage declaring it, + * whatever its default. */ + const dk_var_t *set = dk_find(d->build_args, name, nl); + if (set) { + dk_set(d, list, name, set->value, true, + (dk_prov_t){.ovr = dk_names_add(d, NULL, name, nl)}); return; } const char *value = NULL; + dk_prov_t prov = {0}; if (!ts_node_is_null(df)) { + /* A default may name ARGs declared before it (ARG BASE=golang:${GO}). */ const char *raw = dk_value(d, df); - value = raw ? dk_expand(d, d->local, raw) : NULL; - } else { - const dk_var_t *g = dk_find(d->global, name, strlen(name)); - value = g ? g->value : NULL; + value = raw ? dk_expand(d, *list, raw, &prov) : NULL; + if (value) { + prov.dflt = dk_names_add(d, prov.dflt, name, nl); + } + } else if (d->stage >= 0) { + const dk_var_t *g = dk_find(d->global, name, nl); + if (g) { + value = g->value; + prov = g->prov; + } } - dk_set(d, &d->local, name, value); + dk_set(d, list, name, value, true, prov); } static void dk_env(dk_t *d, TSNode ins) { @@ -498,7 +819,9 @@ static void dk_env(dk_t *d, TSNode ins) { } const char *raw = ts_node_is_null(vl) ? NULL : dk_value(d, vl); const char *name = dk_text(d, nm); - dk_set(d, &d->local, name, raw ? dk_expand(d, d->local, raw) : NULL); + dk_prov_t prov = {0}; + const char *value = raw ? dk_expand(d, d->local, raw, &prov) : NULL; + dk_set(d, &d->local, name, value, false, prov); dk_envkey_t *k = d->stage < DK_MAX_STAGES ? cbm_arena_alloc(d->a, sizeof(*k)) : NULL; if (k && name) { *k = (dk_envkey_t){name, dk_line(pair), d->st[d->stage].envs}; @@ -515,7 +838,9 @@ static void dk_from(dk_t *d, TSNode ins) { spec = ts_node_named_child(ins, i); } } - const char *ref = ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec)); + dk_prov_t prov = {0}; + const char *ref = + ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec), &prov); d->stage++; d->local = NULL; d->workdir = "/"; @@ -523,7 +848,7 @@ static void dk_from(dk_t *d, TSNode ins) { d->cmd.set = false; bool stage = ref && dk_is_stage(d, ref); if (ref && !stage) { - dk_link(d, ref, "from", dk_line(ins)); + dk_link(d, ref, "from", dk_line(ins), prov); } if (d->stage < DK_MAX_STAGES) { d->st[d->stage].parent = stage ? dk_stage_of(d, ref) : -1; @@ -605,7 +930,7 @@ static int dk_copy_json(dk_t *d, const char *p, const char **out, int max) { if (yyjson_is_arr(arr)) { for (yyjson_arr_iter_init(arr, &it); n < max && (v = yyjson_arr_iter_next(&it));) { const char *s = yyjson_get_str(v); - out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s)) : NULL; + out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s), NULL) : NULL; } } if (doc) { @@ -639,11 +964,12 @@ static void dk_copy(dk_t *d, TSNode ins) { if (l > sizeof(FROM) - SKIP_ONE && strncmp(p, FROM, sizeof(FROM) - SKIP_ONE) == 0) { const char *v = cbm_arena_strndup(d->a, p + sizeof(FROM) - SKIP_ONE, l - (sizeof(FROM) - SKIP_ONE)); - const char *ref = v ? dk_expand(d, d->local, v) : NULL; + dk_prov_t prov = {0}; + const char *ref = v ? dk_expand(d, d->local, v, &prov) : NULL; c->has_from = true; c->from = ref; if (ref && ref[0] && !dk_is_stage(d, ref)) { - dk_link(d, ref, "copy-from", dk_line(ins)); + dk_link(d, ref, "copy-from", dk_line(ins), prov); } } p += l; @@ -652,7 +978,7 @@ static void dk_copy(dk_t *d, TSNode ins) { while (!json && *p && nw < DK_MAX_WORDS) { size_t l = strcspn(p, " \t"); const char *w = cbm_arena_strndup(d->a, p, l); - words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w) : NULL; + words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w, NULL) : NULL; p += l; p += strspn(p, " \t"); } @@ -1023,8 +1349,8 @@ static void dk_go_builds(dk_t *d, const char **w, int n) { if (!b) { return; } - *b = (dk_build_t){.out = out ? dk_expand(d, d->local, out) : NULL, - .pkg = dk_expand(d, d->local, pkg ? pkg : "."), + *b = (dk_build_t){.out = out ? dk_expand(d, d->local, out, NULL) : NULL, + .pkg = dk_expand(d, d->local, pkg ? pkg : ".", NULL), .workdir = d->workdir, .next = d->st[d->stage].builds}; d->st[d->stage].builds = b; @@ -1066,7 +1392,7 @@ static void dk_workdir(dk_t *d, TSNode ins) { continue; } const char *raw = dk_text(d, pn); - const char *v = raw ? dk_expand(d, d->local, raw) : NULL; + const char *v = raw ? dk_expand(d, d->local, raw, NULL) : NULL; const char *w = v ? v : raw; if (w) { d->workdir = dk_absolute(d, w, d->workdir); @@ -1431,11 +1757,31 @@ static void dk_file(dk_t *d, TSNode root) { dk_entry(d, ins, &d->cmd); } } + dk_refs_link(d); dk_spawn(d); dk_envs(d); dk_image_set(d); } +/* A build's view of the Dockerfile: only what decides its image references + * (ARG, ENV, FROM, COPY --from) is read, under the build's arguments. */ +static void dk_file_build(dk_t *d, TSNode root) { + for (uint32_t i = 0; i < ts_node_named_child_count(root); i++) { + TSNode ins = ts_node_named_child(root, i); + if (dk_type(ins, "arg_instruction")) { + dk_arg(d, ins); + } else if (dk_type(ins, "env_instruction")) { + dk_env(d, ins); + } else if (dk_type(ins, "from_instruction")) { + dk_from(d, ins); + } else if ((dk_type(ins, "copy_instruction") || dk_type(ins, "add_instruction")) && + d->stage >= 0) { + dk_copy(d, ins); + } + } + dk_refs_link_build(d); +} + static char *dk_read(const char *path, size_t *len) { FILE *f = cbm_fopen(path, "rb"); if (!f) { @@ -1545,12 +1891,15 @@ static void dk_build_site(const cbm_build_site_t *s, void *ud) { char esc_img[CBM_SZ_1K]; cbm_json_escape(esc_file, (int)sizeof(esc_file), b->file); cbm_json_escape(esc_img, (int)sizeof(esc_img), s->image); - char props[CBM_SZ_4K]; - snprintf(props, sizeof(props), - "{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d}", - s->via, esc_file, esc_img, s->line); - cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props); - b->edges++; + const char *props = cbm_arena_sprintf( + b->a, + "{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d%s%s}", + s->via, esc_file, esc_img, s->line, s->build_args ? ",\"build_args\":" : "", + s->build_args ? s->build_args : ""); + if (props) { + cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props); + b->edges++; + } } typedef struct { @@ -1599,6 +1948,41 @@ static void dk_compose_connects(dk_compose_t *c, const cbm_compose_service_t *s, * DEFINES; it DEPENDS_ON the services it depends on / links, USAGE * {ref:image} its image, DEPENDS_ON {via:build} the Dockerfile it builds and * CONNECTS_TO the services its environment addresses. */ +/* The image a service runs: USAGE {ref: image, image[, default_of]} -- the + * variables whose inline default named it. When the project's .env replaced + * a default, the edge carries {override_of, override_source: ".env"}, and + * the image the defaults alone name is kept: on the same Image as + * `default_image`, else its own USAGE {ref: image_default, default_of}, a + * reference the service does not run. */ +static void dk_compose_image(dk_compose_t *c, const cbm_compose_service_t *s, int64_t self) { + int64_t img = cbm_pipeline_image_node(c->ctx, s->image); + int64_t dimg = s->default_image ? cbm_pipeline_image_node(c->ctx, s->default_image) : 0; + char esc[CBM_SZ_1K]; + char desc[CBM_SZ_1K]; + cbm_json_escape(esc, (int)sizeof(esc), s->image); + cbm_json_escape(desc, (int)sizeof(desc), s->default_image ? s->default_image : ""); + const char *props = + cbm_arena_sprintf(c->a, "{\"ref\":\"image\",\"image\":\"%s\"%s%s%s%s%s%s%s}", esc, + s->image_default_of ? ",\"default_of\":" : "", + s->image_default_of ? s->image_default_of : "", + s->image_override_of ? ",\"override_of\":" : "", + s->image_override_of ? s->image_override_of : "", + s->image_override_of ? ",\"override_source\":\".env\"" : "", + dimg > 0 && dimg == img ? ",\"default_image\":\"" : "", + dimg > 0 && dimg == img ? cbm_arena_sprintf(c->a, "%s\"", desc) : ""); + if (props) { + dk_compose_edge(c, self, img, "USAGE", props); + } + if (dimg > 0 && dimg != img && s->image_override_of) { + const char *dprops = cbm_arena_sprintf( + c->a, "{\"ref\":\"image_default\",\"image\":\"%s\",\"default_of\":%s}", desc, + s->image_override_of); + if (dprops) { + dk_compose_edge(c, self, dimg, "USAGE", dprops); + } + } +} + static void dk_compose_service(const cbm_compose_service_t *s, void *ud) { dk_compose_t *c = ud; const char *qn = dk_compose_qn(c, s->name); @@ -1635,11 +2019,7 @@ static void dk_compose_service(const cbm_compose_service_t *s, void *ud) { } dk_compose_connects(c, s, self->id); if (s->image) { - char esc[CBM_SZ_1K]; - cbm_json_escape(esc, (int)sizeof(esc), s->image); - char props[CBM_SZ_2K]; - snprintf(props, sizeof(props), "{\"ref\":\"image\",\"image\":\"%s\"}", esc); - dk_compose_edge(c, self->id, cbm_pipeline_image_node(c->ctx, s->image), "USAGE", props); + dk_compose_image(c, s, self->id); } if (s->dockerfile) { const char *mqn = cbm_fqn_module(c->a, c->ctx->project_name, s->dockerfile); @@ -1706,6 +2086,152 @@ static int dk_link_builds(cbm_pipeline_ctx_t *ctx, CBMArena *a) { return b.edges; } +static bool dk_prop(const char *props, const char *key, char *out, size_t out_sz); +static void *dk_grow(CBMArena *a, void *v, int n, int *cap, size_t size); + +/* A build that passes build arguments (compose build.args, docker build + * --build-arg; on its build edge as `build_args`). */ +typedef struct { + int64_t img; + int64_t module; + const char *dockerfile; + const dk_var_t *args; + const char *props; /* "tool":..,"file":..,"build_line":N */ +} dk_argbuild_t; + +/* The build arguments of build edge properties `props` (NULL: none). */ +static const dk_var_t *dk_build_args_of(CBMArena *a, const char *props) { + if (!props || !strstr(props, "\"build_args\"")) { + return NULL; + } + yyjson_doc *doc = yyjson_read(props, strlen(props), 0); + yyjson_val *args = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "build_args") : NULL; + dk_var_t *list = NULL; + yyjson_obj_iter it; + yyjson_val *k; + if (yyjson_is_obj(args)) { + yyjson_obj_iter_init(args, &it); + while ((k = yyjson_obj_iter_next(&it))) { + const char *v = yyjson_get_str(yyjson_obj_iter_get_val(k)); + dk_var_t *var = v ? cbm_arena_alloc(a, sizeof(*var)) : NULL; + if (var) { + *var = (dk_var_t){.name = cbm_arena_strdup(a, yyjson_get_str(k)), + .value = cbm_arena_strdup(a, v), + .arg = true, + .next = list}; + list = var; + } + } + } + if (doc) { + yyjson_doc_free(doc); + } + return list; +} + +/* Every build edge (Image -DEPENDS_ON {via: build}-> Dockerfile) that passes + * build arguments, into out (arena); returns how many. */ +static int dk_argbuilds(cbm_pipeline_ctx_t *ctx, CBMArena *a, dk_argbuild_t **out) { + const cbm_gbuf_node_t **imgs = NULL; + int ni = 0; + *out = NULL; + if (cbm_gbuf_find_by_label(ctx->gbuf, "Image", &imgs, &ni) != 0) { + return 0; + } + int n = 0; + int cap = 0; + for (int i = 0; i < ni; i++) { + const cbm_gbuf_edge_t **es = NULL; + int ne = 0; + if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, imgs[i]->id, "DEPENDS_ON", &es, &ne) != + 0) { + continue; + } + for (int j = 0; j < ne; j++) { + const char *p = es[j]->properties_json; + char via[CBM_SZ_32]; + const dk_var_t *args = NULL; + if (!dk_prop(p, "via", via, sizeof(via)) || strcmp(via, "build") != 0 || + !(args = dk_build_args_of(a, p))) { + continue; + } + const cbm_gbuf_node_t *mod = cbm_gbuf_find_by_id(ctx->gbuf, es[j]->target_id); + char tool[CBM_SZ_64]; + char file[CBM_SZ_1K]; + char esc[CBM_SZ_2K]; + if (!mod || !mod->file_path || !dk_prop(p, "tool", tool, sizeof(tool)) || + !dk_prop(p, "file", file, sizeof(file))) { + continue; + } + const char *line = strstr(p, "\"line\":"); + if (n == cap) { + *out = dk_grow(a, *out, n, &cap, sizeof(**out)); + if (!*out) { + return 0; + } + } + cbm_json_escape(esc, (int)sizeof(esc), file); + (*out)[n++] = (dk_argbuild_t){ + .img = imgs[i]->id, + .module = mod->id, + .dockerfile = cbm_arena_strdup(a, mod->file_path), + .args = args, + .props = cbm_arena_sprintf(a, "\"tool\":\"%s\",\"file\":\"%s\",\"build_line\":%d", + tool, esc, line ? atoi(line + strlen("\"line\":")) : 0)}; + } + } + return n; +} + +/* Each build passing build arguments reads its Dockerfile in its own view + * (dk_file_build): what those arguments change, it builds FROM. Image-sourced + * DEPENDS_ON edges, recomputed whole every run (CBM_BUILD_EDGE_SOURCE). */ +static int dk_link_build_args(cbm_pipeline_ctx_t *ctx, CBMArena *a) { + dk_argbuild_t *bs = NULL; + int n = ctx->repo_path ? dk_argbuilds(ctx, a, &bs) : 0; + if (n == 0) { + return 0; + } + TSParser *parser = ts_parser_new(); + if (!parser || !ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_DOCKERFILE))) { + if (parser) { + ts_parser_delete(parser); + } + return 0; + } + int edges = 0; + for (int i = 0; i < n; i++) { + char path[CBM_SZ_4K]; + snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, bs[i].dockerfile); + size_t len = 0; + char *src = dk_read(path, &len); + if (!src) { + continue; + } + dk_uncomment(src, len); + TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)len); + if (tree) { + dk_t d = {.ctx = ctx, + .a = a, + .src = src, + .file = bs[i].dockerfile, + .module = bs[i].module, + .stage = -1, + .dir = "", + .workdir = "/", + .build_args = bs[i].args, + .built = bs[i].img, + .build_props = bs[i].props}; + dk_file_build(&d, ts_tree_root_node(tree)); + edges += d.edges; + ts_tree_delete(tree); + } + cbm_free(CBM_MEM_CLASS_OTHER, src); + } + ts_parser_delete(parser); + return edges; +} + static bool dk_node_before(const cbm_gbuf_node_t *a, const cbm_gbuf_node_t *b) { int c = strcmp(a->file_path, b->file_path); return c < 0 || (c == 0 && a->start_line < b->start_line); @@ -1891,17 +2417,19 @@ static void dk_hub_push(CBMArena *a, dk_hub_edges_t *l, const cbm_gbuf_edge_t *e * USAGE {ref:image}, or DEPENDS_ON {via:from|copy-from}? */ static bool dk_hub_ref(const char *type, const char *p) { char v[CBM_SZ_32]; - if (strcmp(type, "USAGE") == 0) { + if (strcmp(type, "USAGE") == 0 || strcmp(type, "DEPLOYS") == 0) { return dk_prop(p, "ref", v, sizeof(v)) && strcmp(v, "image") == 0; } return dk_prop(p, "via", v, sizeof(v)) && (strcmp(v, "from") == 0 || strcmp(v, "copy-from") == 0); } -/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}) and - * references in, each with the tag of its `image` reference. */ +/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}), the + * base images a build's arguments chose out (DEPENDS_ON {via: from | + * copy-from, build_line}: they follow their build) and references in, each + * with the tag of its `image` reference. */ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img, - dk_hub_edges_t *builds, dk_hub_edges_t *refs) { + dk_hub_edges_t *builds, dk_hub_edges_t *refs, dk_hub_edges_t *follows) { const cbm_gbuf_edge_t **es = NULL; int n = 0; char ref[CBM_SZ_1K]; @@ -1909,8 +2437,13 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img, if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, img, "DEPENDS_ON", &es, &n) == 0) { for (int i = 0; i < n; i++) { char via[CBM_SZ_32]; - if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via)) || - strcmp(via, "build") != 0) { + if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via))) { + continue; + } + if (strcmp(via, "build") != 0) { + if (strstr(es[i]->properties_json, "\"build_line\":")) { + dk_hub_push(a, follows, es[i], ""); + } continue; } tag[0] = '\0'; @@ -1921,7 +2454,7 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img, } atomic_fetch_add_explicit(&g_dk_hub_edges, (uint64_t)n, memory_order_relaxed); } - static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON"}; + static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON", "DEPLOYS"}; for (size_t t = 0; t < sizeof(REF_TYPES) / sizeof(REF_TYPES[0]); t++) { if (cbm_gbuf_find_edges_by_target_type(ctx->gbuf, img, REF_TYPES[t], &es, &n) != 0) { continue; @@ -2122,6 +2655,27 @@ static int dk_hub_move(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_hub_edge_t return SKIP_ONE; } +static int dk_prop_int(const char *props, const char *key) { + char pat[CBM_SZ_64]; + snprintf(pat, sizeof(pat), "\"%s\":", key); + const char *p = props ? strstr(props, pat) : NULL; + return p ? atoi(p + strlen(pat)) : -SKIP_ONE; +} + +/* Does base-image edge `f` come from build edge `b`: one source, and its + * tool / file / build_line those of the build? */ +static bool dk_hub_follows(const dk_hub_edge_t *f, const dk_hub_edge_t *b) { + char ft[CBM_SZ_64]; + char bt[CBM_SZ_64]; + char ff[CBM_SZ_1K]; + char bf[CBM_SZ_1K]; + return f->src == b->src && dk_prop(f->props, "tool", ft, sizeof(ft)) && + dk_prop(b->props, "tool", bt, sizeof(bt)) && strcmp(ft, bt) == 0 && + dk_prop(f->props, "file", ff, sizeof(ff)) && dk_prop(b->props, "file", bf, sizeof(bf)) && + strcmp(ff, bf) == 0 && + dk_prop_int(f->props, "build_line") == dk_prop_int(b->props, "line"); +} + /* One repository's Images (imgs[0..n)): split it by component when it is * built from several Dockerfiles, else keep (or bring back) everything on * the repository Image. Returns the edges moved. */ @@ -2129,10 +2683,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img dk_drop_t *drop) { dk_hub_edges_t builds = {0}; dk_hub_edges_t refs = {0}; + dk_hub_edges_t follows = {0}; bool has_sub = false; for (int i = 0; i < n; i++) { has_sub = has_sub || strcmp(imgs[i].name, imgs[i].repo) != 0; - dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs); + dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs, &follows); } const char **comps = NULL; size_t pre = 0; @@ -2158,6 +2713,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img int k = builds.v[i].comp; int64_t src = sub && k >= 0 && sub[k] > 0 ? sub[k] : repo_img; moved += dk_hub_move(ctx, a, &builds.v[i], src, builds.v[i].tgt, drop); + for (int f = 0; f < follows.n; f++) { + if (dk_hub_follows(&follows.v[f], &builds.v[i])) { + moved += dk_hub_move(ctx, a, &follows.v[f], src, follows.v[f].tgt, drop); + } + } } for (int i = 0; i < refs.n; i++) { int k = sub ? dk_hub_match(refs.v[i].tag, comps, nc) : -1; @@ -2274,6 +2834,7 @@ void cbm_pipeline_pass_docker(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fi cbm_ht_free(ix.dirs); } edges += dk_link_builds(ctx, &arena); + edges += dk_link_build_args(ctx, &arena); edges += dk_image_hubs(ctx, &arena); cbm_arena_destroy(&arena); if (edges > 0) { diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index a9e92acc76..034e8984ca 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -20,10 +20,12 @@ #include "foundation/compat_fs.h" #include "foundation/limits.h" #include "cbm.h" -#include "helpers.h" /* cbm_kind_in_set_free_cache */ -#include "foundation/slab_alloc.h" /* cbm_slab_destroy_thread */ -#include "foundation/platform.h" /* cbm_default_worker_count */ -#include "foundation/str_util.h" /* cbm_json_escape */ +#include "helpers.h" /* cbm_kind_in_set_free_cache */ +#include "foundation/slab_alloc.h" /* cbm_slab_destroy_thread */ +#include "foundation/platform.h" /* cbm_default_worker_count */ +#include "foundation/str_util.h" /* cbm_json_escape */ +#include "foundation/json_writer.h" /* cbm_json_writer_allocator */ +#include "pipeline/helm_template.h" #include "pipeline/worker_pool.h" #include @@ -1103,9 +1105,7 @@ static void k8s_bundles_free(k8s_bundles_t *b) { } /* A Helm chart's directory bundles the plain manifests below it. */ -static void k8s_chart_dir_add(k8s_bundles_t *b, const char *chart_yaml) { - char dir[CBM_SZ_1K]; - k8s_dirname(chart_yaml, dir, sizeof(dir)); +static void k8s_chart_dir_register(k8s_bundles_t *b, const char *dir) { if (b->charts && !cbm_ht_get(b->charts, dir)) { char *owned = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, dir); if (owned) { @@ -1114,6 +1114,12 @@ static void k8s_chart_dir_add(k8s_bundles_t *b, const char *chart_yaml) { } } +static void k8s_chart_dir_add(k8s_bundles_t *b, const char *chart_yaml) { + char dir[CBM_SZ_1K]; + k8s_dirname(chart_yaml, dir, sizeof(dir)); + k8s_chart_dir_register(b, dir); +} + /* Build the kustomize bundles (call once every manifest and kustomization is * known and the IMPORTS edges exist). */ static void k8s_bundles_build(cbm_pipeline_ctx_t *ctx, k8s_kusts_t *ks, k8s_bundles_t *b) { @@ -1285,6 +1291,9 @@ typedef struct { int64_t node; char *image; char *container; /* NULL: unnamed */ + int line; /* the image's line */ + int start_line; /* the container's lines */ + int end_line; } k8s_img_t; /* A patch a kustomization states (extract_k8s.c PATCH fact): what it names @@ -1482,8 +1491,12 @@ static void k8s_facts_keep(k8s_facts_t *f, const CBMFileResult *res, const int64 } else if (x->fact == CBM_K8S_FACT_IMAGE) { if (x->name && k8s_grow((void **)&f->imgs, &f->img_cap, f->img_n, sizeof(*f->imgs))) { f->imgs[f->img_n++] = (k8s_img_t){ - node, cbm_mem_strdup(CBM_MEM_CLASS_OTHER, x->name), - x->container ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, x->container) : NULL}; + node, + cbm_mem_strdup(CBM_MEM_CLASS_OTHER, x->name), + x->container ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, x->container) : NULL, + x->line, + x->start_line, + x->end_line}; } } else if (x->fact == CBM_K8S_FACT_ENV) { if (x->name && k8s_grow((void **)&f->envs, &f->env_cap, f->env_n, sizeof(*f->envs))) { @@ -2772,6 +2785,27 @@ static void k8s_link_local_deps(cbm_pipeline_ctx_t *ctx, k8s_local_deps_t *deps) * `file://` dependency (once all charts are read), otherwise to the external * Chart node of the dependency's repository and name, the repository also on * the edge. */ +/* The Chart node `name` defined by chart file `rel_path` (a Chart.yaml, a + * werf.yaml), its File node DEFINES it; 0 on failure. */ +static int64_t k8s_chart_upsert(cbm_pipeline_ctx_t *ctx, const char *rel_path, const char *name, + const char *props) { + char *chart_qn = cbm_infra_qn(ctx->project_name, rel_path, "helm-chart", NULL); + if (!chart_qn) { + return 0; + } + int64_t chart_id = + cbm_gbuf_upsert_node(ctx->gbuf, "Chart", name, chart_qn, rel_path, SKIP_ONE, 0, props); + free(chart_qn); + + char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__"); + const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL; + if (file_node && chart_id > 0) { + cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, chart_id, "DEFINES", "{}"); + } + free(file_qn); + return chart_id > 0 ? chart_id : 0; +} + static void handle_helm_chart(cbm_pipeline_ctx_t *ctx, const char *rel_path, const char *source, k8s_local_deps_t *local_deps) { cbm_helm_chart_t hc; @@ -2781,24 +2815,14 @@ static void handle_helm_chart(cbm_pipeline_ctx_t *ctx, const char *rel_path, con } const char *cname = hc.chart_name[0] ? hc.chart_name : k8s_basename(rel_path); - char *chart_qn = cbm_infra_qn(ctx->project_name, rel_path, "helm-chart", NULL); - if (!chart_qn) { + int64_t chart_id = k8s_chart_upsert(ctx, rel_path, cname, "{\"source\":\"helm\"}"); + if (chart_id <= 0) { cbm_helm_chart_free(&hc); return; } - int64_t chart_id = cbm_gbuf_upsert_node(ctx->gbuf, "Chart", cname, chart_qn, rel_path, SKIP_ONE, - 0, "{\"source\":\"helm\"}"); - free(chart_qn); - - char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__"); - const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL; - if (file_node && chart_id > 0) { - cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, chart_id, "DEFINES", "{}"); - } - free(file_qn); int dep_edges = 0; - for (int i = 0; i < hc.dep_count && chart_id > 0; i++) { + for (int i = 0; i < hc.dep_count; i++) { const cbm_helm_dep_t *dep = &hc.deps[i]; if (k8s_is_file_repository(dep->repository)) { k8s_local_deps_push(local_deps, chart_id, rel_path, ctx->project_name, dep); @@ -2812,6 +2836,106 @@ static void handle_helm_chart(cbm_pipeline_ctx_t *ctx, const char *rel_path, con cbm_log_info("pass.k8s.helm", "file", rel_path, "deps", itoa_k8s(dep_edges)); } +/* ── werf chart handler ──────────────────────────────────────────── */ + +/* werf (werf.io) deploys the Helm chart in the `.helm` directory beside its + * werf.yaml; that chart needs no Chart.yaml, werf names it after its project. + * A `.helm` with a Chart.yaml is an ordinary chart. A custom + * deploy.helmChartDir is not followed. */ +static bool is_werf_file(const char *base) { + return strcmp(base, "werf.yaml") == 0 || strcmp(base, "werf.yml") == 0; +} + +bool cbm_werf_helm_dir(const char *repo_path, const char *dir) { + const char *base = k8s_basename(dir); + if (!repo_path || strcmp(base, ".helm") != 0) { + return false; + } + char path[CBM_SZ_4K]; + snprintf(path, sizeof(path), "%s/%s", repo_path, dir); + if (!cbm_is_dir(path)) { + return false; + } + static const char *const charts[] = {"Chart.yaml", "Chart.yml"}; + for (size_t i = 0; i < sizeof(charts) / sizeof(charts[0]); i++) { + snprintf(path, sizeof(path), "%s/%s/%s", repo_path, dir, charts[i]); + if (cbm_file_exists(path)) { + return false; + } + } + size_t parent = (size_t)(base - dir); + static const char *const werfs[] = {"werf.yaml", "werf.yml"}; + for (size_t i = 0; i < sizeof(werfs) / sizeof(werfs[0]); i++) { + snprintf(path, sizeof(path), "%s/%.*s%s", repo_path, (int)parent, dir, werfs[i]); + if (cbm_file_exists(path)) { + return true; + } + } + return false; +} + +/* The literal top-level `project:` of a werf.yaml's first document into out, + * "" when there is none or it is templated. */ +static void k8s_werf_project(const char *src, char *out, size_t out_sz) { + out[0] = '\0'; + bool seen_key = false; + for (const char *p = src; p && *p;) { + const char *eol = strchr(p, '\n'); + size_t len = eol ? (size_t)(eol - p) : strlen(p); + if (len >= K8S_SLEN("---") && strncmp(p, "---", K8S_SLEN("---")) == 0 && seen_key) { + return; + } + if (isalpha((unsigned char)p[0])) { + seen_key = true; + } + if (len > K8S_SLEN("project:") && strncmp(p, "project:", K8S_SLEN("project:")) == 0) { + /* the value: [b, e) of the line, a comment and quotes removed */ + size_t b = K8S_SLEN("project:"); + size_t e = len; + while (b < e && (p[b] == ' ' || p[b] == '\t')) { + b++; + } + for (size_t i = b; i < e; i++) { + if (p[i] == '#') { + e = i; + } + } + while (e > b && isspace((unsigned char)p[e - SKIP_ONE])) { + e--; + } + if (e >= b + PAIR_LEN && (p[b] == '"' || p[b] == '\'') && p[e - SKIP_ONE] == p[b]) { + b++; + e--; + } + if (e > b && e - b < out_sz && !memchr(p + b, '{', e - b)) { + memcpy(out, p + b, e - b); + out[e - b] = '\0'; + } + return; + } + p = eol ? eol + SKIP_ONE : NULL; + } +} + +/* A werf.yaml beside a `.helm` chart dir: a Chart named after its project + * {source: werf}, defined by werf.yaml, and the chart dir into `chart_dir`. + * False when it has no such chart. */ +static bool handle_werf_chart(cbm_pipeline_ctx_t *ctx, const char *rel_path, const char *source, + char *chart_dir, size_t chart_dir_sz) { + char dir[CBM_SZ_1K]; + k8s_dirname(rel_path, dir, sizeof(dir)); + snprintf(chart_dir, chart_dir_sz, "%s%s.helm", dir, dir[0] ? "/" : ""); + if (!cbm_werf_helm_dir(ctx->repo_path, chart_dir)) { + return false; + } + char project[CBM_HELM_NAME_MAX]; + k8s_werf_project(source, project, sizeof(project)); + const char *cname = project[0] ? project : k8s_basename(rel_path); + int64_t chart_id = k8s_chart_upsert(ctx, rel_path, cname, "{\"source\":\"werf\"}"); + cbm_log_info("pass.k8s.werf", "file", rel_path, "chart", cname); + return chart_id > 0; +} + /* ── Dependency-manifest handler (go.mod / requirements.txt) ──────── */ static bool is_gomod_file(const char *base) { @@ -2969,7 +3093,7 @@ static void handle_dep_manifest(cbm_pipeline_ctx_t *ctx, const char *rel_path, c * the sequential loop did. Sequentially the pass re-read and re-parsed every * YAML manifest on one thread -- 1.1 s of the Go corpus (6,275 manifests; * profile + waste sanitizer, 2026-09-17). */ -typedef enum { K8S_SKIP = 0, K8S_DEP, K8S_HELM, K8S_MANIFEST, K8S_KUSTOMIZE } k8s_kind_t; +typedef enum { K8S_SKIP = 0, K8S_DEP, K8S_HELM, K8S_WERF, K8S_MANIFEST, K8S_KUSTOMIZE } k8s_kind_t; typedef struct { k8s_kind_t kind; @@ -3153,6 +3277,8 @@ static void k8s_prep_file(const k8s_prep_job_t *job, int i, k8s_prep_t *out) { } if (is_helm_chart_file(base)) { out->kind = K8S_HELM; + } else if (is_werf_file(base)) { + out->kind = K8S_WERF; } else if (cbm_is_k8s_manifest(base, out->source)) { /* Always re-extract with CBM_LANG_K8S regardless of any cached * result: cached results were produced during the parallel YAML @@ -3610,7 +3736,8 @@ static void k8s_deploys_edge(k8s_dep_link_t *l, int64_t src, int64_t tgt, const } } -/* The Chart node of chart dir `dir`, 0 when it is none. */ +/* The Chart node of chart dir `dir`, 0 when it is none: its Chart.yaml's, or + * for a werf `.helm` dir the one its werf.yaml defines. */ static int64_t k8s_chart_node(k8s_dep_link_t *l, const char *dir) { static const char *const names[] = {"Chart.yaml", "Chart.yml"}; for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { @@ -3621,6 +3748,19 @@ static int64_t k8s_chart_node(k8s_dep_link_t *l, const char *dir) { return id; } } + const char *base = k8s_basename(dir); + if (strcmp(base, ".helm") != 0) { + return 0; + } + static const char *const werfs[] = {"werf.yaml", "werf.yml"}; + for (size_t i = 0; i < sizeof(werfs) / sizeof(werfs[0]); i++) { + char path[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%.*s%s", (int)(base - dir), dir, werfs[i]); + int64_t id = k8s_first_id(l->charts, path); + if (id > 0) { + return id; + } + } return 0; } @@ -3661,8 +3801,10 @@ static bool k8s_chart_values_file(const char *rel, const char *dir) { /* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}-> * each Resource of /templates/ -- its nearest enclosing chart, so a - * subchart's objects are the subchart's. Its values files (values.yaml, - * values-.yaml beside Chart.yaml) CONFIGURE {strategy: helm_values} it. */ + * subchart's objects are the subchart's -- and each CRD of /crds/, + * which Helm installs first ({via: chart, phase: crds}). Its values files + * (values.yaml, values-.yaml beside Chart.yaml) CONFIGURE {strategy: + * helm_values} it. */ static void k8s_link_chart_contents(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b) { if (!b->charts || cbm_ht_count(b->charts) == 0) { return; @@ -3691,9 +3833,14 @@ static void k8s_link_chart_contents(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b) { } if (k == 0) { char tpl[CBM_SZ_1K]; + char crd[CBM_SZ_1K]; snprintf(tpl, sizeof(tpl), "%s%stemplates/", dir, dir[0] ? "/" : ""); + snprintf(crd, sizeof(crd), "%s%scrds/", dir, dir[0] ? "/" : ""); if (strncmp(fp, tpl, strlen(tpl)) == 0) { k8s_deploys_edge(&l, chart, nodes[i]->id, "{\"via\":\"chart\"}"); + } else if (strncmp(fp, crd, strlen(crd)) == 0) { + k8s_deploys_edge(&l, chart, nodes[i]->id, + "{\"via\":\"chart\",\"phase\":\"crds\"}"); } } else if (k8s_chart_values_file(fp, dir)) { cbm_gbuf_insert_edge(ctx->gbuf, nodes[i]->id, chart, "CONFIGURES", @@ -3709,19 +3856,1188 @@ static void k8s_link_chart_contents(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b) { } } -/* Each Image a chart's objects name literally (their USAGE {ref: image}, after +/* ── Helm template images (PHASE 2 P2.5) ───────────────────────────── + * + * A chart template's container image is a placeholder in the skeleton + * (`<.Values.image.repository>`): it is rendered here (helm_template.c) from + * the chart's own values.yaml, its Chart.yaml (.Chart) and the defines of its + * chart family -- what `helm template` writes with the chart's defaults. The + * object then USES the Image {ref: image, default_of: the values keys read}, + * an image nothing else in the repository changes but a release can. A + * parent chart that changes it for its subchart (its `:` values, its + * `global:`) DEPLOYS the Image it makes {via: chart, ref: image, + * override_of}. A value that does not render (helm_template.h: UNKNOWN) or + * is no image reference names none. */ + +typedef struct { + int64_t node; + char *file; + char *container; /* NULL: unnamed */ + int start_line; + int end_line; +} k8s_timg_t; + +typedef struct { + char *dir; + int64_t chart; + cbm_helm_chart_meta_t meta; + yyjson_mut_val *chart_yaml; /* NULL: none */ + yyjson_mut_val *values; /* NULL: values.yaml does not parse (nothing renders) */ +} k8s_hchart_t; + +/* A template file of a chart family (its defines). */ +typedef struct { + char *file; + char *family; +} k8s_htpl_t; + +/* One rendered image of an object (or a chart / release). */ +typedef struct { + int64_t src; + int64_t img; + const char *ref; + const char *container; + const char *keys; /* JSON array; NULL: none */ +} k8s_himg_t; + +typedef struct { + k8s_himg_t *v; + int n; + int cap; + CBMHashTable *seen; /* "src:img" of each v (keys cbm_alloc'd): one look-up a push */ +} k8s_himgs_t; + +typedef struct { + yyjson_mut_doc *doc; + CBMArena a; + CBMHashTable *charts; /* chart dir -> k8s_hchart_t* */ + CBMHashTable *defines; /* family dir -> cbm_helm_defines_t* */ + k8s_htpl_t *tpls; + int ntpl; + bool tpls_read; + k8s_timg_t *t; + int nt; + int tcap; + k8s_himgs_t dflt; /* the containers' images with the defaults */ + int edges; +} k8s_helm_t; + +/* The templated images of the facts, kept past them. */ +static void k8s_helm_collect(cbm_pipeline_ctx_t *ctx, const k8s_facts_t *f, k8s_helm_t *h) { + for (int i = 0; i < f->img_n; i++) { + const k8s_img_t *im = &f->imgs[i]; + const cbm_gbuf_node_t *n = + strchr(im->image, '<') ? cbm_gbuf_find_by_id(ctx->gbuf, im->node) : NULL; + if (!n || !n->file_path || im->start_line <= 0) { + continue; + } + if (!k8s_grow((void **)&h->t, &h->tcap, h->nt, sizeof(*h->t))) { + return; + } + h->t[h->nt++] = (k8s_timg_t){im->node, cbm_arena_strdup(&h->a, n->file_path), + im->container ? cbm_arena_strdup(&h->a, im->container) : NULL, + im->start_line, im->end_line}; + } +} + +/* Repository file `rel`, read only when discovery took it in: a values file, + * a Chart.yaml, a kustomize valuesFile named by path is never a symlink or + * under one -- nothing is read from outside the repository. */ +static char *k8s_repo_read(cbm_pipeline_ctx_t *ctx, const char *rel, int *len) { + if (!cbm_pipeline_file_node(ctx->gbuf, ctx->project_name, rel)) { + return NULL; + } + char path[CBM_SZ_4K]; + int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel); + if (n <= 0 || (size_t)n >= sizeof(path)) { + return NULL; + } + char *buf = k8s_read_file(path, len); + cbm_path_info_t info; + if (!buf && cbm_path_info_utf8(path, &info) == CBM_PATH_INFO_OK && info.is_regular && + !info.is_symlink && info.size == 0) { + /* an empty file is read: empty text (padded like every read) */ + enum { CBM_TS_EMPTY_PAD = 16 }; + buf = cbm_calloc(CBM_MEM_CLASS_OTHER, CBM_TS_EMPTY_PAD); + *len = 0; + } + return buf; +} + +/* Is repository path `rel` there, or can that not be told (a link is + * reported, never followed; only a path proven absent is not there)? */ +static bool k8s_repo_has(cbm_pipeline_ctx_t *ctx, const char *rel) { + char path[CBM_SZ_4K]; + int n = snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, rel); + cbm_path_info_t info; + return n > 0 && (size_t)n < sizeof(path) && + cbm_path_info_utf8(path, &info) != CBM_PATH_INFO_ABSENT; +} + +/* A file of `dir` (one of `names`), parsed as values; *found when one exists. + * *unread when one is there but is not read (a symlink, too large): what it + * holds is unknown, which is not the same as no file. */ +static yyjson_mut_val *k8s_helm_yaml(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const char *dir, + const char *const *names, bool *found, bool *unread) { + *found = false; + *unread = false; + for (int i = 0; names[i]; i++) { + char rel[CBM_SZ_1K]; + snprintf(rel, sizeof(rel), "%s%s%s", dir, dir[0] ? "/" : "", names[i]); + int len = 0; + char *src = k8s_repo_read(ctx, rel, &len); + if (!src) { + if (k8s_repo_has(ctx, rel)) { + *found = true; + *unread = true; + return NULL; + } + continue; + } + *found = true; + yyjson_mut_val *v = cbm_helm_values_parse(h->doc, src, (size_t)len); + cbm_free(CBM_MEM_CLASS_OTHER, src); + return v; + } + return NULL; +} + +static const char *k8s_helm_meta_str(yyjson_mut_val *obj, const char *key) { + yyjson_mut_val *v = yyjson_mut_is_obj(obj) ? yyjson_mut_obj_get(obj, key) : NULL; + return yyjson_mut_is_str(v) ? yyjson_mut_get_str(v) : NULL; +} + +/* The chart of chart dir `dir` with its metadata and default values. */ +static k8s_hchart_t *k8s_helm_chart(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, k8s_dep_link_t *l, + const char *dir) { + k8s_hchart_t *c = cbm_ht_get(h->charts, dir); + if (c) { + return c; + } + c = cbm_arena_calloc(&h->a, sizeof(*c)); + if (!c) { + return NULL; + } + c->dir = cbm_arena_strdup(&h->a, dir); + c->chart = k8s_chart_node(l, dir); + static const char *const CHART[] = {"Chart.yaml", "Chart.yml", NULL}; + static const char *const VALUES[] = {"values.yaml", "values.yml", NULL}; + bool found = false; + bool chart_unread = false; + bool values_unread = false; + c->chart_yaml = k8s_helm_yaml(ctx, h, dir, CHART, &found, &chart_unread); + yyjson_mut_val *vals = k8s_helm_yaml(ctx, h, dir, VALUES, &found, &values_unread); + /* a Chart.yaml or values file there but not read: the chart's values are + * unknown (it renders nothing), never empty */ + c->values = chart_unread || values_unread ? NULL : found ? vals : yyjson_mut_obj(h->doc); + const cbm_gbuf_node_t *cn = c->chart > 0 ? cbm_gbuf_find_by_id(ctx->gbuf, c->chart) : NULL; + const char *name = k8s_helm_meta_str(c->chart_yaml, "name"); + c->meta.name = name ? name : cn && cn->name ? cbm_arena_strdup(&h->a, cn->name) : NULL; + c->meta.version = k8s_helm_meta_str(c->chart_yaml, "version"); + c->meta.app_version = k8s_helm_meta_str(c->chart_yaml, "appVersion"); + cbm_ht_set(h->charts, c->dir, c); + return c; +} + +static bool k8s_helm_tpl_ext(const char *p) { + static const char *const EXT[] = {".yaml", ".yml", ".tpl", ".gotmpl", ".txt", ".json"}; + for (size_t i = 0; i < sizeof(EXT) / sizeof(EXT[0]); i++) { + if (k8s_has_suffix(p, EXT[i])) { + return true; + } + } + return false; +} + +/* The defines of chart family `family`: every template (/templates/) + * of its charts, read from disk. */ +static const cbm_helm_defines_t *k8s_helm_defines(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, + k8s_helm_t *h, const char *family) { + cbm_helm_defines_t *d = cbm_ht_get(h->defines, family); + if (d) { + return d; + } + if (!h->tpls_read) { + h->tpls_read = true; + const cbm_gbuf_node_t **files = NULL; + int n = 0; + if (cbm_gbuf_find_by_label(ctx->gbuf, "File", &files, &n) == 0 && n > 0) { + h->tpls = cbm_arena_alloc(&h->a, (size_t)n * sizeof(*h->tpls)); + for (int i = 0; h->tpls && i < n; i++) { + const char *fp = files[i]->file_path; + char dir[CBM_SZ_1K]; + char fam[CBM_SZ_1K]; + char tdir[CBM_SZ_1K]; + if (!fp || !k8s_helm_tpl_ext(fp) || !k8s_chart_of(b, fp, dir, sizeof(dir))) { + continue; + } + snprintf(tdir, sizeof(tdir), "%s%stemplates/", dir, dir[0] ? "/" : ""); + if (strncmp(fp, tdir, strlen(tdir)) != 0) { + continue; + } + k8s_chart_family(b, dir, fam, sizeof(fam)); + h->tpls[h->ntpl++] = + (k8s_htpl_t){cbm_arena_strdup(&h->a, fp), cbm_arena_strdup(&h->a, fam)}; + } + } + } + d = cbm_helm_defines_new(); + if (!d) { + return NULL; + } + for (int i = 0; i < h->ntpl; i++) { + if (strcmp(h->tpls[i].family, family) != 0) { + continue; + } + int len = 0; + char *src = k8s_repo_read(ctx, h->tpls[i].file, &len); + if (src) { + cbm_helm_defines_add(d, h->tpls[i].file, src, (size_t)len); + cbm_free(CBM_MEM_CLASS_OTHER, src); + } + } + cbm_ht_set(h->defines, cbm_arena_strdup(&h->a, family), d); + return d; +} + +static void k8s_helm_free_defines(const char *key, void *value, void *ud) { + (void)key; + (void)ud; + cbm_helm_defines_free(value); +} + +static int k8s_timg_cmp(const void *x, const void *y) { + const k8s_timg_t *a = x; + const k8s_timg_t *b = y; + int c = strcmp(a->file, b->file); + return c ? c : a->start_line - b->start_line; +} + +/* Does template line `line` (1-based) of src hold an `image:` key? */ +static bool k8s_helm_image_line(const char *src, size_t len, const uint32_t *starts, int line) { + size_t k = starts[line - SKIP_ONE]; + while (k < len && (src[k] == ' ' || src[k] == '\t')) { + k++; + } + if (k + SKIP_ONE < len && src[k] == '-' && src[k + SKIP_ONE] == ' ') { + k++; + while (k < len && src[k] == ' ') { + k++; + } + } + return k + strlen("image:") <= len && memcmp(src + k, "image:", strlen("image:")) == 0; +} + +/* The probes of a template's templated images [from, to): each `image:` + * line inside a container (both branches of an if / else among them). */ +static int k8s_helm_probes(k8s_helm_t *h, const k8s_timg_t *t, int from, int to, const char *src, + size_t len, cbm_helm_probe_t **out) { + int nl = SKIP_ONE; + for (size_t i = 0; i < len; i++) { + nl += src[i] == '\n'; + } + uint32_t *starts = cbm_arena_alloc(&h->a, (size_t)nl * sizeof(*starts)); + if (!starts) { + return 0; + } + int k = 0; + starts[k++] = 0; + for (size_t i = 0; i < len && k < nl; i++) { + if (src[i] == '\n') { + starts[k++] = (uint32_t)(i + SKIP_ONE); + } + } + int cap = 0; + for (int i = from; i < to; i++) { + cap += t[i].end_line - t[i].start_line + SKIP_ONE; + } + cbm_helm_probe_t *p = cap ? cbm_arena_calloc(&h->a, (size_t)cap * sizeof(*p)) : NULL; + int np = 0; + for (int i = from; p && i < to; i++) { + for (int line = t[i].start_line; line <= t[i].end_line && line <= k; line++) { + bool dup = false; + for (int j = 0; j < np && !dup; j++) { + dup = p[j].line == line; + } + if (!dup && k8s_helm_image_line(src, len, starts, line)) { + p[np++].line = line; + } + } + } + *out = p; + return np; +} + +static yyjson_mut_val *k8s_helm_dig(yyjson_mut_val *v, const char *path) { + const char *p = path; + while (v && *p) { + const char *dot = strchr(p, '.'); + size_t kl = dot ? (size_t)(dot - p) : strlen(p); + v = yyjson_mut_is_obj(v) ? yyjson_mut_obj_getn(v, p, kl) : NULL; + p += kl + (dot ? SKIP_ONE : 0); + } + return v; +} + +/* Is values key path `key` (dot-joined, `\.` escaped) set in `v`? */ +static bool k8s_helm_sets(k8s_helm_t *h, yyjson_mut_val *v, const char *key) { + char *part = cbm_arena_alloc(&h->a, strlen(key) + SKIP_ONE); /* a key is never cut */ + const char *p = key; + while (v && part && *p) { + size_t k = 0; + while (*p && *p != '.' && *p != '[') { + if (*p == '\\' && p[1]) { + p++; + } + part[k++] = *p++; + } + part[k] = '\0'; + v = yyjson_mut_is_obj(v) ? yyjson_mut_obj_get(v, part) : NULL; + while (v && *p == '[') { + long idx = strtol(p + SKIP_ONE, (char **)&p, 10); + p += *p == ']' ? SKIP_ONE : 0; + v = yyjson_mut_is_arr(v) ? yyjson_mut_arr_get(v, (size_t)idx) : NULL; + } + if (*p == '.') { + p++; + } + } + return v != NULL; +} + +/* The values keys of JSON array `keys` that `overlay` sets (relative to + * the chart), prefixed by `prefix` -- as a JSON array; NULL: none. */ +static const char *k8s_helm_overridden(k8s_helm_t *h, const char *keys, yyjson_mut_val *overlay, + yyjson_mut_val *globals, const char *prefix) { + if (!keys) { + return NULL; + } + yyjson_doc *doc = yyjson_read(keys, strlen(keys), 0); + yyjson_val *arr = doc ? yyjson_doc_get_root(doc) : NULL; + const char *out = NULL; + if (yyjson_is_arr(arr)) { + size_t idx, max; + yyjson_val *k; + yyjson_arr_foreach(arr, idx, max, k) { + const char *key = yyjson_get_str(k); + bool global = key && strncmp(key, "global.", strlen("global.")) == 0; + bool set = key && (global ? k8s_helm_sets(h, globals, key + strlen("global.")) + : k8s_helm_sets(h, overlay, key)); + if (!set) { + continue; + } + /* JSON escaping writes at most 6 bytes a byte */ + int en = (int)(strlen(key) * 6 + SKIP_ONE); + char *esc = cbm_arena_alloc(&h->a, (size_t)en); + if (!esc) { + out = NULL; + break; + } + cbm_json_escape(esc, en, key); + out = cbm_arena_sprintf(&h->a, "%s%s\"%s%s%s\"", out ? out : "[", out ? "," : "", + global || !prefix ? "" : prefix, global || !prefix ? "" : ".", + esc); + } + } + if (doc) { + yyjson_doc_free(doc); + } + return out ? cbm_arena_sprintf(&h->a, "%s]", out) : NULL; +} + +static void k8s_himg_push(k8s_himgs_t *l, k8s_himg_t x) { + char key[CBM_SZ_64]; + snprintf(key, sizeof(key), "%lld:%lld", (long long)x.src, (long long)x.img); + if (!l->seen) { + l->seen = cbm_ht_create(0); + } + if (!l->seen || cbm_ht_get(l->seen, key)) { + return; /* the first (in file order) of the same Image stands for it */ + } + size_t kl = strlen(key) + SKIP_ONE; + char *k = cbm_alloc(CBM_MEM_CLASS_OTHER, kl); + if (!k || !k8s_grow((void **)&l->v, &l->cap, l->n, sizeof(*l->v))) { + cbm_free(CBM_MEM_CLASS_OTHER, k); + return; + } + memcpy(k, key, kl); + cbm_ht_set(l->seen, k, (void *)(uintptr_t)SKIP_ONE); + l->v[l->n++] = x; +} + +static void k8s_himg_key_free(const char *key, void *value, void *ud) { + (void)value; + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); +} + +static void k8s_himgs_free(k8s_himgs_t *l) { + if (l->seen) { + cbm_ht_foreach(l->seen, k8s_himg_key_free, NULL); + cbm_ht_free(l->seen); + } + cbm_free(CBM_MEM_CLASS_OTHER, l->v); + *l = (k8s_himgs_t){0}; +} + +/* Does `src` USE Image `img` already by a literal reference? */ +static bool k8s_helm_literal_use(cbm_pipeline_ctx_t *ctx, int64_t src, int64_t img) { + const cbm_gbuf_edge_t **es = NULL; + int n = 0; + if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, src, "USAGE", &es, &n) != 0) { + return false; + } + for (int i = 0; i < n; i++) { + if (es[i]->target_id == img && es[i]->properties_json && + strstr(es[i]->properties_json, "\"ref\":\"image\"")) { + return true; + } + } + return false; +} + +/* The captures of probes within [first, last] lines, as images of `src`. */ +static void k8s_helm_take(cbm_pipeline_ctx_t *ctx, const cbm_helm_probe_t *p, int np, int first, + int last, int64_t src, const char *container, k8s_himgs_t *out) { + for (int i = 0; i < np; i++) { + if (p[i].line < first || p[i].line > last) { + continue; + } + for (int c = 0; c < p[i].n; c++) { + const char *ref = p[i].image[c]; + int64_t img = ref && cbm_image_ref_valid(ref) ? cbm_pipeline_image_node(ctx, ref) : 0; + if (img > 0) { + k8s_himg_push(out, (k8s_himg_t){src, img, ref, container, p[i].keys[c]}); + } + } + } +} + +/* 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 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); + /* a subchart a dependency aliases is .Chart.Name = its alias (Helm's + * getAliasDependency) */ + cbm_helm_chart_meta_t meta = c->meta; + meta.name = as_name ? as_name : meta.name; + /* .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; +} + +static void k8s_helm_emit_uses(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, const k8s_himgs_t *l) { + for (int i = 0; i < l->n; i++) { + const k8s_himg_t *x = &l->v[i]; + if (k8s_helm_literal_use(ctx, x->src, x->img)) { + continue; /* a literal reference of the object wins */ + } + char esc[CBM_SZ_1K]; + char cesc[CBM_SZ_512]; + cbm_json_escape(esc, (int)sizeof(esc), x->ref); + cbm_json_escape(cesc, (int)sizeof(cesc), x->container ? x->container : "-"); + const char *props = cbm_arena_sprintf( + &h->a, "{\"ref\":\"image\",\"image\":\"%s\",\"container\":\"%s\"%s%s}", esc, cesc, + x->keys ? ",\"default_of\":" : "", x->keys ? x->keys : ""); + if (props) { + cbm_gbuf_insert_edge(ctx->gbuf, x->src, x->img, "USAGE", props); + h->edges++; + } + } +} + +/* Chart `from` -DEPLOYS {via, ref: image, image, override_of}-> the images an + * 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, + yyjson_mut_val *globals, const char *prefix, + k8s_himgs_t *emitted) { + for (int i = 0; i < got->n; i++) { + const k8s_himg_t *x = &got->v[i]; + bool same = false; + for (int j = 0; j < dflt->n && !same; j++) { + same = dflt->v[j].src == x->src && dflt->v[j].img == x->img && + 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) { + continue; + } + k8s_himg_t e = {from, x->img, x->ref, NULL, ovr}; + int before = emitted->n; + k8s_himg_push(emitted, e); + if (emitted->n == before) { + continue; + } + char esc[CBM_SZ_1K]; + cbm_json_escape(esc, (int)sizeof(esc), x->ref); + const char *props = cbm_arena_sprintf( + &h->a, "{\"via\":\"%s\",\"ref\":\"image\",\"image\":\"%s\",\"override_of\":%s}", via, + esc, ovr); + if (props) { + cbm_gbuf_insert_edge(ctx->gbuf, from, x->img, "DEPLOYS", props); + h->edges++; + } + } +} + +/* The chart dirs enclosing chart dir `dir`, nearest first (arena). */ +static int k8s_helm_ancestors(k8s_helm_t *h, k8s_bundles_t *b, const char *dir, const char ***out) { + int n = 0; + int cap = 0; + const char **v = NULL; + char cur[CBM_SZ_1K]; + snprintf(cur, sizeof(cur), "%s", dir); + while (cur[0]) { + char up[CBM_SZ_1K]; + if (!k8s_chart_of(b, cur, up, sizeof(up))) { + break; + } + if (n == cap) { + cap = cap ? cap * PAIR_LEN : CBM_SZ_16; + const char **g = cbm_arena_alloc(&h->a, (size_t)cap * sizeof(*g)); + if (!g) { + break; + } + if (n) { + memcpy(g, v, (size_t)n * sizeof(*g)); + } + v = g; + } + v[n++] = cbm_arena_strdup(&h->a, up); + snprintf(cur, sizeof(cur), "%s", up); + } + *out = v; + return n; +} + +/* The dependency of `parent`'s Chart.yaml naming `sub` (by name), NULL: none. */ +static yyjson_mut_val *k8s_helm_dep(const k8s_hchart_t *parent, const k8s_hchart_t *sub) { + yyjson_mut_val *deps = yyjson_mut_is_obj(parent->chart_yaml) + ? yyjson_mut_obj_get(parent->chart_yaml, "dependencies") + : NULL; + if (yyjson_mut_is_arr(deps) && sub->meta.name) { + size_t idx, max; + yyjson_mut_val *d; + yyjson_mut_arr_foreach(deps, idx, max, d) { + const char *name = k8s_helm_meta_str(d, "name"); + if (name && strcmp(name, sub->meta.name) == 0) { + return d; + } + } + } + return NULL; +} + +/* Is the subchart a dependency disables in the parent's values `vals` (its + * `condition`: the first of its comma-separated paths that holds a bool)? */ +static bool k8s_helm_disabled(k8s_helm_t *h, yyjson_mut_val *dep, yyjson_mut_val *vals) { + const char *cond = dep ? k8s_helm_meta_str(dep, "condition") : NULL; + for (const char *p = cond; p && *p;) { + const char *comma = strchr(p, ','); + size_t l = comma ? (size_t)(comma - p) : strlen(p); + char *path = cbm_arena_strndup(&h->a, p, l); + yyjson_mut_val *v = path ? k8s_helm_dig(vals, path) : NULL; + if (yyjson_mut_is_bool(v)) { + return !yyjson_mut_get_bool(v); + } + p += l + (comma ? SKIP_ONE : 0); + } + return false; +} + +/* The values chart dir `dir` renders with when chart dir `top` deploys it + * with values `top_vals` (NULL: top's defaults): down the charts between + * 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), *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 **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 */ + if (strcmp(dir, top) == 0) { + ti = na; + } else { + for (int i = 0; i < na && ti < 0; i++) { + ti = strcmp(anc[i], top) == 0 ? i : ti; + } + } + *path = ""; + *as_name = NULL; + *full = NULL; + if (ti < 0) { + return NULL; + } + k8s_hchart_t *cur_chart = k8s_helm_chart(ctx, h, l, top); + 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--) { + k8s_hchart_t *child = k8s_helm_chart(ctx, h, l, i >= 0 ? anc[i] : dir); + if (!child || !child->values) { + return NULL; + } + yyjson_mut_val *dep = k8s_helm_dep(cur_chart, child); + if (k8s_helm_disabled(h, dep, cur)) { + return NULL; + } + const char *alias = dep ? k8s_helm_meta_str(dep, "alias") : NULL; + alias = alias ? alias : child->meta.name; + if (!alias) { + return NULL; + } + *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; + yyjson_mut_val *cg = next ? yyjson_mut_obj_get(next, "global") : NULL; + yyjson_mut_val *g = cbm_helm_values_coalesce(h->doc, cg, pg); /* the parent's win */ + if (!next || !g) { + return NULL; + } + (void)yyjson_mut_obj_remove_key(next, "global"); + yyjson_mut_obj_add(next, yyjson_mut_strcpy(h->doc, "global"), g); + cur = next; + cur_chart = child; + } + return cur; +} + +/* 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, 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, 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); + } +} + +/* Every templated container's images with its chart family's defaults -- + * what `helm template ` renders. */ +static void k8s_helm_link_defaults(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8s_helm_t *h) { + if (h->nt == 0 || !ctx->repo_path) { + return; + } + qsort(h->t, (size_t)h->nt, sizeof(*h->t), k8s_timg_cmp); + k8s_dep_link_t l = {.ctx = ctx, .charts = k8s_index_by_file(ctx, "Chart")}; + k8s_himgs_t uses = {0}; + for (int from = 0; from < h->nt;) { + int to = from + SKIP_ONE; + while (to < h->nt && strcmp(h->t[to].file, h->t[from].file) == 0) { + to++; + } + char dir[CBM_SZ_1K]; + char fam[CBM_SZ_1K]; + k8s_hchart_t *c = k8s_chart_of(b, h->t[from].file, dir, sizeof(dir)) + ? 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 = 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, &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, full, from, to, vals, src, (size_t)len, + probes, np, &uses); + } + cbm_free(CBM_MEM_CLASS_OTHER, src); + from = to; + } + k8s_helm_emit_uses(ctx, h, &uses); + h->dflt = uses; /* the release views compare against these */ + k8s_free_index(l.charts); +} + +/* ── Release images ─────────────────────────────────────────────────── + * + * A release (a Flux HelmRelease's spec.values, a kustomize helmCharts + * entry's valuesFile + valuesInline, a Terraform helm_release's values + + * set) renders its chart with its values over the defaults. Of a chart in + * the repository the templates render again: each image that differs from + * the defaults' is the release's own -- release -DEPLOYS {via, ref: image, + * image, override_of: the keys it set that the image read}-> Image. A chart + * outside the repository has no templates here: its images are read from the + * release values by convention -- a mapping with `repository` (and `tag` / + * `digest` / `registry`) whose reference names a registry host; one without + * a host names none (no host is invented) -- {inferred: values, values_key}. */ + +/* The chart dir of local Chart node `cn` (a werf chart: its `.helm`). */ +static bool k8s_helm_chart_dir(const cbm_gbuf_node_t *cn, char *out, size_t sz) { + if (!cn->file_path) { + return false; + } + k8s_dirname(cn->file_path, out, sz); + const char *base = k8s_basename(cn->file_path); + if (strcmp(base, "werf.yaml") == 0 || strcmp(base, "werf.yml") == 0) { + size_t l = strlen(out); + snprintf(out + l, sz - l, "%s.helm", l ? "/" : ""); + } + return true; +} + +/* A local chart's images rendered with release values `rv`, against the + * defaults. */ +static void k8s_helm_release_local(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8s_helm_t *h, + k8s_dep_link_t *l, int64_t src, const char *via, const char *top, + yyjson_mut_val *rv, k8s_himgs_t *emitted) { + size_t tl = strlen(top); + for (int from = 0; from < h->nt;) { + int to = from + SKIP_ONE; + while (to < h->nt && strcmp(h->t[to].file, h->t[from].file) == 0) { + to++; + } + char dir[CBM_SZ_1K]; + bool in = + k8s_chart_of(b, h->t[from].file, dir, sizeof(dir)) && + (strcmp(dir, top) == 0 || (tl == 0 || (strncmp(dir, top, tl) == 0 && dir[tl] == '/'))); + k8s_hchart_t *c = in ? k8s_helm_chart(ctx, h, l, dir) : NULL; + const char *path = NULL; + 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, &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, 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, + path && path[0] ? path : NULL, emitted); + k8s_himgs_free(&got); + } + cbm_free(CBM_MEM_CLASS_OTHER, srcs); + from = to; + } +} + +/* Does image reference `ref` name a registry host (its first path part has + * a `.` or `:`, or is localhost)? */ +static bool k8s_helm_has_host(const char *ref) { + const char *slash = strchr(ref, '/'); + if (!slash) { + return false; + } + size_t fl = (size_t)(slash - ref); + return memchr(ref, '.', fl) || memchr(ref, ':', fl) || + (fl == strlen("localhost") && strncmp(ref, "localhost", fl) == 0); +} + +/* The images release values `v` name by convention (see above). */ +static void k8s_helm_convention(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, int64_t src, + const char *via, yyjson_mut_val *v, const char *path, + k8s_himgs_t *emitted) { + size_t idx, max; + if (yyjson_mut_is_arr(v)) { + yyjson_mut_val *it; + yyjson_mut_arr_foreach(v, idx, max, it) { + k8s_helm_convention(ctx, h, src, via, it, + cbm_arena_sprintf(&h->a, "%s[%zu]", path ? path : "", idx), + emitted); + } + return; + } + if (!yyjson_mut_is_obj(v)) { + return; + } + yyjson_mut_val *repo = yyjson_mut_obj_get(v, "repository"); + if (yyjson_mut_is_str(repo) && yyjson_mut_get_len(repo) > 0) { + char reg[CBM_SZ_512] = ""; + char tag[CBM_SZ_256] = ""; + char dig[CBM_SZ_256] = ""; + yyjson_mut_val *r = yyjson_mut_obj_get(v, "registry"); + yyjson_mut_val *tg = yyjson_mut_obj_get(v, "tag"); + yyjson_mut_val *dg = yyjson_mut_obj_get(v, "digest"); + bool ok = (!r || yyjson_mut_is_null(r) || cbm_helm_scalar_text(r, reg, sizeof(reg))) && + (!tg || yyjson_mut_is_null(tg) || cbm_helm_scalar_text(tg, tag, sizeof(tag))) && + (!dg || yyjson_mut_is_null(dg) || cbm_helm_scalar_text(dg, dig, sizeof(dig))); + const char *ref = ok ? cbm_arena_sprintf(&h->a, "%s%s%s%s%s", reg, reg[0] ? "/" : "", + yyjson_mut_get_str(repo), + dig[0] ? "@" + : tag[0] ? ":" + : "", + dig[0] ? dig : tag) + : NULL; + 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; + if (img > 0) { + k8s_himg_push(emitted, (k8s_himg_t){src, img, ref, NULL, NULL}); + } + if (emitted->n > before) { + char esc[CBM_SZ_1K]; + char kesc[CBM_SZ_1K]; + cbm_json_escape(esc, (int)sizeof(esc), ref); + cbm_json_escape(kesc, (int)sizeof(kesc), path ? path : ""); + const char *props = cbm_arena_sprintf( + &h->a, + "{\"via\":\"%s\",\"ref\":\"image\",\"image\":\"%s\",\"inferred\":\"values\"," + "\"values_key\":\"%s\"}", + via, esc, kesc); + if (props) { + cbm_gbuf_insert_edge(ctx->gbuf, src, img, "DEPLOYS", props); + h->edges++; + } + } + } + yyjson_mut_val *k; + yyjson_mut_val *child; + yyjson_mut_obj_foreach(v, idx, max, k, child) { + if (!yyjson_mut_is_obj(child) && !yyjson_mut_is_arr(child)) { + continue; + } + const char *key = yyjson_mut_get_str(k); + const char *cp = path && path[0] ? cbm_arena_sprintf(&h->a, "%s.%s", path, key) + : cbm_arena_strdup(&h->a, key); + k8s_helm_convention(ctx, h, src, via, child, cp, emitted); + } +} + +/* One release of Chart node `chart` with values `rv`. */ +static void k8s_helm_release(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8s_helm_t *h, + k8s_dep_link_t *l, int64_t src, const char *via, int64_t chart, + yyjson_mut_val *rv) { + const cbm_gbuf_node_t *cn = chart > 0 ? cbm_gbuf_find_by_id(ctx->gbuf, chart) : NULL; + if (!cn || !yyjson_mut_is_obj(rv) || src <= 0) { + return; + } + k8s_himgs_t emitted = {0}; + char dir[CBM_SZ_1K]; + if (cbm_pipeline_chart_is_external(cn)) { + k8s_helm_convention(ctx, h, src, via, rv, NULL, &emitted); + } else if (k8s_helm_chart_dir(cn, dir, sizeof(dir)) && b->charts && + cbm_ht_get(b->charts, dir)) { + k8s_helm_release_local(ctx, b, h, l, src, via, dir, rv, &emitted); + } + k8s_himgs_free(&emitted); +} + +/* A node's document (lines start_line..end_line of its file) as values. */ +static yyjson_mut_val *k8s_helm_node_yaml(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, + const cbm_gbuf_node_t *n) { + int len = 0; + char *src = n->file_path ? k8s_repo_read(ctx, n->file_path, &len) : NULL; + if (!src) { + return NULL; + } + size_t b = 0; + size_t e = (size_t)len; + int line = SKIP_ONE; + for (size_t i = 0; i < (size_t)len && n->start_line > 0; i++) { + if (src[i] != '\n') { + continue; + } + line++; + if (line == n->start_line) { + b = i + SKIP_ONE; + } + if (n->end_line > 0 && line == n->end_line + SKIP_ONE) { + e = i; + break; + } + } + yyjson_mut_val *v = b <= e ? cbm_helm_values_parse(h->doc, src + b, e - b) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, src); + return v; +} + +/* String property `key` of edge properties `props` into out; false: none. */ +static bool k8s_json_str(const char *props, const char *key, char *out, size_t sz) { + out[0] = '\0'; + yyjson_doc *d = props ? yyjson_read(props, strlen(props), 0) : NULL; + yyjson_val *v = d ? yyjson_obj_get(yyjson_doc_get_root(d), key) : NULL; + bool ok = yyjson_is_str(v); + if (ok) { + snprintf(out, sz, "%s", yyjson_get_str(v)); + } + if (d) { + yyjson_doc_free(d); + } + return ok; +} + +/* Does field `key` of `obj` give a value (a non-null scalar, a non-empty list + * or map)? */ +static bool k8s_helm_gives(yyjson_mut_val *obj, const char *key) { + yyjson_mut_val *v = yyjson_mut_is_obj(obj) ? yyjson_mut_obj_get(obj, key) : NULL; + if (!v || yyjson_mut_is_null(v)) { + return false; + } + if (yyjson_mut_is_arr(v)) { + return yyjson_mut_arr_size(v) > 0; + } + if (yyjson_mut_is_obj(v)) { + return yyjson_mut_obj_size(v) > 0; + } + return !yyjson_mut_is_str(v) || yyjson_mut_get_len(v) > 0; +} + +/* A HelmRelease's values: spec.values; NULL when there are none -- or when a + * source not read also gives some (valuesFrom, the chart's valuesFiles, a + * postRenderer that may patch the images): its images are then unknown. */ +static yyjson_mut_val *k8s_helm_flux_values(yyjson_mut_val *docv) { + yyjson_mut_val *spec = yyjson_mut_is_obj(docv) ? yyjson_mut_obj_get(docv, "spec") : NULL; + yyjson_mut_val *chart = yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "chart") : NULL; + yyjson_mut_val *cspec = yyjson_mut_is_obj(chart) ? yyjson_mut_obj_get(chart, "spec") : NULL; + if (k8s_helm_gives(spec, "valuesFrom") || k8s_helm_gives(spec, "postRenderers") || + k8s_helm_gives(cspec, "valuesFiles") || k8s_helm_gives(cspec, "valuesFile")) { + return NULL; + } + return yyjson_mut_is_obj(spec) ? yyjson_mut_obj_get(spec, "values") : NULL; +} + +/* A kustomize helmCharts entry's values: valuesFile, then valuesInline over + * it; NULL when neither -- or when a part is not read (additionalValuesFiles, + * a valuesMerge other than the default override, a valuesFile that cannot be + * read): its images are then unknown. */ +static yyjson_mut_val *k8s_helm_kustomize_values(cbm_pipeline_ctx_t *ctx, k8s_helm_t *h, + const cbm_gbuf_node_t *kust, const char *props) { + char chart[CBM_SZ_512] = ""; + char release[CBM_SZ_512] = ""; + char vfile[CBM_SZ_1K] = ""; + (void)k8s_json_str(props, "chart", chart, sizeof(chart)); + (void)k8s_json_str(props, "release", release, sizeof(release)); + (void)k8s_json_str(props, "values", vfile, sizeof(vfile)); + int len = 0; + char *src = kust->file_path ? k8s_repo_read(ctx, kust->file_path, &len) : NULL; + yyjson_mut_val *doc = src ? cbm_helm_values_parse(h->doc, src, (size_t)len) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, src); + /* kustomize runs the kustomization's own transformers over the inflated + * chart: images / patches / replacements may change what it deploys */ + static const char *const TRANSFORMS[] = { + "images", "patches", "patchesJson6902", "patchesStrategicMerge", + "replacements", "transformers", "components", NULL}; + for (int i = 0; TRANSFORMS[i]; i++) { + if (k8s_helm_gives(doc, TRANSFORMS[i])) { + return NULL; + } + } + yyjson_mut_val *charts = yyjson_mut_is_obj(doc) ? yyjson_mut_obj_get(doc, "helmCharts") : NULL; + yyjson_mut_val *inline_vals = NULL; + size_t idx, max; + yyjson_mut_val *c; + yyjson_mut_arr_foreach(charts, idx, max, c) { + const char *name = k8s_helm_meta_str(c, "name"); + const char *rel = k8s_helm_meta_str(c, "releaseName"); + if (name && strcmp(name, chart) == 0 && + (!release[0] || (rel && strcmp(rel, release) == 0))) { + const char *merge = k8s_helm_meta_str(c, "valuesMerge"); + if (k8s_helm_gives(c, "additionalValuesFiles") || + (merge && strcmp(merge, "override") != 0)) { + return NULL; + } + inline_vals = yyjson_mut_obj_get(c, "valuesInline"); + break; + } + } + yyjson_mut_val *file_vals = NULL; + if (vfile[0]) { + int fl = 0; + char *fs = k8s_repo_read(ctx, vfile, &fl); + file_vals = fs ? cbm_helm_values_parse(h->doc, fs, (size_t)fl) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, fs); + if (!file_vals) { + return NULL; /* a valuesFile not read: the release's values would be partial */ + } + } + if (!inline_vals && !file_vals) { + return NULL; + } + return cbm_helm_values_coalesce(h->doc, file_vals, inline_vals); +} + +/* Every release's images: HelmReleases and kustomize helmCharts from their + * DEPLOYS edges to a Chart, Terraform helm_releases from the context. */ +static void k8s_helm_link_releases(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b, k8s_helm_t *h) { + k8s_dep_link_t l = {.ctx = ctx, .charts = k8s_index_by_file(ctx, "Chart")}; + const cbm_gbuf_edge_t **es = NULL; + int n = 0; + if (ctx->repo_path && cbm_gbuf_find_edges_by_type(ctx->gbuf, "DEPLOYS", &es, &n) == 0) { + /* collected first: release edges are DEPLOYS too */ + typedef struct { + int64_t src; + int64_t tgt; + const char *props; + } k8s_rel_t; + k8s_rel_t *rels = n ? cbm_arena_alloc(&h->a, (size_t)n * sizeof(*rels)) : NULL; + int nr = 0; + for (int i = 0; rels && i < n; i++) { + const char *p = es[i]->properties_json; + if (p && + (strstr(p, "\"via\":\"helmrelease\"") || strstr(p, "\"via\":\"helmcharts\"")) && + !strstr(p, "\"ref\":")) { + rels[nr++] = + (k8s_rel_t){es[i]->source_id, es[i]->target_id, cbm_arena_strdup(&h->a, p)}; + } + } + for (int i = 0; i < nr; i++) { + const cbm_gbuf_node_t *src = cbm_gbuf_find_by_id(ctx->gbuf, rels[i].src); + if (!src) { + continue; + } + bool hr = strstr(rels[i].props, "\"via\":\"helmrelease\"") != NULL; + yyjson_mut_val *rv = NULL; + if (hr) { + rv = k8s_helm_flux_values(k8s_helm_node_yaml(ctx, h, src)); + } else { + rv = k8s_helm_kustomize_values(ctx, h, src, rels[i].props); + } + k8s_helm_release(ctx, b, h, &l, rels[i].src, hr ? "helmrelease" : "helmcharts", + rels[i].tgt, rv); + } + } + for (int i = 0; i < ctx->tf_release_n; i++) { + cbm_tf_release_t *r = &ctx->tf_releases[i]; + if (r->values) { + yyjson_doc *jd = yyjson_read(r->values, strlen(r->values), 0); + yyjson_mut_val *rv = jd ? yyjson_val_mut_copy(h->doc, yyjson_doc_get_root(jd)) : NULL; + if (jd) { + yyjson_doc_free(jd); + } + k8s_helm_release(ctx, b, h, &l, r->src, "terraform", r->chart, rv); + cbm_free(CBM_MEM_CLASS_OTHER, r->values); + } + } + cbm_free(CBM_MEM_CLASS_OTHER, ctx->tf_releases); + ctx->tf_releases = NULL; + ctx->tf_release_n = 0; + ctx->tf_release_cap = 0; + k8s_free_index(l.charts); +} + +static void k8s_helm_init(k8s_helm_t *h) { + *h = (k8s_helm_t){0}; + cbm_arena_init_lazy(&h->a, CBM_SZ_4K); + h->doc = yyjson_mut_doc_new(cbm_json_writer_allocator()); + h->charts = cbm_ht_create(0); + h->defines = cbm_ht_create(0); +} + +static void k8s_helm_free(k8s_helm_t *h) { + if (h->defines) { + cbm_ht_foreach(h->defines, k8s_helm_free_defines, NULL); + cbm_ht_free(h->defines); + } + if (h->charts) { + cbm_ht_free(h->charts); + } + if (h->doc) { + yyjson_mut_doc_free(h->doc); + } + cbm_free(CBM_MEM_CLASS_OTHER, h->t); + k8s_himgs_free(&h->dflt); + cbm_arena_destroy(&h->a); + if (h->edges > 0) { + cbm_log_info("pass.k8s.helm_images", "edges", itoa_k8s(h->edges)); + } + *h = (k8s_helm_t){0}; +} + +/* Each Image a chart's objects use (their USAGE {ref: image}, after * pass_docker.c moved references onto component Images): Chart -DEPLOYS - * {via: chart, ref: image}-> it. A templated image names no Image, and no - * values default is read. Edges are collected first: inserting while walking - * a chart's own DEPLOYS edges would grow the list being read. */ + * {via: chart, ref: image}-> it -- {default_of: the values keys} when every + * object of the chart uses it by a rendered default (the keys unioned), none + * when one names it literally. Edges are collected first: inserting while + * walking a chart's own DEPLOYS edges would grow the list being read. */ +typedef struct { + int64_t chart; + int64_t img; + bool literal; + bool lost; /* a mark that could not be carried whole: no edge, never an unmarked one */ + char *keys; /* JSON array (cbm_alloc); NULL: none yet */ +} k8s_cimg_t; + +/* JSON array `a` (owned, may be NULL) with the strings of JSON array value + * `bv` appended once; the result is owned. *lost when one could not be. */ +static char *k8s_keys_union(char *a, yyjson_val *bv, bool *lost) { + size_t idx, max; + yyjson_val *k; + yyjson_arr_foreach(bv, idx, max, k) { + const char *s = yyjson_get_str(k); + if (!s) { + continue; + } + /* JSON escaping writes at most 6 bytes (\u00XX) a byte */ + size_t qn = strlen(s) * 6 + PAIR_LEN + SKIP_ONE; + char *quoted = cbm_alloc(CBM_MEM_CLASS_OTHER, qn); + if (!quoted) { + *lost = true; + break; + } + quoted[0] = '"'; + cbm_json_escape(quoted + SKIP_ONE, (int)(qn - PAIR_LEN), s); + size_t ql = strlen(quoted); + quoted[ql++] = '"'; + quoted[ql] = '\0'; + if (a && strstr(a, quoted)) { + cbm_free(CBM_MEM_CLASS_OTHER, quoted); + continue; + } + size_t al = a ? strlen(a) : 0; + char *g = cbm_alloc(CBM_MEM_CLASS_OTHER, al + ql + 3); + if (!g) { + cbm_free(CBM_MEM_CLASS_OTHER, quoted); + *lost = true; + break; + } + if (a) { + snprintf(g, al + ql + 3, "%.*s,%s]", (int)(al - SKIP_ONE), a, quoted); + } else { + snprintf(g, ql + 3, "[%s]", quoted); + } + cbm_free(CBM_MEM_CLASS_OTHER, quoted); + cbm_free(CBM_MEM_CLASS_OTHER, a); + a = g; + } + return a; +} + static void k8s_link_chart_images(cbm_pipeline_ctx_t *ctx) { const cbm_gbuf_node_t **charts = NULL; int nc = 0; if (cbm_gbuf_find_by_label(ctx->gbuf, "Chart", &charts, &nc) != 0 || nc == 0) { return; } - int64_t *pairs = NULL; - int np = 0; + k8s_cimg_t *v = NULL; + int n = 0; int cap = 0; for (int c = 0; c < nc; c++) { const cbm_gbuf_edge_t **dep = NULL; @@ -3748,26 +5064,52 @@ static void k8s_link_chart_images(cbm_pipeline_ctx_t *ctx) { !strstr(up, "\"ref\":\"image\"")) { continue; } - if (np + PAIR_LEN > cap) { - int ncap = cap ? cap * PAIR_LEN : CBM_SZ_64; - int64_t *grown = - cbm_realloc(CBM_MEM_CLASS_OTHER, pairs, (size_t)ncap * sizeof(*pairs)); - if (!grown) { + int at = -SKIP_ONE; + for (int i = n - SKIP_ONE; i >= 0 && at < 0; i--) { + at = v[i].chart == charts[c]->id && v[i].img == img->id ? i : at; + } + if (at < 0) { + if (!k8s_grow((void **)&v, &cap, n, sizeof(*v))) { break; } - pairs = grown; - cap = ncap; + v[n] = (k8s_cimg_t){charts[c]->id, img->id, false, false, NULL}; + at = n++; + } + /* the USAGE's default_of, read as JSON (a key path may hold `]`) */ + yyjson_doc *ud = yyjson_read(up, strlen(up), 0); + yyjson_val *dv = ud ? yyjson_obj_get(yyjson_doc_get_root(ud), "default_of") : NULL; + if (!ud) { + v[at].lost = true; + } else if (!dv) { + v[at].literal = true; + } else if (yyjson_is_arr(dv)) { + v[at].keys = k8s_keys_union(v[at].keys, dv, &v[at].lost); + } else { + v[at].lost = true; + } + if (ud) { + yyjson_doc_free(ud); } - pairs[np++] = charts[c]->id; - pairs[np++] = img->id; } } } - for (int i = 0; i + SKIP_ONE < np; i += PAIR_LEN) { - cbm_gbuf_insert_edge(ctx->gbuf, pairs[i], pairs[i + SKIP_ONE], "DEPLOYS", - "{\"via\":\"chart\",\"ref\":\"image\"}"); + for (int i = 0; i < n; i++) { + bool marked = !v[i].literal && v[i].keys; + if (!v[i].literal && v[i].lost) { + cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys); + continue; /* the mark is not whole: no edge rather than an unmarked one */ + } + size_t pn = (marked ? strlen(v[i].keys) : 0) + CBM_SZ_64; + char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn); + if (props) { + snprintf(props, pn, "{\"via\":\"chart\",\"ref\":\"image\"%s%s}", + marked ? ",\"default_of\":" : "", marked ? v[i].keys : ""); + cbm_gbuf_insert_edge(ctx->gbuf, v[i].chart, v[i].img, "DEPLOYS", props); + cbm_free(CBM_MEM_CLASS_OTHER, props); + } + cbm_free(CBM_MEM_CLASS_OTHER, v[i].keys); } - cbm_free(CBM_MEM_CLASS_OTHER, pairs); + cbm_free(CBM_MEM_CLASS_OTHER, v); } /* A HelmRelease whose chartRef names OCIRepository `oci` (its USAGE edge @@ -3830,7 +5172,7 @@ static void k8s_prop_add(char *out, size_t out_sz, int *w, const char *key, cons * namespace, values, line} from the kustomization (its Kustomization * Resource, else its File -- the unit a Flux / Argo deploy reaches) to the * external Chart of that repository and name. */ -static void k8s_link_kcharts(k8s_dep_link_t *l, const k8s_facts_t *f) { +static void k8s_link_kcharts(k8s_dep_link_t *l, k8s_bundles_t *b, const k8s_facts_t *f) { for (int i = 0; i < f->kchart_n; i++) { const k8s_kchart_t *c = &f->kcharts[i]; if (!c->chart || strchr(c->chart, '/') || strchr(c->chart, '$')) { @@ -3838,8 +5180,18 @@ static void k8s_link_kcharts(k8s_dep_link_t *l, const k8s_facts_t *f) { } int64_t src = k8s_first_id(l->kusts, c->kust); src = src > 0 ? src : k8s_first_id(l->files, c->kust); + /* No repo: the chart in the kustomization's chartHome (charts/). */ + int64_t local = 0; + if (!c->url || !c->url[0]) { + char kdir[CBM_SZ_1K]; + char cdir[CBM_SZ_1K]; + k8s_dirname(c->kust, kdir, sizeof(kdir)); + snprintf(cdir, sizeof(cdir), "%s%scharts/%s", kdir, kdir[0] ? "/" : "", c->chart); + local = b->charts && cbm_ht_get(b->charts, cdir) ? k8s_chart_node(l, cdir) : 0; + } char props[CBM_SZ_4K]; - int w = snprintf(props, sizeof(props), "{\"via\":\"helmcharts\",\"external\":true"); + int w = snprintf(props, sizeof(props), "{\"via\":\"helmcharts\"%s", + local > 0 ? "" : ",\"external\":true"); k8s_prop_add(props, sizeof(props), &w, "chart", c->chart); k8s_prop_add(props, sizeof(props), &w, "url", c->url); k8s_prop_add(props, sizeof(props), &w, "version", c->version); @@ -3850,7 +5202,8 @@ static void k8s_link_kcharts(k8s_dep_link_t *l, const k8s_facts_t *f) { continue; } snprintf(props + w, sizeof(props) - (size_t)w, ",\"line\":%d}", c->line); - k8s_deploys_edge(l, src, k8s_external_chart(l, c->chart, c->url, c->kust), props); + k8s_deploys_edge( + l, src, local > 0 ? local : k8s_external_chart(l, c->chart, c->url, c->kust), props); } } @@ -4527,7 +5880,7 @@ static void k8s_link_deploys(cbm_pipeline_ctx_t *ctx, k8s_kusts_t *ks, k8s_bundl k8s_link_chart_refs(&l, &f->srcs[s]); } if (l.files && l.kusts) { - k8s_link_kcharts(&l, f); + k8s_link_kcharts(&l, b, f); } cbm_free(CBM_MEM_CLASS_OTHER, (void *)members.items); cbm_free(CBM_MEM_CLASS_OTHER, (void *)units.items); @@ -4603,6 +5956,14 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, k8s_chart_dir_add(&bundles, rel); helm_count++; break; + case K8S_WERF: { + char chart_dir[CBM_SZ_1K]; + if (handle_werf_chart(ctx, rel, p->source, chart_dir, sizeof(chart_dir))) { + k8s_chart_dir_register(&bundles, chart_dir); + helm_count++; + } + break; + } case K8S_KUSTOMIZE: k8s_kusts_push(&kusts, rel, p->source); k8s_kustomize_keep(&facts, rel, p->res); @@ -4647,6 +6008,9 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, k8s_link_crds(ctx, &bundles, &facts); k8s_link_deploys(ctx, &kusts, &bundles, &facts); k8s_link_images(ctx, &facts); + k8s_helm_t helm; + k8s_helm_init(&helm); + k8s_helm_collect(ctx, &facts, &helm); k8s_link_envs(ctx, &facts); k8s_link_patches(ctx, &kusts, &facts); k8s_link_renames(ctx, &kusts); @@ -4657,11 +6021,17 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, k8s_link_local_deps(ctx, &local_deps); k8s_link_includes(ctx, &bundles, files, file_count); k8s_link_chart_contents(ctx, &bundles); - k8s_bundles_free(&bundles); + /* The images chart templates render with their values (helm_template.c). */ + k8s_helm_link_defaults(ctx, &bundles, &helm); k8s_kusts_free(&kusts); /* Shell and Make deploy commands, against the nodes linked above. */ cbm_pipeline_pass_shell(ctx, files, file_count); cbm_pipeline_pass_terraform(ctx, files, file_count); + /* The images releases render (their values over the charts' defaults), + * before the image hubs move references. */ + k8s_helm_link_releases(ctx, &bundles, &helm); + k8s_helm_free(&helm); + k8s_bundles_free(&bundles); cbm_pipeline_pass_ansible(ctx, files, file_count); cbm_pipeline_pass_docker(ctx, files, file_count); /* The Images a chart's objects use, once the image hubs moved them. */ diff --git a/src/pipeline/pass_shell.c b/src/pipeline/pass_shell.c index 8e6f82ce91..334322d15b 100644 --- a/src/pipeline/pass_shell.c +++ b/src/pipeline/pass_shell.c @@ -376,10 +376,12 @@ typedef struct { const char *ci; /* static: the CI running the command; NULL: none */ const char *job; /* pass-arena: its job; NULL: none */ bool chart; - bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */ - bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */ - bool build; /* the image `ext` is built from the Dockerfile `path` */ - bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */ + bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */ + bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */ + bool build; /* the image `ext` is built from the Dockerfile `path` */ + bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */ + const char *build_args; /* pass-arena, build: --build-arg as a JSON object; NULL: none */ + const char *default_of; /* pass-arena, build: JSON array of the ${X:-d} defaults naming `ext` */ } sh_hit_t; typedef struct { @@ -413,9 +415,10 @@ typedef struct { const char *s; /* scalar value; NULL: unknown */ const char **items; /* array items (an item NULL: unknown); NULL: no array */ int n; + const char *dflt_of; /* JSON array: the variables whose ${X:-d} default made it */ } sh_val_t; -static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0}; +static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0, NULL}; typedef enum { SH_FREE, SH_AT, SH_LOST } sh_cwd_mode_t; @@ -464,8 +467,38 @@ typedef struct { sh_cwd_t *dirs; /* pushd stack */ int ndirs; int cap_dirs; + /* While set, an evaluation adds to dflt_acc (a JSON array) the variables + * whose ${X:-d} default supplied its value. */ + bool dflt_track; + const char *dflt_acc; } sh_t; +/* JSON array `list` (NULL: empty) with identifier `name` appended once. */ +static const char *sh_names_add(CBMArena *a, const char *list, const char *name, size_t nl) { + if (!list) { + return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name); + } + char quoted[CBM_SZ_256]; + snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name); + if (strstr(list, quoted)) { + return list; + } + return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted); +} + +/* JSON array `a` with the names of JSON array `b` appended once. */ +static const char *sh_names_union(CBMArena *ar, const char *a, const char *b) { + for (const char *p = b ? strchr(b, '"') : NULL; p;) { + const char *e = strchr(p + SKIP_ONE, '"'); + if (!e) { + break; + } + a = sh_names_add(ar, a, p + SKIP_ONE, (size_t)(e - p - SKIP_ONE)); + p = strchr(e + SKIP_ONE, '"'); + } + return a; +} + static void sh_set(sh_t *s, const char *name, const sh_val_t *v, bool local) { if (!sh_grow((void **)&s->undo, &s->cap_undo, s->nundo, sizeof(*s->undo))) { return; @@ -622,14 +655,14 @@ static const sh_val_t *sh_lookup(sh_t *s, const char *name, bool *found) { } sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v)); if (v) { - *v = (sh_val_t){s->script, NULL, 0}; + *v = (sh_val_t){s->script, NULL, 0, NULL}; } return v ? v : &SH_UNKNOWN_VAL; } if (strcmp(name, "PWD") == 0) { sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v)); if (v) { - *v = (sh_val_t){sh_cwd_value(s), NULL, 0}; + *v = (sh_val_t){sh_cwd_value(s), NULL, 0, NULL}; } return v ? v : &SH_UNKNOWN_VAL; } @@ -745,20 +778,29 @@ static const char *sh_eval_expansion(sh_t *s, TSNode n) { val = v->n > 0 ? v->items[0] : ""; } if (!op) { + if (found && s->dflt_track) { + s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of); + } return found ? val : NULL; } bool colon = op[0] == ':'; bool use_default = !found || (colon && val && !val[0]); if (!use_default) { + if (s->dflt_track) { + s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of); + } return val; } sh_str_t b = {0}; sh_concat(s, &b, n, op_end, close, true); const char *d = sh_str_done(&b); + if (s->dflt_track) { + s->dflt_acc = sh_names_add(s->a, s->dflt_acc, nm, strlen(nm)); + } if (d && strchr(op, '=')) { sh_val_t *nv = cbm_arena_alloc(s->a, sizeof(*nv)); if (nv) { - *nv = (sh_val_t){d, NULL, 0}; + *nv = (sh_val_t){d, NULL, 0, sh_names_add(s->a, NULL, nm, strlen(nm))}; sh_set(s, nm, nv, false); } } @@ -943,6 +985,9 @@ static const char *sh_eval(sh_t *s, TSNode n) { } bool found = false; const sh_val_t *val = sh_lookup(s, nm, &found); + if (found && s->dflt_track) { + s->dflt_acc = sh_names_union(s->a, s->dflt_acc, val->dflt_of); + } return found ? val->s : NULL; } if (strcmp(t, "expansion") == 0) { @@ -1319,6 +1364,8 @@ static void sh_hit_push(sh_t *s, TSNode cmd, sh_hit_t x) { x.line = (int)ts_node_start_point(cmd).row + SKIP_ONE + s->line_base; x.ext = x.ext ? cbm_arena_strdup(&h->arena, x.ext) : NULL; x.path = x.path ? cbm_arena_strdup(&h->arena, x.path) : NULL; + x.build_args = x.build_args ? cbm_arena_strdup(&h->arena, x.build_args) : NULL; + x.default_of = x.default_of ? cbm_arena_strdup(&h->arena, x.default_of) : NULL; x.ci = s->ci; x.job = s->job ? cbm_arena_strdup(&h->arena, s->job) : NULL; h->items[h->count++] = x; @@ -2268,7 +2315,11 @@ static void sh_assign(sh_t *s, TSNode n, bool local) { if (!nm || !v) { return; } - *v = (sh_val_t){"", NULL, 0}; + *v = (sh_val_t){"", NULL, 0, NULL}; + bool track = s->dflt_track; + const char *acc = s->dflt_acc; + s->dflt_track = true; + s->dflt_acc = NULL; if (sh_type(value, "array")) { uint32_t cnt = ts_node_named_child_count(value); const char **items = cbm_arena_alloc(s->a, (cnt ? cnt : SKIP_ONE) * sizeof(*items)); @@ -2282,10 +2333,13 @@ static void sh_assign(sh_t *s, TSNode n, bool local) { items[k++] = sh_eval_word(s, c); } } - *v = (sh_val_t){k > 0 ? items[0] : "", items, k}; + *v = (sh_val_t){k > 0 ? items[0] : "", items, k, NULL}; } else if (!ts_node_is_null(value)) { v->s = sh_eval(s, value); } + v->dflt_of = s->dflt_acc; + s->dflt_track = track; + s->dflt_acc = acc; bool append = false; for (uint32_t i = 0; i < ts_node_child_count(n); i++) { TSNode c = ts_node_child(n, i); @@ -2434,6 +2488,64 @@ static const char *sh_image_repo(sh_t *s, TSNode cmd, const sh_argv_t *a, int i) return sh_str_done(&out); } +/* JSON object `json` (NULL: none) with build argument `kv` ("K=V") added; a + * `K` alone (the environment's value), an unknown word or a secret (by name + * or value -- never kept in the graph, never an image) adds nothing. */ +static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) { + const char *eq = kv ? strchr(kv, '=') : NULL; + if (!eq || eq == kv) { + return json; + } + for (const char *p = kv; p < eq; p++) { + if (!isalnum((unsigned char)*p) && *p != '_') { + return json; + } + } + const char *key = cbm_arena_strndup(s->a, kv, (size_t)(eq - kv)); + if (!key || cbm_is_secret_binding(key, eq + SKIP_ONE)) { + return json; + } + /* JSON escaping writes at most 6 bytes a byte: nothing is cut */ + int vn = (int)(strlen(eq + SKIP_ONE) * 6 + SKIP_ONE); + char *ev = cbm_arena_alloc(s->a, (size_t)vn); + if (!ev) { + return json; + } + cbm_json_escape(ev, vn, eq + SKIP_ONE); + const char *pair = cbm_arena_sprintf(s->a, "\"%.*s\":\"%s\"", (int)(eq - kv), kv, ev); + if (!pair) { + return json; + } + return json ? cbm_arena_sprintf(s->a, "%.*s,%s}", (int)(strlen(json) - SKIP_ONE), json, pair) + : cbm_arena_sprintf(s->a, "{%s}", pair); +} + +/* The variables whose ${X:-d} default supplied argv word `i` (a JSON array, + * NULL: none): the word evaluated once more with the defaults recorded. */ +static const char *sh_word_defaults(sh_t *s, TSNode cmd, const sh_argv_t *a, int i) { + uint32_t b = a->span[(size_t)i * PAIR_LEN]; + uint32_t e = a->span[(size_t)i * PAIR_LEN + SKIP_ONE]; + if (!memchr(s->src + b, '$', e - b)) { + return NULL; + } + TSNode w = ts_node_descendant_for_byte_range(cmd, b, e); + while (!ts_node_is_null(w) && (ts_node_start_byte(w) != b || ts_node_end_byte(w) != e)) { + w = ts_node_parent(w); + } + if (ts_node_is_null(w)) { + return NULL; + } + bool track = s->dflt_track; + const char *acc = s->dflt_acc; + s->dflt_track = true; + s->dflt_acc = NULL; + (void)sh_eval_word(s, w); + const char *out = s->dflt_acc; + s->dflt_track = track; + s->dflt_acc = acc; + return out; +} + /* docker / podman / nerdctl [buildx | image | builder] build, buildah bud: * each tagged image is built from the Dockerfile -f names (against the cwd), * else /Dockerfile -- one file of the repository. */ @@ -2467,17 +2579,20 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) { cbm_ht_set(s->hits->declarers, s->file, (void *)s->file); } int tags[SH_BUILD_TAGS]; + int tag_words[SH_BUILD_TAGS]; const char *tag_vals[SH_BUILD_TAGS]; int nt = 0; const char *dfile = NULL; bool dfile_known = true; const char *ctx = NULL; bool ctx_seen = false; + const char *args = NULL; for (; i < a->n; i++) { const char *w = a->v[i]; if (w && (sh_eq(w, "-t") || sh_eq(w, "--tag")) && i + SKIP_ONE < a->n) { if (nt < SH_BUILD_TAGS) { tags[nt] = i + SKIP_ONE; + tag_words[nt] = i + SKIP_ONE; tag_vals[nt++] = a->v[i + SKIP_ONE]; } i++; @@ -2485,9 +2600,15 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) { dfile = a->v[i + SKIP_ONE]; dfile_known = dfile != NULL; i++; + } else if (w && sh_eq(w, "--build-arg") && i + SKIP_ONE < a->n) { + args = sh_build_arg(s, args, a->v[i + SKIP_ONE]); + i++; + } else if (w && strncmp(w, "--build-arg=", strlen("--build-arg=")) == 0) { + args = sh_build_arg(s, args, w + strlen("--build-arg=")); } else if (w && strncmp(w, "--tag=", strlen("--tag=")) == 0) { if (nt < SH_BUILD_TAGS && w[strlen("--tag=")]) { tags[nt] = -SKIP_ONE; + tag_words[nt] = i; tag_vals[nt++] = w + strlen("--tag="); } } else if (w && strncmp(w, "--file=", strlen("--file=")) == 0) { @@ -2513,7 +2634,12 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) { } if (img && img[0]) { sh_hit_push(s, cmd, - (sh_hit_t){.via = "docker-build", .ext = img, .path = hit, .build = true}); + (sh_hit_t){.via = "docker-build", + .ext = img, + .path = hit, + .build = true, + .build_args = args, + .default_of = sh_word_defaults(s, cmd, a, tag_words[k])}); } } } @@ -3305,8 +3431,8 @@ static void sh_makefile(sh_pass_t *p, const char *rel, const char *src, size_t l sh_val_t *root = cbm_arena_alloc(&a, sizeof(*root)); sh_val_t *cur = cbm_arena_alloc(&a, sizeof(*cur)); if (root && cur) { - *root = (sh_val_t){SH_ROOT, NULL, 0}; - *cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0}; + *root = (sh_val_t){SH_ROOT, NULL, 0, NULL}; + *cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0, NULL}; cbm_ht_set(s.env, "__cbm_root", root); if (m.makefile) { cbm_ht_set(s.env, "__cbm_curdir", cur); @@ -3647,15 +3773,25 @@ static int sh_link_build(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, CBMHashTabl char esc_file[CBM_SZ_1K]; char esc_img[CBM_SZ_1K]; char ci[CBM_SZ_1K]; - char props[CBM_SZ_4K]; cbm_json_escape(esc_file, (int)sizeof(esc_file), x->file); cbm_json_escape(esc_img, (int)sizeof(esc_img), x->ext); sh_ci_props(x, ci, sizeof(ci)); - snprintf(props, sizeof(props), + /* sized to what it holds: default_of and build_args are never cut */ + size_t pn = strlen(esc_file) + strlen(esc_img) + strlen(ci) + + (x->default_of ? strlen(x->default_of) : 0) + + (x->build_args ? strlen(x->build_args) : 0) + CBM_SZ_256; + char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn); + if (!props) { + return 0; + } + snprintf(props, pn, "{\"via\":\"build\",\"tool\":\"docker-build\",\"file\":\"%s\",\"image\":\"%s\"%s," - "\"line\":%d}", - esc_file, esc_img, ci, x->line); + "\"line\":%d%s%s%s%s}", + esc_file, esc_img, ci, x->line, x->default_of ? ",\"default_of\":" : "", + x->default_of ? x->default_of : "", x->build_args ? ",\"build_args\":" : "", + x->build_args ? x->build_args : ""); cbm_gbuf_insert_edge(ctx->gbuf, img, mod, "DEPENDS_ON", props); + cbm_free(CBM_MEM_CLASS_OTHER, props); return SKIP_ONE; } @@ -3852,7 +3988,7 @@ typedef struct { static void sh_ci_var(sh_t *s, const char *name, const char *value) { sh_val_t *v = value ? cbm_arena_alloc(s->a, sizeof(*v)) : NULL; if (v) { - *v = (sh_val_t){value, NULL, 0}; + *v = (sh_val_t){value, NULL, 0, NULL}; } sh_set(s, name, v ? v : &SH_UNKNOWN_VAL, false); } diff --git a/src/pipeline/pass_terraform.c b/src/pipeline/pass_terraform.c index 6328311c17..e9abd9c389 100644 --- a/src/pipeline/pass_terraform.c +++ b/src/pipeline/pass_terraform.c @@ -22,13 +22,16 @@ #include "foundation/log.h" #include "foundation/mem_core.h" #include "foundation/str_util.h" +#include "foundation/json_writer.h" /* cbm_json_writer_allocator */ #include "graph_buffer/graph_buffer.h" +#include "pipeline/helm_template.h" #include "pipeline/pipeline.h" #include "pipeline/pipeline_internal.h" #include "cbm.h" #include "helpers.h" #include "lang_specs.h" +#include #include #include #include @@ -249,6 +252,522 @@ static bool tf_plain_name(const char *s) { } /* One `resource "helm_release" "" { ... }` block. */ +/* ── helm_release values (for pass_k8s.c's release images) ────────── */ + +/* The expression inside wrapper nodes. */ +static TSNode tf_unwrap(TSNode n) { + while (tf_type(n, "expression") || tf_type(n, "literal_value") || + tf_type(n, "collection_value") || tf_type(n, "template_expr")) { + if (ts_node_named_child_count(n) != SKIP_ONE) { + break; + } + n = ts_node_named_child(n, 0); + } + return n; +} + +/* A heredoc's text (a `<<-` one dedented); NULL when it interpolates. */ +static char *tf_heredoc(const tf_t *t, TSNode n) { + TSNode open = {0}; + TSNode close = {0}; + bool dedent = false; + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + if (tf_type(c, "heredoc_start")) { + uint32_t b = ts_node_start_byte(c); + dedent = ts_node_end_byte(c) - b >= 3 && t->src[b + PAIR_LEN] == '-'; + } else if (tf_type(c, "heredoc_identifier")) { + if (ts_node_is_null(open)) { + open = c; + } else { + close = c; + } + } else if (!tf_type(c, "template_literal")) { + return NULL; /* an interpolation or a directive: no literal */ + } + } + if (ts_node_is_null(open) || ts_node_is_null(close)) { + return NULL; + } + const char *b = t->src + ts_node_end_byte(open); + const char *e = t->src + ts_node_start_byte(close); + const char *nl = memchr(b, '\n', (size_t)(e - b)); + b = nl ? nl + SKIP_ONE : e; + while (e > b && e[-1] != '\n') { + e--; /* the closing line's indentation */ + } + size_t indent = (size_t)-1; + for (const char *p = b; dedent && p < e;) { + const char *le = memchr(p, '\n', (size_t)(e - p)); + le = le ? le : e; + size_t sp = 0; + while (p + sp < le && (p[sp] == ' ' || p[sp] == '\t')) { + sp++; + } + if (p + sp < le && sp < indent) { + indent = sp; + } + p = le + (le < e ? SKIP_ONE : 0); + } + if (!dedent || indent == (size_t)-1) { + indent = 0; + } + char *out = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)(e - b) + SKIP_ONE); + size_t k = 0; + for (const char *p = b; out && p < e;) { + const char *le = memchr(p, '\n', (size_t)(e - p)); + le = le ? le + SKIP_ONE : e; + size_t skip = 0; + while (skip < indent && p + skip < le && (p[skip] == ' ' || p[skip] == '\t')) { + skip++; + } + memcpy(out + k, p + skip, (size_t)(le - p) - skip); + k += (size_t)(le - p) - skip; + p = le; + } + if (out) { + out[k] = '\0'; + } + return out; +} + +/* HCL number literal s (digits, a fraction, an exponent) as Terraform turns a + * number into a string: exact decimal, no exponent, no trailing zeros (cty's + * big.Float Text('f', -1); 1.10 -> "1.1", 2e3 -> "2000"). False when it is + * no such literal or does not fit out. */ +static bool tf_number_text(const char *s, size_t n, char *out, size_t sz) { + enum { TF_MAX_EXP = 4096 }; + char dig[CBM_SZ_256]; + size_t nd = 0; + long exp = 0; + bool frac = false; + size_t i = 0; + for (; i < n && (isdigit((unsigned char)s[i]) || s[i] == '.'); i++) { + if (s[i] == '.') { + if (frac) { + return false; + } + frac = true; + continue; + } + if (nd + SKIP_ONE >= sizeof(dig)) { + return false; + } + dig[nd++] = s[i]; + exp -= frac ? SKIP_ONE : 0; + } + if (i < n) { + if (s[i] != 'e' && s[i] != 'E') { + return false; + } + i++; + bool neg = i < n && s[i] == '-'; + i += i < n && (s[i] == '-' || s[i] == '+'); + long e = 0; + if (i == n) { + return false; + } + for (; i < n; i++) { + if (!isdigit((unsigned char)s[i]) || e > TF_MAX_EXP) { + return false; + } + e = e * 10 + (s[i] - '0'); + } + exp += neg ? -e : e; + } + size_t b = 0; + while (b < nd && dig[b] == '0') { + b++; + } + if (b == nd) { + return snprintf(out, sz, "0") == SKIP_ONE; /* every digit 0 (or none) */ + } + size_t e = nd; + while (dig[e - SKIP_ONE] == '0') { /* stops at dig[b], which is no 0 */ + e--; + exp++; + } + size_t m = e - b; /* significant digits: dig[b, e) x 10^exp */ + size_t o = 0; + long point = (long)m + exp; /* digits before the decimal point */ + size_t need = exp >= 0 ? m + (size_t)exp + : point > 0 ? m + SKIP_ONE + : m + PAIR_LEN + (size_t)-point; + if (need + SKIP_ONE > sz) { + return false; + } + if (point <= 0) { + out[o++] = '0'; + out[o++] = '.'; + for (long z = 0; z < -point; z++) { + out[o++] = '0'; + } + } + for (size_t k = 0; k < m; k++) { + if (point > 0 && (long)k == point) { + out[o++] = '.'; + } + out[o++] = dig[b + k]; + } + for (long z = 0; z < exp; z++) { + out[o++] = '0'; + } + out[o] = '\0'; + return true; +} + +/* An HCL literal as JSON: a string without interpolation, a number, a bool, + * null, a tuple or object of those; NULL: anything else. `num_text`: a number + * as the string Terraform makes of it (a helm_release set value is a string), + * else a float64. */ +static yyjson_mut_val *tf_json_v(const tf_t *t, yyjson_mut_doc *doc, TSNode n, bool num_text) { + n = tf_unwrap(n); + if (tf_type(n, "string_lit") || tf_type(n, "quoted_template")) { + char buf[CBM_SZ_4K]; + size_t len = 0; + buf[0] = '\0'; + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + if (tf_type(c, "quoted_template_start") || tf_type(c, "quoted_template_end")) { + continue; + } + if (!tf_type(c, "template_literal")) { + return NULL; + } + uint32_t b = ts_node_start_byte(c); + if (!tf_add(buf, sizeof(buf), &len, t->src + b, ts_node_end_byte(c) - b)) { + return NULL; + } + } + size_t k = 0; /* \" \\ \n \t */ + for (size_t i = 0; i < len; i++) { + if (buf[i] == '\\' && i + SKIP_ONE < len) { + char c = buf[++i]; + buf[k++] = c == 'n' ? '\n' : c == 't' ? '\t' : c; + } else { + buf[k++] = buf[i]; + } + } + return yyjson_mut_strncpy(doc, buf, k); + } + if (tf_type(n, "heredoc_template")) { + char *s = tf_heredoc(t, n); + yyjson_mut_val *v = s ? yyjson_mut_strcpy(doc, s) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, s); + return v; + } + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + if (tf_type(n, "numeric_lit")) { + char buf[CBM_SZ_256]; + if (num_text) { + return tf_number_text(t->src + b, e - b, buf, sizeof(buf)) ? yyjson_mut_strcpy(doc, buf) + : NULL; + } + snprintf(buf, sizeof(buf), "%.*s", (int)(e - b), t->src + b); + return yyjson_mut_real(doc, strtod(buf, NULL)); + } + if (tf_type(n, "bool_lit")) { + return yyjson_mut_bool(doc, e - b == strlen("true") && strncmp(t->src + b, "true", 4) == 0); + } + if (tf_type(n, "null_lit")) { + return yyjson_mut_null(doc); + } + if (tf_type(n, "tuple")) { + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (uint32_t i = 0; arr && i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + if (tf_type(c, "tuple_start") || tf_type(c, "tuple_end")) { + continue; + } + yyjson_mut_val *v = tf_json_v(t, doc, c, num_text); + if (!v) { + return NULL; + } + yyjson_mut_arr_append(arr, v); + } + return arr; + } + if (tf_type(n, "object")) { + yyjson_mut_val *obj = yyjson_mut_obj(doc); + for (uint32_t i = 0; obj && i < ts_node_named_child_count(n); i++) { + TSNode c = ts_node_named_child(n, i); + if (!tf_type(c, "object_elem")) { + continue; + } + TSNode kn = tf_unwrap(ts_node_child_by_field_name(c, "key", (uint32_t)strlen("key"))); + TSNode vn = ts_node_child_by_field_name(c, "val", (uint32_t)strlen("val")); + yyjson_mut_val *key = NULL; + if (tf_type(kn, "variable_expr") && ts_node_named_child_count(kn) == SKIP_ONE) { + TSNode id = ts_node_named_child(kn, 0); + uint32_t kb = ts_node_start_byte(id); + key = yyjson_mut_strncpy(doc, t->src + kb, ts_node_end_byte(id) - kb); + } else { + key = tf_json_v(t, doc, kn, false); + } + yyjson_mut_val *val = tf_json_v(t, doc, vn, num_text); + if (!key || !yyjson_mut_is_str(key) || !val) { + return NULL; + } + (void)yyjson_mut_obj_put(obj, key, val); + } + return obj; + } + return NULL; +} + +static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) { + return tf_json_v(t, doc, n, false); +} + +static char *tf_read(const char *path, size_t *len); + +/* A repository file's text (cbm_alloc; capped and padded like every file + * read; an empty file is empty text), NULL when unreadable -- or when it is + * no file discovery took in: a symlink, or a path through one, is never + * followed out of the repository. */ +static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) { + if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) { + return NULL; + } + char full[CBM_SZ_4K]; + int n = snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path); + if (n <= 0 || (size_t)n >= sizeof(full)) { + return NULL; + } + char *buf = tf_read(full, len); + cbm_path_info_t info; + if (!buf && cbm_path_info_utf8(full, &info) == CBM_PATH_INFO_OK && info.is_regular && + !info.is_symlink && info.size == 0) { + enum { TF_EMPTY_PAD = 16 }; + buf = cbm_calloc(CBM_MEM_CLASS_OTHER, TF_EMPTY_PAD); + *len = 0; + } + return buf; +} + +/* A `values = [...]` item as values: a YAML string / heredoc, file() + * of a repository file, yamlencode / jsonencode of a literal; NULL: none of + * those. */ +static yyjson_mut_val *tf_values_item(const tf_t *t, yyjson_mut_doc *doc, TSNode item, CBMArena *a, + const char ***files, int *nfiles) { + TSNode n = tf_unwrap(item); + if (tf_type(n, "function_call")) { + TSNode fn = ts_node_named_child(n, 0); + TSNode args = {0}; + for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) { + if (tf_type(ts_node_named_child(n, i), "function_arguments")) { + args = ts_node_named_child(n, i); + } + } + if (ts_node_is_null(fn) || ts_node_is_null(args) || + ts_node_named_child_count(args) != SKIP_ONE) { + return NULL; + } + uint32_t fb = ts_node_start_byte(fn); + size_t fl = ts_node_end_byte(fn) - fb; + TSNode arg = ts_node_named_child(args, 0); + if ((fl == strlen("yamlencode") && strncmp(t->src + fb, "yamlencode", fl) == 0) || + (fl == strlen("jsonencode") && strncmp(t->src + fb, "jsonencode", fl) == 0)) { + return tf_json(t, doc, arg); + } + if (fl == strlen("file") && strncmp(t->src + fb, "file", fl) == 0) { + char v[CBM_SZ_1K]; + char path[CBM_SZ_1K]; + if (!t->ctx->repo_path || !tf_string(t->src, t->mdir, arg, v, sizeof(v)) || + !tf_repo_path(t->mdir, v, path, sizeof(path))) { + return NULL; + } + const char **grown = cbm_arena_alloc(a, (size_t)(*nfiles + SKIP_ONE) * sizeof(*grown)); + if (grown) { + if (*nfiles) { + memcpy(grown, *files, (size_t)*nfiles * sizeof(*grown)); + } + grown[(*nfiles)++] = cbm_arena_strdup(a, path); + *files = grown; + } + size_t len = 0; + char *buf = tf_read_repo(t, path, &len); + yyjson_mut_val *out = buf ? cbm_helm_values_parse(doc, buf, len) : NULL; + cbm_free(CBM_MEM_CLASS_OTHER, buf); + return out; + } + return NULL; + } + yyjson_mut_val *s = tf_json(t, doc, n); + return yyjson_mut_is_str(s) + ? cbm_helm_values_parse(doc, yyjson_mut_get_str(s), yyjson_mut_get_len(s)) + : NULL; +} + +/* A `set` entry (name / value / type; read with numbers as text) applied to + * root; false when it is no literal. */ +static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *entry) { + yyjson_mut_val *nm = yyjson_mut_obj_get(entry, "name"); + yyjson_mut_val *val = yyjson_mut_obj_get(entry, "value"); + yyjson_mut_val *ty = yyjson_mut_obj_get(entry, "type"); + if (!yyjson_mut_is_str(nm) || !val) { + return false; + } + const char *v = NULL; + if (yyjson_mut_is_str(val)) { + v = yyjson_mut_get_str(val); + } else if (yyjson_mut_is_bool(val)) { + v = yyjson_mut_get_bool(val) ? "true" : "false"; + } + bool as_string = yyjson_mut_is_str(ty) && strcmp(yyjson_mut_get_str(ty), "string") == 0; + return v && cbm_helm_values_set(doc, root, yyjson_mut_get_str(nm), v, as_string); +} + +/* A `set` block's attributes as an object; NULL when one is no literal. */ +static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode block) { + yyjson_mut_val *entry = yyjson_mut_obj(doc); + for (uint32_t j = 0; entry && j < ts_node_named_child_count(block); j++) { + TSNode sb = ts_node_named_child(block, j); + if (!tf_type(sb, "body")) { + continue; + } + for (uint32_t k = 0; k < ts_node_named_child_count(sb); k++) { + TSNode at = ts_node_named_child(sb, k); + if (!tf_type(at, "attribute") || ts_node_named_child_count(at) < PAIR_LEN) { + continue; + } + TSNode key = ts_node_named_child(at, 0); + uint32_t b = ts_node_start_byte(key); + yyjson_mut_val *v = tf_json_v(t, doc, ts_node_named_child(at, SKIP_ONE), true); + if (!v) { + return NULL; + } + yyjson_mut_obj_add(entry, + yyjson_mut_strncpy(doc, t->src + b, ts_node_end_byte(key) - b), v); + } + } + return entry; +} + +/* 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); + if (!tf_type(a, "block") || ts_node_is_null(kw) || ts_node_is_null(label)) { + return false; + } + uint32_t kb = ts_node_start_byte(kw); + if (ts_node_end_byte(kw) - kb != strlen("dynamic") || + strncmp(t->src + kb, "dynamic", strlen("dynamic")) != 0) { + return false; + } + uint32_t lb = ts_node_start_byte(label); + size_t ll = ts_node_end_byte(label) - lb; + return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) || + (ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0) || + (ll == strlen("\"set_sensitive\"") && + 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`? */ +static bool tf_word(const tf_t *t, TSNode n, const char *word) { + if (ts_node_is_null(n)) { + return false; + } + uint32_t b = ts_node_start_byte(n); + size_t l = ts_node_end_byte(n) - b; + return l == strlen(word) && strncmp(t->src + b, word, l) == 0; +} + +/* The values a helm_release body gives its chart -- `values` items + * coalesced in order, then each `set` -- as JSON (cbm_alloc); NULL when a + * part is no literal, 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; + 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); + TSNode key = ts_node_named_child(a, 0); + uint32_t kb = ts_node_is_null(key) ? 0 : ts_node_start_byte(key); + 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, 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; + } + 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"); + 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")) { + continue; + } + yyjson_mut_val *v = tf_values_item(t, doc, it, t->arena, files, nfiles); + ok = v && yyjson_mut_is_obj(v); + root = ok ? cbm_helm_values_coalesce(doc, root, v) : root; + } + } else if (pass == SKIP_ONE && is_set && tf_type(a, "block")) { + yyjson_mut_val *entry = tf_set_block(t, doc, a); + ok = entry && tf_set_entry(doc, root, entry); + } else if (pass == SKIP_ONE && is_set && tf_type(a, "attribute")) { + /* provider v3: set = [{ name, value, type }, ...] */ + yyjson_mut_val *list = tf_json_v(t, doc, ts_node_named_child(a, SKIP_ONE), true); + ok = yyjson_mut_is_arr(list); + size_t idx, max; + yyjson_mut_val *entry; + yyjson_mut_arr_foreach(list, idx, max, entry) { + ok = ok && tf_set_entry(doc, root, entry); + } + } + } + } + char *out = NULL; + if (ok) { + size_t len = 0; + out = yyjson_mut_val_write_opts(root, 0, cbm_json_writer_allocator(), &len, NULL); + } + if (doc) { + yyjson_mut_doc_free(doc); + } + return out; +} + +static void tf_release_push(tf_t *t, int64_t src, int64_t chart, TSNode body) { + cbm_pipeline_ctx_t *ctx = t->ctx; + if (ctx->tf_release_n == ctx->tf_release_cap) { + int cap = ctx->tf_release_cap ? ctx->tf_release_cap * PAIR_LEN : CBM_SZ_16; + cbm_tf_release_t *g = + cbm_realloc(CBM_MEM_CLASS_OTHER, ctx->tf_releases, (size_t)cap * sizeof(*g)); + if (!g) { + return; + } + ctx->tf_releases = g; + ctx->tf_release_cap = cap; + } + const char **files = NULL; + int nfiles = 0; + 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++) { + const char *mqn = cbm_fqn_module(t->arena, ctx->project_name, files[i]); + const cbm_gbuf_node_t *mod = mqn ? cbm_gbuf_find_by_qn(ctx->gbuf, mqn) : NULL; + if (mod && mod->id != src) { + cbm_gbuf_insert_edge(ctx->gbuf, src, mod->id, "IMPORTS", "{\"local_name\":\"values\"}"); + t->edges++; + } + } +} + static void tf_helm_release(tf_t *t, TSNode block, const char *name) { TSNode body = {0}; for (uint32_t i = 0; i < ts_node_named_child_count(block); i++) { @@ -320,6 +839,7 @@ static void tf_helm_release(tf_t *t, TSNode block, const char *name) { external ? ",\"external\":true" : "", (int)ts_node_start_point(block).row + SKIP_ONE); cbm_gbuf_insert_edge(t->ctx->gbuf, src, tgt, "DEPLOYS", props); t->edges++; + tf_release_push(t, src, tgt, body); } /* The text of a string_lit label: "helm_release" -> helm_release. */ diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 99df053e41..bb97c361dd 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2456,8 +2456,9 @@ static bool closure_dir_has_compose(const char *repo_path, const char *rel, cons return found; } -/* The nearest directory at or above `rel`'s that holds a Chart.yaml, into out - * ("" for the repository root); false when none does. */ +/* The nearest directory at or above `rel`'s that holds a Chart.yaml, or is a + * werf `.helm` chart, into out ("" for the repository root); false when none + * is. */ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *out, size_t sz) { snprintf(out, sz, "%s", rel); char *cut = strrchr(out, '/'); @@ -2475,6 +2476,9 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o return true; } } + if (out[0] && cbm_werf_helm_dir(repo_path, out)) { + return true; + } if (!out[0]) { return false; } @@ -2482,6 +2486,33 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o } } +/* Is `rel` a file a release reads as values (an IMPORTS {local_name: + * valuesFile | values} edge reaches its node)? */ +static bool closure_release_values(cbm_store_t *store, const char *project, const char *rel) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) { + return false; + } + bool hit = false; + for (int i = 0; i < count && !hit; i++) { + cbm_edge_t *edges = NULL; + int ne = 0; + if (cbm_store_find_edges_by_target_type(store, nodes[i].id, "IMPORTS", &edges, &ne) != + CBM_STORE_OK) { + continue; + } + for (int j = 0; j < ne && !hit; j++) { + const char *p = edges[j].properties_json; + hit = p && (strstr(p, "\"local_name\":\"valuesFile\"") || + strstr(p, "\"local_name\":\"values\"")); + } + cbm_store_free_edges(edges, ne); + } + cbm_store_free_nodes(nodes, count); + return hit; +} + /* Kubernetes inputs reach past their own file (pass_k8s.c): a kustomization * decides which manifests deploy together and in which namespace, a * Chart.yaml links other charts, and a manifest's Services select workloads @@ -2498,7 +2529,9 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project, if (cbm_is_kustomize_file(base) || strcmp(base, "Kustomization") == 0) { return "k8s_bundle_input_changed"; } - if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0) { + /* A werf.yaml names the chart of the `.helm` dir beside it. */ + if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0 || + strcmp(base, "werf.yaml") == 0 || strcmp(base, "werf.yml") == 0) { return "helm_chart_changed"; } /* A chart's values file CONFIGURES it and its templates/ are what it @@ -2518,6 +2551,12 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project, if (strcmp(base, ".env") == 0 && closure_dir_has_compose(repo_path, rel, base)) { return "compose_env_changed"; } + /* A release's values file -- a kustomization's valuesFile, a Terraform + * helm_release's file(...) -- feeds the images the release renders, + * which a repair of the values file alone does not redraw. */ + if (closure_release_values(store, project, rel)) { + return "helm_release_values_changed"; + } cbm_node_t *nodes = NULL; int count = 0; if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index fbae8044fb..553aa19d97 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -380,6 +380,16 @@ typedef struct { int reader_count; } cbm_envjoin_base_t; +/* A Terraform helm_release (pass_terraform.c): the chart it DEPLOYS and its + * values -- `values = [...]` coalesced in order, then `set` -- as JSON; NULL + * 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 */ +} cbm_tf_release_t; + /* Shared context passed to each pass function. * Derived from cbm_pipeline_t fields during run. */ typedef struct { @@ -486,6 +496,12 @@ typedef struct { const struct cbm_doclink_scope *doc_link_base; int doc_link_base_count; bool doc_links_failed; + + /* The run's Terraform helm_releases (pass_terraform.c fills them, the K8s + * pass reads and frees them right after). */ + cbm_tf_release_t *tf_releases; + int tf_release_n; + int tf_release_cap; } cbm_pipeline_ctx_t; /* Origin-aware LSP returns only success/failure. Never retain partial output @@ -1060,6 +1076,9 @@ bool cbm_is_k8s_manifest(const char *name, const char *content); /* A Helm values file name: values.yaml / .yml, values-.yaml / .yml * (pass_k8s.c; beside Chart.yaml it CONFIGURES the chart). */ bool cbm_helm_values_name(const char *base); +/* Is `dir` (repository relative) a werf chart: a `.helm` directory without a + * Chart.yaml whose parent holds werf.yaml (pass_k8s.c)? */ +bool cbm_werf_helm_dir(const char *repo_path, const char *dir); /* Secret detection */ bool cbm_is_secret_binding(const char *key, const char *value); @@ -1259,6 +1278,7 @@ typedef struct { const char *via; /* "skaffold" / "compose" */ const char *image; /* the reference, variables expanded */ const char *dockerfile; /* repository path */ + const char *build_args; /* compose build.args as a JSON object; NULL: none */ int line; } cbm_build_site_t; typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud); @@ -1269,10 +1289,17 @@ typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud); * depends_on and links list, and the names of the environment variables its * environment sets (map keys / list items' names -- never a value), with * their 1-based lines and the service name each value addresses (a bare - * name, `name:port`, a URL's host; NULL: none -- cbm_env_address). */ + * name, `name:port`, a URL's host; NULL: none -- cbm_env_address). + * image_default_of: the variables whose inline default (${X:-d}) named the + * image; image_override_of: those whose default the project's .env replaced + * (JSON arrays, NULL: none), and default_image the image those defaults + * name. */ typedef struct { const char *name; const char *image; + const char *image_default_of; + const char *image_override_of; + const char *default_image; const char *dockerfile; const char **deps; int ndeps; @@ -1340,6 +1367,12 @@ bool cbm_repo_rel_join(const char *dir, const char *v, char *out, size_t sz); * for an official image; registry "unknown" when the reference names no * host -- none is invented), made when new; 0 for scratch or no image. */ int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref); +/* Does `ref` follow the image reference grammar (distribution/reference): + * [host[:port]/]path[:tag][@algo:hex], path components lowercase alphanumerics + * joined by `.`, `_`, `__` or dashes, a tag of word characters, `.` and `-` + * (128 at most)? A value rendered from a template is checked before it names + * an Image (pass_docker.c). */ +bool cbm_image_ref_valid(const char *ref); /* A chart repository URL as an identity (pass_k8s.c): `url` trimmed, its * scheme and host lower-cased, trailing `/` and `.git` dropped. False for no @@ -1730,7 +1763,8 @@ int cbm_delta_drop_edges_from_label(cbm_store_t *store, const char *project, con const char *label); /* The edges that keep a file-less shared node alive. */ #define SH_PROCESS_EDGES ((const char *const[]){"SPAWNS", NULL}) -#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", NULL}) +/* An Image a release DEPLOYS (pass_k8s.c release images) may have no other edge. */ +#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", "DEPLOYS", NULL}) /* Pre-seed proxy nodes with their REAL database ids and move the gbuf id * watermark above MAX(id); returns that max id, or -1 on failure. */ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf); diff --git a/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml b/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml new file mode 100644 index 0000000000..7da8794739 --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: cx +version: 0.1.0 +appVersion: "1.0" diff --git a/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl b/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl new file mode 100644 index 0000000000..f4aff46fec --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/templates/_helpers.tpl @@ -0,0 +1,4 @@ +{{- define "cx.image" -}} +{{- $reg := .registry -}} +{{- printf "%s/%s:%s" $reg .repository .tag -}} +{{- end -}} diff --git a/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml b/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml new file mode 100644 index 0000000000..52c14349c5 --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/templates/deploy.yaml @@ -0,0 +1,14 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cx +spec: + template: + spec: + containers: + - name: main + image: "{{ include "cx.image" .Values.image }}" + {{- range .Values.sidecars }} + - name: {{ .name }} + image: "{{ .image.repository }}:{{ .image.tag }}" + {{- end }} diff --git a/tests/fixtures/complexity/Kubernetes/chart/values.yaml b/tests/fixtures/complexity/Kubernetes/chart/values.yaml new file mode 100644 index 0000000000..c279c38a7a --- /dev/null +++ b/tests/fixtures/complexity/Kubernetes/chart/values.yaml @@ -0,0 +1,12 @@ +common: &common + pullPolicy: IfNotPresent +image: + <<: *common + registry: ghcr.io + repository: acme/cx + tag: "1.0" +sidecars: + - name: a + image: {repository: ghcr.io/acme/cx-side, tag: "2.0"} + - name: b + image: {repository: ghcr.io/acme/cx-side, tag: "2.1"} diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index cebd5275bc..b51ad995e7 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -4445,6 +4445,7 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) { "SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT CASE WHEN " "s.label='File' THEN s.file_path ELSE s.name END || ' -> ' || t.label || ':' || " "t.name || ' (' || e.type || coalesce(' ' || json_extract(e.properties,'$.ref'), '') " + "|| coalesce(' ' || json_extract(e.properties,'$.phase'), '') " "|| ')' AS x FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " "t.id=e.target_id WHERE e.project=?1 AND ((s.label='Chart' AND e.type='DEPLOYS') OR " "(t.label='Chart' AND e.type='CONFIGURES')) ORDER BY x)"; @@ -4462,8 +4463,8 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) { /* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}-> * each object of its templates/ (a subchart's objects are the subchart's), - * and each Image those objects name literally ({ref: image}) -- a templated - * image names none, and no values default is read. Its values files + * and each Image those objects use ({ref: image}) -- a templated one rendered + * from values.yaml (`nginx`: host-less, kept as written). Its values files * (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file * elsewhere (ci/) does not. */ TEST(probe_helm_chart_deploys_its_objects) { @@ -4492,6 +4493,7 @@ TEST(probe_helm_chart_deploys_its_objects) { "db -> Image:ghcr.io/acme/db (DEPLOYS image)\n" "db -> Resource:StatefulSet/db (DEPLOYS)\n" "web -> Image:ghcr.io/acme/sidecar (DEPLOYS image)\n" + "web -> Image:nginx (DEPLOYS image)\n" "web -> Resource:Deployment/web (DEPLOYS)\n" "web -> Resource:Service/web (DEPLOYS)"; if (!got || strcmp(got, want) != 0) { @@ -4503,6 +4505,793 @@ TEST(probe_helm_chart_deploys_its_objects) { PASS(); } +/* A chart also deploys the CRDs of its crds/ ({phase: crds}), which Helm + * installs before its templates. werf deploys the `.helm` dir beside its + * werf.yaml as a chart without Chart.yaml, named after the werf project + * (templated werf.yaml lines before it are skipped); a `.helm` with its own + * Chart.yaml stays that chart, and a werf.yaml without `.helm` makes none. */ +TEST(probe_helm_chart_crds_and_werf_chart) { + static const GpfFile files[] = { + {"charts/op/Chart.yaml", "apiVersion: v2\nname: op\nversion: 1.0.0\n"}, + {"charts/op/crds/widget.yaml", + "apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n" + " name: widgets.acme.io\nspec:\n group: acme.io\n names:\n kind: Widget\n" + " plural: widgets\n scope: Namespaced\n"}, + {"charts/op/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: op\n"}, + {"site/werf.yaml", "{{- $_ := set . \"Env\" (.Env | default \"prod\") }}\n\n" + "project: site-web\nconfigVersion: 1\n---\nimage: web\nfrom: nginx\n"}, + {"site/.helm/values.yaml", "replicas: 1\n"}, + {"site/.helm/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: site\nspec:\n" + " replicas: {{ .Values.replicas }}\n"}, + {"plain/werf.yaml", "project: plain\nconfigVersion: 1\n"}, + {"own/werf.yaml", "project: own-werf\nconfigVersion: 1\n"}, + {"own/.helm/Chart.yaml", "apiVersion: v2\nname: own\nversion: 0.1.0\n"}, + {"own/.helm/templates/cm.yaml", + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: own\n"}}; + char *got = gpf_chart_edges(files, 10); + static const char want[] = + "op -> Resource:CustomResourceDefinition/widgets.acme.io (DEPLOYS crds)\n" + "op -> Resource:Deployment/op (DEPLOYS)\n" + "own -> Resource:ConfigMap/own (DEPLOYS)\n" + "site-web -> Resource:Deployment/site (DEPLOYS)\n" + "site/.helm/values.yaml -> Chart:site-web (CONFIGURES)"; + if (!got || strcmp(got, want) != 0) { + fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + free(got); + FAIL("crds/ and werf chart edge set"); + } + free(got); + PASS(); +} + +/* Every edge into an Image (or out of one into an Image), one line each: + * `source -> Image:name TYPE image=.. [via=..] [ref=..] [default_of=..] + * [override_of=..] [inferred=..] [values_key=..]`, sorted. */ +static char *gpf_image_edges(const GpfFile *files, int nfiles) { + GpfProj lp; + cbm_store_t *store = gpf_index_files(&lp, files, nfiles); + char *out = NULL; + if (store) { + sqlite3_stmt *st = NULL; + const char *sql = + "SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name " + "|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || " + "coalesce(json_extract(e.properties,'$.image'),'-') || " + "coalesce(' via=' || json_extract(e.properties,'$.via'), '') || " + "coalesce(' ref=' || json_extract(e.properties,'$.ref'), '') || " + "coalesce(' default_of=' || json_extract(e.properties,'$.default_of'), '') || " + "coalesce(' override_of=' || json_extract(e.properties,'$.override_of'), '') || " + "coalesce(' inferred=' || json_extract(e.properties,'$.inferred'), '') || " + "coalesce(' values_key=' || json_extract(e.properties,'$.values_key'), '') AS x " + "FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id " + "WHERE e.project=?1 AND t.label='Image' ORDER BY x)"; + if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + out = strdup((const char *)sqlite3_column_text(st, 0)); + } + } + sqlite3_finalize(st); + } + gpf_cleanup(&lp, store); + return out; +} + +static bool gpf_expect_image_edges(const GpfFile *files, int n, const char *want) { + char *got = gpf_image_edges(files, n); + bool ok = got && strcmp(got, want) == 0; + if (!ok) { + fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + } + free(got); + return ok; +} + +/* An image a default value names keeps its Image, its edge marked + * default_of with the ARGs / variables whose default supplied it (an ARG + * chain names each); a literal FROM is unmarked. A build's arguments (compose + * build.args, docker build --build-arg) make the Image it builds DEPEND_ON + * the base they choose {override_of}; compose's .env replacing an inline + * default is the effective edge's override_of, the default's image kept as + * {ref: image_default} -- one the service does not run. */ +TEST(probe_docker_default_images_marked_and_overrides) { + static const GpfFile files[] = { + {"src/web/Dockerfile", "ARG GO=1.22\nARG BASE=golang:${GO}\nFROM ${BASE} AS build\n" + "RUN go build -o /app .\nFROM alpine:3.20\n" + "COPY --from=build /app /app\n"}, + {"src/api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"}, + {".env", "REG=ghcr.io/acme\nAPI_IMG=ghcr.io/acme/api-custom\n"}, + {"compose.yaml", "services:\n web:\n image: ${REG}/web:${TAG:-dev}\n build:\n" + " context: ./src/web\n args:\n GO: \"1.23\"\n" + " api:\n image: ${API_IMG:-ghcr.io/acme/api}\n build: ./src/api\n"}, + {"scripts/build.sh", "#!/bin/sh\nIMAGE=${IMAGE:-ghcr.io/acme/api-ci}\n" + "docker build --build-arg PY=3.13 -t \"$IMAGE:1\" src/api\n"}}; + static const char want[] = + "Image:ghcr.io/acme/api-ci -> Image:python DEPENDS_ON image=python:3.13-slim via=from " + "override_of=[\"PY\"]\n" + "Image:ghcr.io/acme/web -> Image:golang DEPENDS_ON image=golang:1.23 via=from " + "default_of=[\"BASE\"] override_of=[\"GO\"]\n" + "Module:src/api/Dockerfile -> Image:python DEPENDS_ON image=python:3.12-slim via=from " + "default_of=[\"PY\"]\n" + "Module:src/web/Dockerfile -> Image:alpine DEPENDS_ON image=alpine:3.20 via=from\n" + "Module:src/web/Dockerfile -> Image:golang DEPENDS_ON image=golang:1.22 via=from " + "default_of=[\"GO\",\"BASE\"]\n" + "Resource:Service/api -> Image:ghcr.io/acme/api USAGE image=ghcr.io/acme/api " + "ref=image_default default_of=[\"API_IMG\"]\n" + "Resource:Service/api -> Image:ghcr.io/acme/api-custom USAGE image=ghcr.io/acme/api-custom " + "ref=image override_of=[\"API_IMG\"]\n" + "Resource:Service/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:dev " + "ref=image default_of=[\"TAG\"]"; + if (!gpf_expect_image_edges(files, 5, want)) { + FAIL("default-valued images and their in-repo overrides"); + } + PASS(); +} + +/* The chart a Helm template renders (helm_template.c): the container image + * from the chart family's defaults -- an include helper (bitnami + * common.images.image, a dict argument, .Chart.AppVersion), a `with` + * binding, the taken branch of an if / else, a subchart under its alias with + * the parent's values and global. The object USES it {default_of: the values + * keys read}; the chart DEPLOYS it. A value that does not render -- a + * .Release field, lookup, a missing define, a field of a missing map -- names + * no Image. */ +TEST(probe_helm_template_images_render_with_defaults) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", + "apiVersion: v2\nname: app\nversion: 1.2.3\nappVersion: \"2.0.1\"\ndependencies:\n" + " - name: db\n version: 0.1.0\n alias: database\n"}, + {"charts/app/values.yaml", + "image:\n registry: ghcr.io\n repository: acme/app\n tag: \"\"\n digest: \"\"\n" + "sidecar:\n image: { repository: docker.io/library/busybox, tag: 1.36 }\nglobal: {}\n" + "database:\n image:\n tag: \"17\"\n"}, + {"charts/app/templates/_helpers.tpl", + "{{- define \"common.images.image\" -}}\n" + "{{- $registryName := default .imageRoot.registry ((.global).imageRegistry) -}}\n" + "{{- $termination := .imageRoot.tag | toString -}}\n" + "{{- if not .imageRoot.tag }}\n {{- if .chart }}\n" + " {{- $termination = .chart.AppVersion | toString -}}\n {{- end -}}\n{{- end -}}\n" + "{{- if $registryName }}\n" + "{{- printf \"%s/%s:%s\" $registryName .imageRoot.repository $termination -}}\n" + "{{- else -}}\n{{- printf \"%s:%s\" .imageRoot.repository $termination -}}\n" + "{{- end -}}\n{{- end -}}\n"}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n image: {{ include " + "\"common.images.image\" (dict \"imageRoot\" .Values.image \"global\" .Values.global " + "\"chart\" .Chart) }}\n - name: side\n {{- with .Values.sidecar.image }}\n" + " image: \"{{ .repository }}:{{ .tag }}\"\n {{- end }}\n" + " - name: rel\n image: \"ghcr.io/acme/{{ .Release.Name }}:1\"\n" + " - name: miss\n image: {{ include \"nope\" . }}\n" + " - name: deep\n image: \"{{ .Values.absent.repository }}\"\n"}, + {"charts/app/charts/db/Chart.yaml", + "apiVersion: v2\nname: db\nversion: 0.1.0\nappVersion: \"16.4\"\n"}, + {"charts/app/charts/db/values.yaml", + "image:\n repository: bitnami/postgresql\n tag: \"\"\n digest: \"\"\n"}, + {"charts/app/charts/db/templates/sts.yaml", + "apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: db\nspec:\n template:\n" + " spec:\n containers:\n - name: db\n {{- if .Values.image.digest }}\n" + " image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/" + "{{ .Values.image.repository }}@{{ .Values.image.digest }}\"\n {{- else }}\n" + " image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/" + "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n" + " {{- end }}\n"}}; + static const char want[] = + "Chart:app -> Image:docker.io/library/busybox DEPLOYS image=- via=chart ref=image " + "default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n" + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\"," + "\"image.repository\"]\n" + "Chart:db -> Image:docker.io/bitnami/postgresql DEPLOYS image=- via=chart ref=image " + "default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]\n" + "Resource:Deployment/app -> Image:docker.io/library/busybox USAGE " + "image=docker.io/library/busybox:1.36 ref=image " + "default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:2.0.1 " + "ref=image default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\"," + "\"image.repository\"]\n" + "Resource:StatefulSet/db -> Image:docker.io/bitnami/postgresql USAGE " + "image=docker.io/bitnami/postgresql:17 ref=image " + "default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]"; + if (!gpf_expect_image_edges(files, 7, want)) { + FAIL("Helm template images rendered with the chart family's defaults"); + } + PASS(); +} + +/* A release renders its chart with its values over the defaults: of a chart + * in the repository each image that differs is the release's own {via, + * override_of} -- a Flux HelmRelease's spec.values (a Git source), a + * kustomize helmCharts entry's valuesFile + valuesInline (chartHome charts/), + * a Terraform helm_release's values + set. Of an external chart the values + * name images by convention -- `repository` (+ tag / registry) naming a + * registry host -- {inferred: values}; a host-less repository names none. */ +TEST(probe_helm_release_images) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}, + {"flux/app.yaml", + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: app\nspec:\n" + " chart:\n spec:\n chart: ./charts/app\n sourceRef:\n" + " kind: GitRepository\n name: repo\n values:\n image:\n" + " tag: \"9.9\"\n---\n" + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: sonarr\n" + "spec:\n chart:\n spec:\n chart: app-template\n sourceRef:\n" + " kind: HelmRepository\n name: bjw-s\n values:\n controllers:\n" + " main:\n containers:\n main:\n image:\n" + " repository: ghcr.io/home-operations/sonarr\n tag: 4.0.15\n" + " side:\n image:\n repository: busybox\n" + " tag: \"1.36\"\n"}, + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: kapp\n valuesFile: values-prod.yaml\n" + " valuesInline:\n image:\n tag: \"5.5\"\n"}, + {"kz/values-prod.yaml", "image:\n repository: quay.io/acme/app\n tag: \"4.4\"\n"}, + {"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" + " value = \"7.7\"\n }\n}\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\",\"image.tag\"]\n" + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\",\"image.tag\"]\n" + "Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS " + "image=ghcr.io/acme/app:7.7 via=terraform ref=image override_of=[\"image.tag\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"image.repository\",\"image.tag\"]\n" + "Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"image.repository\",\"image.tag\"]\n" + "Resource:HelmRelease/app -> Image:ghcr.io/acme/app DEPLOYS image=ghcr.io/acme/app:9.9 " + "via=helmrelease ref=image override_of=[\"image.tag\"]\n" + "Resource:HelmRelease/sonarr -> Image:ghcr.io/home-operations/sonarr DEPLOYS " + "image=ghcr.io/home-operations/sonarr:4.0.15 via=helmrelease ref=image inferred=values " + "values_key=controllers.main.containers.main.image\n" + "Resource:Kustomization -> Image:quay.io/acme/app DEPLOYS image=quay.io/acme/app:5.5 " + "via=helmcharts ref=image override_of=[\"image.repository\",\"image.tag\"]"; + if (!gpf_expect_image_edges(files, 10, want)) { + FAIL("release images: local renders and the external values convention"); + } + PASS(); +} + +/* The chart every release test below deploys: an image from two values. */ +#define GPF_APP_CHART \ + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, \ + {"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, \ + { \ + "charts/app/templates/deploy.yaml", \ + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" \ + " spec:\n containers:\n - name: app\n" \ + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" \ + } + +/* The edges of the chart's defaults (every test below has them). */ +#define GPF_APP_DEFAULTS \ + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " \ + "default_of=[\"image.repository\",\"image.tag\"]\n" \ + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " \ + "ref=image default_of=[\"image.repository\",\"image.tag\"]" + +/* A Terraform number set on a helm_release reaches the chart as the string + * Terraform makes of it -- its exact decimal (1.1, 1.10 -> "1.1", 2e3 -> + * "2000"), never a float's %.17g ("1.1000000000000001"). */ +TEST(probe_helm_release_terraform_number_set) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n" + " value = 1.10\n }\n}\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"image.repository\",\"image.tag\"]\n" + "Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS " + "image=ghcr.io/acme/app:1.1 via=terraform ref=image override_of=[\"image.tag\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"image.repository\",\"image.tag\"]"; + if (!gpf_expect_image_edges(files, 4, want)) { + FAIL("a Terraform number set is its exact decimal"); + } + PASS(); +} + +/* A Flux HelmRelease whose values also come from a source not read -- + * valuesFrom, the chart's valuesFiles, a postRenderer that may patch the + * images -- renders no image of its own: its values are not known whole. */ +TEST(probe_helm_release_flux_unread_sources) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"flux/app.yaml", + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: files\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n valuesFiles:\n" + " - charts/app/values-prod.yaml\n sourceRef:\n kind: GitRepository\n" + " name: repo\n values:\n image:\n tag: \"9.9\"\n---\n" + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: from\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n" + " kind: GitRepository\n name: repo\n valuesFrom:\n" + " - kind: ConfigMap\n name: app-values\n values:\n image:\n" + " tag: \"8.8\"\n---\n" + "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: post\n" + "spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n" + " kind: GitRepository\n name: repo\n postRenderers:\n" + " - kustomize:\n images:\n - name: ghcr.io/acme/app\n" + " newName: quay.io/acme/app\n values:\n image:\n tag: \"7.7\"\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a HelmRelease with a source not read has no image of its own"); + } + PASS(); +} + +/* A kustomize helmCharts entry with additionalValuesFiles, or a valuesMerge + * other than the default override, renders no image of its own. */ +TEST(probe_helm_release_kustomize_unread_sources) { + static const GpfFile files[] = { + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: more\n additionalValuesFiles:\n" + " - extra.yaml\n valuesInline:\n image:\n tag: \"5.5\"\n" + " - name: app\n releaseName: merged\n valuesMerge: merge\n" + " valuesInline:\n image:\n tag: \"6.6\"\n"}, + {"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a helmCharts entry with a part not read has no image of its own"); + } + PASS(); +} + +/* A Terraform helm_release with a `dynamic "set"` block (entries made by a + * for_each) renders no image of its own, whatever its literal sets say. */ +TEST(probe_helm_release_terraform_dynamic_set) { + static const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n dynamic \"set\" {\n" + " for_each = var.sets\n content {\n name = set.key\n" + " value = set.value\n }\n }\n set {\n name = \"image.tag\"\n" + " value = \"3.3\"\n }\n}\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a helm_release with a dynamic set has no image of its own"); + } + PASS(); +} + +/* A 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, + {"tf/main.tf", + "resource \"helm_release\" \"tagged\" {\n name = \"tagged\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n" + " value = \"quay.io/acme/app\"\n }\n set_sensitive {\n name = \"image.tag\"\n" + " value = var.tag\n }\n}\n" + "resource \"helm_release\" \"pw\" {\n name = \"pw\"\n" + " chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n" + " value = \"quay.io/acme/pw\"\n }\n set_sensitive {\n name = \"db.password\"\n" + " value = var.pw\n }\n}\n" + "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). + * + * NOT a cross-file guarantee: app's cm.yaml sets .Values.image.tag, and Helm + * renders a chart's files with one shared .Values (a subchart's is the + * parent's .Values.). Each file renders alone here, so app's image is + * known only because nothing models that set reaching deploy.yaml -- the + * known limit (review round 4, R4-5), whose exact fix is to run the family's + * files in Helm's order with one .Values (the run-included-files follow-up). */ +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" + "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\"]\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(); +} + +/* A kustomization that also transforms what it inflates (images:, patches) + * has no release image of its own: the transformer may change it. */ +TEST(probe_helm_release_kustomize_transformers) { + static const GpfFile files[] = { + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: kapp\n valuesInline:\n image:\n" + " tag: \"1.5\"\nimages:\n - name: ghcr.io/acme/app\n newTag: \"2.0\"\n"}, + {"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}}; + if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) { + FAIL("a kustomization's own transformers make its release images unknown"); + } + PASS(); +} + +/* An empty values.yaml is values {} (Helm's), not values not read: the + * subchart under it still renders its image. */ +TEST(probe_helm_empty_values_file_renders) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", + "apiVersion: v2\nname: app\nversion: 1.0.0\nappVersion: \"1.0\"\n"}, + {"charts/app/values.yaml", ""}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app 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"; + if (!gpf_expect_image_edges(files, 3, want)) { + FAIL("an empty values.yaml renders with {}"); + } + PASS(); +} + +/* A subchart a dependency aliases renders with .Chart.Name its alias (Helm's + * getAliasDependency), so an image naming the chart names the alias. */ +TEST(probe_helm_aliased_subchart_chart_name) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", + "apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n - name: worker\n" + " version: 0.1.0\n alias: worker-high\n"}, + {"charts/app/values.yaml", "worker-high: {}\n"}, + {"charts/app/charts/worker/Chart.yaml", + "apiVersion: v2\nname: worker\nversion: 0.1.0\nappVersion: \"1.0\"\n"}, + {"charts/app/charts/worker/values.yaml", "{}\n"}, + {"charts/app/charts/worker/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n template:\n" + " spec:\n containers:\n - name: worker\n" + " image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}}; + static const char want[] = + "Chart:worker -> Image:ghcr.io/acme/worker-high DEPLOYS image=- via=chart ref=image\n" + "Resource:Deployment/worker -> Image:ghcr.io/acme/worker-high USAGE " + "image=ghcr.io/acme/worker-high:1.0 ref=image"; + if (!gpf_expect_image_edges(files, 5, want)) { + FAIL("an aliased subchart's .Chart.Name is the alias"); + } + PASS(); +} + +/* An image read from a values list keeps its mark on the chart's edge: + * default_of names the list paths (images[0].repository) whole. */ +TEST(probe_helm_list_path_mark_on_chart_edge) { + static const GpfFile files[] = { + {"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"charts/app/values.yaml", + "images:\n - repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ (index .Values.images 0).repository }}:" + "{{ (index .Values.images 0).tag }}\"\n"}}; + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " + "default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " + "ref=image default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]"; + if (!gpf_expect_image_edges(files, 3, want)) { + FAIL("a list path's mark is kept whole on the chart edge"); + } + PASS(); +} + +#ifndef _WIN32 +/* gpf_image_edges with symlinks laid in after the files: links[i] names a + * repository path and the absolute or relative target it points at (POSIX + * symlinks: the tests using it skip on Windows). */ +static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links, + int nlinks) { + GpfProj lp; + memset(&lp, 0, sizeof(lp)); + snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_gpf_XXXXXX"); + if (!cbm_mkdtemp(lp.tmpdir)) { + return NULL; + } + gpf_to_fwd_slashes(lp.tmpdir); + for (int i = 0; i < nfiles + nlinks; i++) { + const GpfFile *f = i < nfiles ? &files[i] : &links[i - nfiles]; + char path[700]; + snprintf(path, sizeof(path), "%s/%s", lp.tmpdir, f->name); + char *slash = strrchr(path, '/'); + if (slash && slash > path + strlen(lp.tmpdir)) { + *slash = '\0'; + cbm_mkdir_p(path, 0755); + *slash = '/'; + } + if (i >= nfiles) { + if (symlink(f->content, path) != 0) { + return NULL; + } + continue; + } + FILE *fp = fopen(path, "wb"); + if (!fp) { + return NULL; + } + fputs(f->content, fp); + fclose(fp); + } + cbm_store_t *store = gpf_open_indexed(&lp); + char *out = NULL; + if (store) { + sqlite3_stmt *st = NULL; + const char *sql = + "SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name " + "|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || " + "coalesce(json_extract(e.properties,'$.image'),'-') || " + "coalesce(' via=' || json_extract(e.properties,'$.via'), '') AS x " + "FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id " + "WHERE e.project=?1 AND t.label='Image' ORDER BY x)"; + if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) { + out = strdup((const char *)sqlite3_column_text(st, 0)); + } + } + sqlite3_finalize(st); + } + gpf_cleanup(&lp, store); + return out; +} +#endif + +/* A values file a chart, a kustomization or a Terraform file() names by path + * is read only when it is a file of the repository: a symlink out of it -- + * to a file holding an image -- is never followed. The chart whose values + * file is such a link renders nothing (its values are unknown, not empty); + * the releases reading one have no image of their own. */ +TEST(probe_helm_values_symlink_not_followed) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink contract"); +#else + char outside[256]; + snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char leak[300]; + snprintf(leak, sizeof(leak), "%s/values.yaml", outside); + FILE *lf = fopen(leak, "wb"); + ASSERT_NOT_NULL(lf); + fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf); + fclose(lf); + const GpfFile files[] = { + GPF_APP_CHART, + {"linked/Chart.yaml", "apiVersion: v2\nname: linked\nversion: 1.0.0\n"}, + {"linked/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: linked\nspec:\n template:\n" + " spec:\n containers:\n - name: linked\n" + " image: \"ghcr.io/acme/linked:{{ .Values.tag | default \"latest\" }}\"\n"}, + {"kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n" + " - name: app\n releaseName: kapp\n valuesFile: prod.yaml\n"}, + {"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, + {"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, + {"kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n" + " spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}}; + const GpfFile links[] = {{"linked/values.yaml", leak}, {"kz/prod.yaml", leak}}; + char *got = gpf_image_edges_linked(files, 9, links, 2); + th_rmtree(outside); + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0\n" + "Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0"; + bool ok = got && strcmp(got, want) == 0; + if (!ok) { + fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + } + free(got); + if (!ok) { + FAIL("no values read through a symlink out of the repository"); + } + PASS(); +#endif +} + +/* A Terraform helm_release reading its values with file() of a symlink out + * of the repository has no image of its own: the link is never followed. */ +TEST(probe_helm_terraform_values_symlink_not_followed) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX symlink contract"); +#else + char outside[256]; + snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + char leak[300]; + snprintf(leak, sizeof(leak), "%s/vals.yaml", outside); + FILE *lf = fopen(leak, "wb"); + ASSERT_NOT_NULL(lf); + fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf); + fclose(lf); + const GpfFile files[] = { + GPF_APP_CHART, + {"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n" + " chart = \"../charts/app\"\n values = [file(\"vals.yaml\")]\n}\n"}}; + const GpfFile links[] = {{"tf/vals.yaml", leak}}; + char *got = gpf_image_edges_linked(files, 4, links, 1); + th_rmtree(outside); + static const char want[] = + "Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n" + "Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0"; + bool ok = got && strcmp(got, want) == 0; + if (!ok) { + fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want); + } + free(got); + if (!ok) { + FAIL("no Terraform values read through a symlink out of the repository"); + } + PASS(); +#endif +} + +/* A build argument holding a secret -- by its name (*_TOKEN) or its value (a + * GitHub token) -- is never kept on a build edge; the others still choose the + * base image they set. */ +TEST(probe_docker_build_args_keep_no_secret) { + static const GpfFile files[] = { + {"web/Dockerfile", "ARG GO=1.22\nFROM golang:${GO}\n"}, + {"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"}, + {".env", "NPM_TOKEN=npm-secret-value\n"}, + {"compose.yaml", "services:\n web:\n image: ghcr.io/acme/web\n build:\n" + " context: ./web\n args:\n NPM_TOKEN: ${NPM_TOKEN}\n" + " GO: \"1.23\"\n"}, + {"scripts/build.sh", + "#!/bin/sh\ndocker build --build-arg CI_KEY=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB " + "--build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"}}; + int secrets = gpf_query_int(files, 5, + "SELECT count(*) FROM edges WHERE project=?1 AND " + "(properties LIKE '%npm-secret-value%' OR properties LIKE " + "'%ghp_abcdefghij%' OR properties LIKE '%NPM_TOKEN%')"); + int overrides = gpf_query_int(files, 5, + "SELECT count(*) FROM edges WHERE project=?1 AND " + "json_extract(properties,'$.override_of') IS NOT NULL"); + ASSERT_EQ(secrets, 0); + ASSERT_EQ(overrides, 2); + PASS(); +} + +/* Build arguments of any length are kept whole on the build edge (never cut + * to malformed JSON): the one choosing the base still makes its edge. */ +TEST(probe_docker_long_build_args_kept_whole) { + enum { ARGS = 40, VAL = 150, CAP = ARGS * (VAL + 40) + 512 }; + char *script = malloc(CAP); + ASSERT_NOT_NULL(script); + int o = snprintf(script, CAP, "#!/bin/sh\ndocker build"); + char val[VAL + 1]; + memset(val, 'v', VAL); + val[VAL] = '\0'; + for (int i = 0; i < ARGS; i++) { + o += snprintf(script + o, (size_t)(CAP - o), " --build-arg LABEL%d=%s", i, val); + } + snprintf(script + o, (size_t)(CAP - o), " --build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"); + const GpfFile files[] = {{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"}, + {"scripts/build.sh", script}}; + int overrides = + gpf_query_int(files, 2, + "SELECT count(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE " + "e.project=?1 AND t.name='python' AND " + "json_extract(e.properties,'$.override_of') IS NOT NULL"); + free(script); + ASSERT_EQ(overrides, 1); + PASS(); +} + /* A template's `include "name"` calls the define of that name in its chart * family -- the chart and its subcharts share one template namespace -- never * a same-named define of an unrelated chart; a define may also be written in @@ -6313,6 +7102,26 @@ SUITE(grammar_probe_f) { RUN_TEST(probe_terraform_helm_release_deploys_its_chart); RUN_TEST(probe_helm_templated_objects_are_resources); RUN_TEST(probe_helm_chart_deploys_its_objects); + RUN_TEST(probe_helm_chart_crds_and_werf_chart); + RUN_TEST(probe_docker_default_images_marked_and_overrides); + RUN_TEST(probe_helm_template_images_render_with_defaults); + RUN_TEST(probe_helm_release_images); + RUN_TEST(probe_helm_release_terraform_number_set); + RUN_TEST(probe_helm_release_flux_unread_sources); + RUN_TEST(probe_helm_release_kustomize_unread_sources); + RUN_TEST(probe_helm_release_terraform_dynamic_set); + RUN_TEST(probe_helm_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); + RUN_TEST(probe_helm_list_path_mark_on_chart_edge); + RUN_TEST(probe_helm_values_symlink_not_followed); + RUN_TEST(probe_helm_terraform_values_symlink_not_followed); + RUN_TEST(probe_docker_build_args_keep_no_secret); + RUN_TEST(probe_docker_long_build_args_kept_whole); RUN_TEST(probe_helm_include_resolves_in_the_chart_family); RUN_TEST(probe_k8s_selector_stays_in_its_directory); RUN_TEST(probe_k8s_selector_within_kustomize_build); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b52513acee..f004cca3e6 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15,6 +15,7 @@ #include "pipeline/lsp_surface.h" #include "pipeline/pass_lsp_cross.h" #include "pipeline/pipeline_internal.h" +#include "pipeline/helm_template.h" #include "pipeline/artifact.h" #include "store/store.h" #include "semantic/semantic.h" @@ -4205,6 +4206,1020 @@ TEST(incremental_helm_values_edit_keeps_chart_edge) { PASS(); } +/* A werf `.helm` dir is a chart without Chart.yaml (pass_k8s.c): its values + * file CONFIGURES the werf Chart, an edge a closure repair of the values file + * alone does not hold, so the edit rebuilds, like one beside a Chart.yaml. */ +TEST(incremental_werf_values_edit_keeps_chart_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_werfchart_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "werf.yaml", "project: site\nconfigVersion: 1\n"); + write_temp_file(tmp, ".helm/values.yaml", "replicas: 1\n"); + write_temp_file(tmp, ".helm/templates/svc.yaml", + "apiVersion: v1\nkind: Service\nmetadata:\n name: web\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/werfchart.db", tmp); + static const char edge[] = + "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " + "t.id=e.target_id WHERE e.type='CONFIGURES' AND s.file_path='.helm/values.yaml' AND " + "t.label='Chart' AND t.name='site'"; + int before = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, edge) : -1; + write_temp_file(tmp, ".helm/values.yaml", "replicas: 3\n"); + int after = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, edge) : -1; + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + th_rmtree(tmp); + ASSERT_EQ(before, 1); + ASSERT_EQ(after, 1); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); + PASS(); +} + +/* A kustomization's helmCharts valuesFile (an IMPORTS {local_name: + * valuesFile} target) feeds the image the release renders: the values file + * edited alone rebuilds, and the release's image follows the edit. */ +TEST(incremental_release_values_edit_rebuilds) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_relvalues_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "kz/kustomization.yaml", + "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" + "helmCharts:\n - name: app\n releaseName: kapp\n" + " valuesFile: values-prod.yaml\n"); + write_temp_file(tmp, "kz/values-prod.yaml", "image:\n tag: \"2.0\"\n"); + write_temp_file(tmp, "kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"); + write_temp_file(tmp, "kz/charts/app/values.yaml", + "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"); + write_temp_file(tmp, "kz/charts/app/templates/deploy.yaml", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n" + " template:\n spec:\n containers:\n - name: app\n" + " image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/relvalues.db", tmp); + static const char v2[] = "SELECT COUNT(*) FROM edges WHERE type='DEPLOYS' AND " + "json_extract(properties,'$.image')='ghcr.io/acme/app:2.0'"; + static const char v3[] = "SELECT COUNT(*) FROM edges WHERE type='DEPLOYS' AND " + "json_extract(properties,'$.image')='ghcr.io/acme/app:3.0'"; + int before = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, v2) : -1; + write_temp_file(tmp, "kz/values-prod.yaml", "image:\n tag: \"3.0\"\n"); + int rc = k8s_inc_index(tmp, db_path); + cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route(); + int stale = rc == 0 ? k8s_inc_count(db_path, v2) : -1; + int after = rc == 0 ? k8s_inc_count(db_path, v3) : -1; + th_rmtree(tmp); + ASSERT_EQ(before, 1); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL); + ASSERT_EQ(stale, 0); + ASSERT_EQ(after, 1); + PASS(); +} + +/* Helm's values as its templates see them (helm_template.c): go-yaml v2 + * scalars -- YAML 1.1 booleans, every number a float64 printed like Go's %v + * -- --set typing (int64, bool, null, a leading-zero string), coalesce where + * a null deletes, and a field of a missing map an error: no value. */ +TEST(helm_values_follow_helm_semantics) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + ASSERT_NOT_NULL(doc); + static const char src[] = "tag: 1.10\nbig: 20231010\nm: 1000000\nyes_: yes\noff_: Off\n" + "nul: ~\nq: \"1.10\"\noct: 0755\nkeep: k\ngone: g\n"; + yyjson_mut_val *v = cbm_helm_values_parse(doc, src, strlen(src)); + ASSERT_NOT_NULL(v); + char buf[64]; + ASSERT_TRUE(cbm_helm_scalar_text(yyjson_mut_obj_get(v, "tag"), buf, sizeof(buf))); + ASSERT_STR_EQ(buf, "1.1"); + ASSERT_TRUE(cbm_helm_scalar_text(yyjson_mut_obj_get(v, "big"), buf, sizeof(buf))); + ASSERT_STR_EQ(buf, "2.023101e+07"); + ASSERT_TRUE(cbm_helm_scalar_text(yyjson_mut_obj_get(v, "m"), buf, sizeof(buf))); + ASSERT_STR_EQ(buf, "1e+06"); + ASSERT_TRUE(cbm_helm_scalar_text(yyjson_mut_obj_get(v, "oct"), buf, sizeof(buf))); + ASSERT_STR_EQ(buf, "493"); + ASSERT_TRUE(yyjson_mut_is_true(yyjson_mut_obj_get(v, "yes_"))); + ASSERT_TRUE(yyjson_mut_is_false(yyjson_mut_obj_get(v, "off_"))); + ASSERT_TRUE(yyjson_mut_is_null(yyjson_mut_obj_get(v, "nul"))); + ASSERT_STR_EQ(yyjson_mut_get_str(yyjson_mut_obj_get(v, "q")), "1.10"); + ASSERT_TRUE(cbm_helm_values_set(doc, v, "a\\.b.c[1]", "007", false)); + ASSERT_TRUE(cbm_helm_values_set(doc, v, "n", "42", false)); + ASSERT_TRUE(cbm_helm_values_set(doc, v, "s", "42", true)); + yyjson_mut_val *ab = yyjson_mut_obj_get(v, "a.b"); + ASSERT_NOT_NULL(ab); + yyjson_mut_val *c = yyjson_mut_obj_get(ab, "c"); + ASSERT_TRUE(yyjson_mut_is_arr(c)); + ASSERT_TRUE(yyjson_mut_is_null(yyjson_mut_arr_get(c, 0))); + ASSERT_STR_EQ(yyjson_mut_get_str(yyjson_mut_arr_get(c, 1)), "007"); + ASSERT_TRUE(yyjson_mut_is_int(yyjson_mut_obj_get(v, "n"))); + ASSERT_TRUE(yyjson_mut_is_str(yyjson_mut_obj_get(v, "s"))); + static const char over[] = "gone: null\nkeep: x\nnew: 1\n"; + yyjson_mut_val *o = cbm_helm_values_parse(doc, over, strlen(over)); + yyjson_mut_val *m = cbm_helm_values_coalesce(doc, v, o); + ASSERT_NULL(yyjson_mut_obj_get(m, "gone")); + ASSERT_STR_EQ(yyjson_mut_get_str(yyjson_mut_obj_get(m, "keep")), "x"); + ASSERT_NOT_NULL(yyjson_mut_obj_get(m, "new")); + ASSERT_STR_EQ(yyjson_mut_get_str(yyjson_mut_obj_get(v, "keep")), "k"); /* base untouched */ + /* a template: a missing last key renders "", a field of it is an error */ + static const char tpl[] = "image: \"{{ .Values.keep }}:{{ .Values.none }}\"\n" + "image: \"{{ .Values.none.repo }}\"\n"; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probes[2] = {{.line = 1}, {.line = 2}}; + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, probes, 2); + ASSERT_EQ(probes[0].n, 1); + ASSERT_STR_EQ(probes[0].image[0], "k:"); + ASSERT_EQ(probes[1].n, 1); + ASSERT_NULL(probes[1].image[0]); + cbm_arena_destroy(&a); + yyjson_mut_doc_free(doc); + PASS(); +} + +/* 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_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, meta, defs, &probe, 1); + char *out = strdup(probe.n == 0 ? "" : probe.image[0] ? probe.image[0] : ""); + cbm_arena_destroy(&a); + if (doc) { + yyjson_mut_doc_free(doc); + } + 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 + * partial or wrong image; a range over a huge int ends the same way. */ +TEST(helm_render_range_budget_ends_unknown) { + static const char vals[] = "tag: \"1.0\"\n"; + static const char ok[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n"; + char *got = helm_capture(vals, ok, NULL, 1); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + static const char rng[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 20000000 }}{{ end }}\n"; + got = helm_capture(vals, rng, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + static const char huge[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 2000000000 }}{{ range 2000000000 }}{{ end }}{{ end }}\n"; + got = helm_capture(vals, huge, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An indent whose output passes the output budget stops the render: no + * capture of it is known (an indent of 9e18 stops before any work). */ +TEST(helm_render_indent_budget_ends_unknown) { + static const char big[] = "image: \"ghcr.io/acme/app{{ indent 70000000 \"\" | trim }}:1\"\n"; + char *got = helm_capture("{}\n", big, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + static const char huge[] = + "image: \"ghcr.io/acme/app{{ indent 9000000000000000000 \"\" | trim }}:1\"\n"; + got = helm_capture("{}\n", huge, NULL, 1); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* Defines that each include the one below twice multiply the work by two a + * level: past the step budget the render stops, its captures UNKNOWN. */ +TEST(helm_render_define_fanout_ends_unknown) { + enum { LEVELS = 21, FAN_CAP = 8192 }; + char *fan = malloc(FAN_CAP); + ASSERT_NOT_NULL(fan); + int o = snprintf(fan, FAN_CAP, "{{- define \"d0\" -}}x{{- end -}}\n"); + for (int k = 1; k < LEVELS; k++) { + o += snprintf(fan + o, (size_t)(FAN_CAP - o), + "{{- define \"d%d\" -}}{{ include \"d%d\" . }}{{ include \"d%d\" . }}" + "{{- end -}}\n", + k, k - 1, k - 1); + } + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_fan.tpl", fan, strlen(fan)); + free(fan); + static const char tpl[] = "image: \"ghcr.io/acme/app:{{ include \"d20\" . | trunc 1 }}\"\n"; + char *got = helm_capture("{}\n", tpl, d, 1); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* go-yaml v2's aliasing limit (Helm reads values with it): a document whose + * decodes come mostly from alias expansion -- a billion laughs -- is no + * values; a few aliases still read. */ +TEST(helm_values_alias_expansion_limit) { + enum { LEVELS = 5, BUF = 4096 }; + char laughs[BUF]; + int o = snprintf(laughs, sizeof(laughs), "a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n"); + for (int k = 1; k < LEVELS; k++) { + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "a%d: &a%d [", k, k); + for (int i = 0; i < 10; i++) { + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "%s*a%d", i ? ", " : "", k - 1); + } + o += snprintf(laughs + o, sizeof(laughs) - (size_t)o, "]\n"); + } + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + ASSERT_NOT_NULL(doc); + static const char few[] = "base: &b {tag: \"1\"}\none: *b\ntwo: *b\n"; + yyjson_mut_val *ok = cbm_helm_values_parse(doc, few, strlen(few)); + yyjson_mut_val *bomb = cbm_helm_values_parse(doc, laughs, strlen(laughs)); + yyjson_mut_doc_free(doc); + ASSERT_NOT_NULL(ok); + ASSERT_NULL(bomb); + PASS(); +} + +/* --set as Helm's strvals reads it: a list index past MaxIndex (65536) is an + * error, so the set is no value (a release with it is unknown) -- never a + * list padded to it; a key longer than the parser holds is no value either, + * never split into two keys. */ +TEST(helm_values_set_index_cap_and_long_key) { + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + ASSERT_NOT_NULL(doc); + yyjson_mut_val *root = yyjson_mut_obj(doc); + ASSERT_TRUE(cbm_helm_values_set(doc, root, "list[3]", "x", false)); + ASSERT_FALSE(cbm_helm_values_set(doc, root, "big[65537]", "x", false)); + ASSERT_FALSE(cbm_helm_values_set(doc, root, "huge[9000000000000]", "x", false)); + char key[700]; + memset(key, 'k', sizeof(key) - 1); + key[sizeof(key) - 1] = '\0'; + ASSERT_FALSE(cbm_helm_values_set(doc, root, key, "x", false)); + yyjson_mut_doc_free(doc); + PASS(); +} + +/* A values key longer than 1 KiB read by an image is in its default_of + * mark whole -- a mark is never cut. */ +TEST(helm_render_long_key_mark_whole) { + enum { KEY = 1500 }; + char *key = malloc(KEY + 1); + ASSERT_NOT_NULL(key); + memset(key, 'k', KEY); + key[KEY] = '\0'; + size_t vn = KEY + CBM_SZ_64; + char *vals = malloc(vn); + char *tpl = malloc(vn); + ASSERT_NOT_NULL(vals); + ASSERT_NOT_NULL(tpl); + snprintf(vals, vn, "%s: \"1.0\"\n", key); + snprintf(tpl, vn, "image: \"ghcr.io/acme/app:{{ index .Values \"%s\" }}\"\n", key); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *v = cbm_helm_values_parse(doc, vals, strlen(vals)); + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = 1}; + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, &probe, 1); + char want[KEY + 8]; + snprintf(want, sizeof(want), "[\"%s\"]", key); + bool whole = probe.n == 1 && probe.keys[0] && strcmp(probe.keys[0], want) == 0; + cbm_arena_destroy(&a); + yyjson_mut_doc_free(doc); + free(key); + free(vals); + free(tpl); + ASSERT_TRUE(whole); + PASS(); +} + +/* A condition that is UNKNOWN runs no branch -- and what a branch would + * assign is UNKNOWN after it: `$tag = .Values.image.tagNew` under a + * .Capabilities test leaves no image naming the old tag. */ +TEST(helm_unknown_branch_spoils_what_it_assigns) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\", tagNew: \"2.0\"}\n"; + static const char tpl[] = + "{{- $tag := .Values.image.tag -}}\n" + "{{- if semverCompare \">=1.29-0\" .Capabilities.KubeVersion.Version }}" + "{{- $tag = .Values.image.tagNew -}}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* set / unset / merge change a map in place -- not modelled: what it holds + * after is UNKNOWN (an image read before the change keeps its value). */ +TEST(helm_mutator_spoils_the_map_it_changes) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" + "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *before = helm_capture(vals, tpl, NULL, 1); + char *after = helm_capture(vals, tpl, NULL, 3); + ASSERT_STR_EQ(before, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(after, ""); + free(before); + free(after); + PASS(); +} + +/* A break under an UNKNOWN condition in a range: whether the loop goes on + * is unknown, so its later iterations are not rendered (Helm with a true + * condition renders no image here). */ +TEST(helm_unknown_break_leaves_the_loop) { + static const char tpl[] = "{{- range list \"a\" \"b\" }}\n" + "{{- if .Capabilities.APIVersions }}{{ break }}{{ end }}\n" + "image: \"ghcr.io/acme/{{ . }}:1\"\n" + "{{ end }}\n"; + char *got = helm_capture("{}\n", tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* 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" + "{{- $user := deepCopy (.Values.cfg | default dict) -}}\n" + "{{- if .Capabilities.APIVersions }}{{- $config = toYaml .Values.cfg | fromYaml -}}" + "{{- $config = mergeOverwrite $config $user -}}{{- end }}\n" + "{{- $config = mergeOverwrite $config $user -}}\n" + "{{- $_ := unset (deepCopy .Values.cfg) \"a\" }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 6); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An image line inside a range: n captures of one probe cost memory linear + * in n (the capture arrays grow by doubling), not a copy of every earlier + * capture each time. */ +TEST(helm_render_captures_grow_linearly) { + enum { ITER = 5000 }; + static const char tpl[] = "{{- range 5000 }}\n" + "image: \"ghcr.io/acme/app:1\"\n" + "{{ end }}\n"; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probe = {.line = 2}; + size_t before = cbm_arena_total(&a); + cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), NULL, NULL, NULL, &probe, 1); + size_t used = cbm_arena_total(&a) - before; + int n = probe.n; + cbm_arena_destroy(&a); + ASSERT_EQ(n, ITER); + ASSERT_TRUE(used < (size_t)CBM_SZ_16 * CBM_SZ_1K * CBM_SZ_1K); /* far below 8*n^2 bytes */ + PASS(); +} + +/* Memory a render takes -- here a 20,000-item values list copied by each of + * 200 `first` calls -- counts against its budget: past 256 MiB it stops and + * the template's captures are UNKNOWN. */ +TEST(helm_render_memory_budget_ends_unknown) { + enum { ITEMS = 20000 }; + size_t cap = (size_t)ITEMS * 4 + CBM_SZ_64; + char *vals = malloc(cap); + ASSERT_NOT_NULL(vals); + size_t o = (size_t)snprintf(vals, cap, "tag: \"1.0\"\nbig: ["); + for (int i = 0; i < ITEMS; i++) { + o += (size_t)snprintf(vals + o, cap - o, "%s1", i ? "," : ""); + } + snprintf(vals + o, cap - o, "]\n"); + static const char tpl[] = "image: \"ghcr.io/acme/app:{{ .Values.tag }}\"\n" + "{{- range 200 }}{{ $x := first $.Values.big }}{{ end }}\n"; + char *got = helm_capture(vals, tpl, NULL, 1); + free(vals); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A set skipped by an UNKNOWN condition whose target is a call's result -- + * `index .Values "image"`, the idiom for a dashed key -- may change the map + * it names: what the image reads of it is UNKNOWN. */ +TEST(helm_skipped_set_of_a_call_result) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = + "{{- if semverCompare \">=1.29-0\" .Capabilities.KubeVersion.Version }}" + "{{- $_ := set (index .Values \"image\") \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A new map given a map another part holds is new no more: a change made + * through it (`set $img ...` of `index $d "image"`) reaches .Values. */ +TEST(helm_own_map_given_a_shared_one) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "{{- $d := dict -}}{{- $_ := set $d \"image\" .Values.image -}}\n" + "{{- $img := index $d \"image\" -}}{{- $_ := set $img \"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(); +} + +/* A set whose target is an UNKNOWN call result (index by .Release.Name) may + * change any map of the values: every image after it is UNKNOWN. */ +TEST(helm_set_of_an_unknown_call_result) { + static const char vals[] = + "apps: {web: {repository: ghcr.io/acme/web, tag: \"1.0\"}}\n"; + static const char tpl[] = "{{- $_ := set (index .Values.apps .Release.Name) \"tag\" \"2.0\" }}\n" + "image: \"{{ .Values.apps.web.repository }}:{{ .Values.apps.web.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A break under an UNKNOWN condition: whether the rest of the loop ran is + * unknown, so what it assigns ($tag = .) is UNKNOWN after the loop. */ +TEST(helm_unknown_break_spoils_the_rest_of_the_loop) { + static const char vals[] = "versions: [\"1.0\", \"2.0\"]\n"; + static const char tpl[] = + "{{- $tag := \"0.9\" }}{{- range .Values.versions }}\n" + "{{- if semverCompare \">=1.29-0\" $.Capabilities.KubeVersion.Version }}{{ break }}" + "{{ end }}\n" + "{{- $tag = . }}{{- end }}\n" + "image: \"ghcr.io/acme/app:{{ $tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 4); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* 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 -- + * 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[] = + "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $cfg := .Values.image }}{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + static const char assigned[] = + "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $cfg = .Values.image }}{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + static const char kept[] = "{{- $cfg := dict }}{{- if .Capabilities.APIVersions }}" + "{{- $_ := set $cfg \"tag\" \"2.0\" }}{{- end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, redefined, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + got = helm_capture(vals, assigned, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + got = helm_capture(vals, kept, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* Many maps poisoned (a new dict changed each iteration) keep every later + * read correct: a values read after them is known. */ +TEST(helm_many_changed_maps_keep_reads_right) { + static const char vals[] = "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n"; + static const char tpl[] = "{{- range 20000 }}{{ $c := dict }}{{ $_ := set $c \"k\" \"v\" }}" + "{{ end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, NULL, 2); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + PASS(); +} + +/* Defines that include each other: A changes its argument and includes B, + * B includes A. Whether B may change its argument is not settled by A's + * scan still running -- an UNKNOWN include of B with .Values.image spoils + * what reads it. */ +TEST(helm_define_memo_under_mutual_recursion) { + static const char defs_src[] = + "{{- define \"A\" -}}{{- include \"B\" . -}}{{- $_ := set . \"tag\" \"2.0\" -}}{{- end -}}\n" + "{{- define \"B\" -}}{{- if false }}{{ include \"A\" . }}{{ end -}}{{- end -}}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_ab.tpl", defs_src, strlen(defs_src)); + static const char vals[] = + "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\nother: {tag: \"x\"}\n"; + static const char tpl[] = + "{{- if .Capabilities.APIVersions }}{{ include \"A\" .Values.other }}{{ end }}\n" + "{{- if .Capabilities.APIVersions }}{{ include \"B\" .Values.image }}{{ end }}\n" + "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"; + char *got = helm_capture(vals, tpl, d, 3); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* An include not run because its argument is poisoned may still change maps + * within it: a handle taken before ($sub) reads UNKNOWN after. */ +TEST(helm_include_with_poisoned_argument) { + static const char defs_src[] = + "{{- define \"mut\" -}}{{- $_ := set .sub \"tag\" \"2.0\" -}}{{- end -}}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "templates/_mut.tpl", defs_src, strlen(defs_src)); + static const char vals[] = "image: {sub: {repository: ghcr.io/acme/app, tag: \"1.0\"}}\n"; + static const char tpl[] = "{{- $sub := .Values.image.sub -}}\n" + "{{- $_ := set .Values.image \"x\" \"y\" -}}\n" + "{{- include \"mut\" .Values.image -}}\n" + "image: \"{{ $sub.repository }}:{{ $sub.tag }}\"\n"; + char *got = helm_capture(vals, tpl, d, 4); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(got, ""); + free(got); + 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(); +} + +static const char HELM_IMAGE_VALUES[] = + "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\ncfg: {a: {}}\nempty: {}\n"; +#define HELM_IMAGE_LINE "image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" + +/* A set below a values map puts .Values.image at cfg.a.d; a deep merge into + * the map above it (.Values.cfg) reaches .Values.image there: the image after + * it is UNKNOWN. */ +TEST(helm_deep_merge_over_a_change_below) { + static const char tpl[] = + "{{- $_ := set .Values.cfg.a \"d\" .Values.image }}\n" + "{{- $_ := mergeOverwrite .Values.cfg (dict \"a\" (dict \"d\" (dict \"tag\" \"2.0\"))) }}\n" + HELM_IMAGE_LINE; + char *got = helm_capture(HELM_IMAGE_VALUES, tpl, NULL, 3); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* and / or stop at an operand whose truth is not known (.Release.Namespace; + * a map a set changed -- .Values.empty is empty no more): whether the + * operands after it run is not known, nor a change they make -- the image + * after it is UNKNOWN. A known false one runs none: the image stays known. */ +TEST(helm_and_or_with_an_unknown_operand) { + static const char *const tpls[] = { + "{{- $_ := and .Release.Namespace (set .Values.image \"tag\" \"2.0\") }}\n" HELM_IMAGE_LINE, + "{{- $_ := or .Release.Namespace (set .Values.image \"tag\" \"2.0\") }}\n" HELM_IMAGE_LINE, + "{{- $_ := set .Values.empty \"x\" \"y\" }}{{- $_ := and .Values.empty " + "(set .Values.image \"tag\" \"2.0\") }}\n" HELM_IMAGE_LINE}; + for (size_t i = 0; i < sizeof(tpls) / sizeof(tpls[0]); i++) { + char *got = helm_capture(HELM_IMAGE_VALUES, tpls[i], NULL, 2); + bool unknown = strcmp(got, "") == 0; + free(got); + ASSERT_TRUE(unknown); + } + static const char known[] = + "{{- $_ := and false (set .Values.image \"tag\" \"2.0\") }}\n" HELM_IMAGE_LINE; + char *got = helm_capture(HELM_IMAGE_VALUES, known, NULL, 2); + ASSERT_STR_EQ(got, "ghcr.io/acme/app:1.0"); + free(got); + PASS(); +} + +/* An if whose first condition is UNKNOWN: Helm evaluates the else-if + * conditions after it -- a set there, or an assignment ($tag = ...), may run: + * the image after it is UNKNOWN. */ +TEST(helm_later_condition_of_a_skipped_if) { + static const char set[] = + "{{- if regexMatch \"^v\" .Values.image.tag }}{{- else if set .Values.image \"tag\" \"2.0\" }}" + "{{- end }}\n" HELM_IMAGE_LINE; + char *got = helm_capture(HELM_IMAGE_VALUES, set, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + static const char assign[] = + "{{- $tag := \"1.0\" }}{{- if regexMatch \"^v\" .Values.image.tag }}" + "{{- else if $tag = \"2.0\" }}{{- end }}\n" + "image: \"ghcr.io/acme/app:{{ $tag }}\"\n"; + got = helm_capture(HELM_IMAGE_VALUES, assign, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* sortAlpha sorts a []string in place (splitList gives one): the list $tags + * holds changes -- not run here, so the image after it is UNKNOWN. */ +TEST(helm_sort_alpha_changes_its_list) { + static const char tpl[] = "{{- $tags := splitList \",\" .Values.tags }}" + "{{- $_ := sortAlpha $tags }}\n" + "image: \"ghcr.io/acme/app:{{ first $tags }}\"\n"; + char *got = helm_capture("tags: \"2.0,1.0\"\n", tpl, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + PASS(); +} + +/* A form the grammar rejects may hide a change (Go may take it): a template, + * a define it runs, or a template file it includes that does not parse + * leaves its images UNKNOWN. */ +TEST(helm_parse_error_spoils) { + static const char bad[] = "{{- $x := }}\n" HELM_IMAGE_LINE; + char *got = helm_capture(HELM_IMAGE_VALUES, bad, NULL, 2); + ASSERT_STR_EQ(got, ""); + free(got); + static const char defs_src[] = + "{{- define \"bad\" -}}{{- $x := }}{{- end -}}\n" + "{{- define \"good\" -}}ok{{- end -}}\n"; + cbm_helm_defines_t *d = cbm_helm_defines_new(); + ASSERT_NOT_NULL(d); + cbm_helm_defines_add(d, "charts/app/templates/_bad.tpl", defs_src, strlen(defs_src)); + static const char bad_file[] = "{{- $x := }}\nkind: ConfigMap\n"; + cbm_helm_defines_add(d, "charts/app/templates/bad.yaml", bad_file, strlen(bad_file)); + static const char good[] = "x: {{ include \"good\" . }}\n" HELM_IMAGE_LINE; + static const char via_define[] = "x: {{ include \"bad\" . }}\n" HELM_IMAGE_LINE; + static const char via_file[] = + "x: {{ include (print $.Template.BasePath \"/bad.yaml\") . | sha256sum }}\n" HELM_IMAGE_LINE; + char *ok = helm_capture_chart(HELM_IMAGE_VALUES, good, d, &HELM_APP, 2); + char *by_define = helm_capture_chart(HELM_IMAGE_VALUES, via_define, d, &HELM_APP, 2); + char *by_file = helm_capture_chart(HELM_IMAGE_VALUES, via_file, d, &HELM_APP, 2); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(ok, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(by_define, ""); + ASSERT_STR_EQ(by_file, ""); + free(ok); + free(by_define); + free(by_file); + PASS(); +} + +/* One capture reading 20,000 keys, then 2,000 captures of two keys each: + * every image and mark after the big one stays right (its keys leave the + * set one by one; the set is never cleared by walking its capacity). */ +TEST(helm_captures_after_a_huge_capture) { + enum { ITEMS = 20000, LATER = 2000 }; + size_t cap = (size_t)ITEMS * 8 + CBM_SZ_256; + char *vals = malloc(cap); + ASSERT_NOT_NULL(vals); + size_t o = (size_t)snprintf(vals, cap, "image: {repository: ghcr.io/acme/app, tag: \"1.0\"}\n" + "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"); + 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: \"r/{{ range .Values.big }}{{ .x }}{{ end }}\"\n" + "{{- range 2000 }}\n" + "image: \"{{ $.Values.image.repository }}:{{ $.Values.image.tag }}\"\n" + "{{- end }}\n"; + CBMArena a; + cbm_arena_init_lazy(&a, CBM_SZ_4K); + cbm_helm_probe_t probes[2] = {{.line = 1}, {.line = 3}}; + bool ok = cbm_helm_render(&a, "t.yaml", tpl, strlen(tpl), v, NULL, NULL, probes, 2); + int n = probes[1].n; + bool last = n == LATER && probes[1].image[n - 1] && + strcmp(probes[1].image[n - 1], "ghcr.io/acme/app:1.0") == 0 && + probes[1].keys[n - 1] && + strcmp(probes[1].keys[n - 1], "[\"image.repository\",\"image.tag\"]") == 0; + cbm_arena_destroy(&a); + if (doc) { + yyjson_mut_doc_free(doc); + } + ASSERT_TRUE(ok); + ASSERT_EQ(n, LATER); + ASSERT_TRUE(last); + PASS(); +} + +/* 5,000 includes of one name in a loop -- a template file of the chart with + * no change, or a name that is none -- get one answer each time (asked + * once): the first spoils nothing, the second every image after it. */ +TEST(helm_includes_of_one_name_in_a_loop) { + cbm_helm_defines_t *d = helm_chart_files(); + ASSERT_NOT_NULL(d); + static const char clean[] = + "{{- range 5000 }}{{ $_ := include (print $.Template.BasePath \"/secret.yaml\") $ }}" + "{{ end }}\n" HELM_IMAGE_LINE; + static const char missing[] = + "{{- range 5000 }}{{ $_ := include (print $.Template.BasePath \"/missing.yaml\") $ }}" + "{{ end }}\n" HELM_IMAGE_LINE; + char *kept = helm_capture_chart(HELM_IMAGE_VALUES, clean, d, &HELM_APP, 2); + char *spoiled = helm_capture_chart(HELM_IMAGE_VALUES, missing, d, &HELM_APP, 2); + cbm_helm_defines_free(d); + ASSERT_STR_EQ(kept, "ghcr.io/acme/app:1.0"); + ASSERT_STR_EQ(spoiled, ""); + free(kept); + free(spoiled); + 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. */ @@ -23930,6 +24945,49 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(incremental_env_edit_replaces_scoped_envvars); RUN_TEST(incremental_image_split_change_rebuilds); RUN_TEST(incremental_helm_values_edit_keeps_chart_edge); + RUN_TEST(incremental_werf_values_edit_keeps_chart_edge); + RUN_TEST(incremental_release_values_edit_rebuilds); + RUN_TEST(helm_values_follow_helm_semantics); + RUN_TEST(helm_render_range_budget_ends_unknown); + RUN_TEST(helm_render_indent_budget_ends_unknown); + RUN_TEST(helm_render_define_fanout_ends_unknown); + RUN_TEST(helm_values_alias_expansion_limit); + RUN_TEST(helm_values_set_index_cap_and_long_key); + RUN_TEST(helm_render_long_key_mark_whole); + RUN_TEST(helm_unknown_branch_spoils_what_it_assigns); + RUN_TEST(helm_mutator_spoils_the_map_it_changes); + RUN_TEST(helm_unknown_break_leaves_the_loop); + RUN_TEST(helm_change_of_a_new_value_spoils_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); + RUN_TEST(helm_own_map_given_a_shared_one); + RUN_TEST(helm_set_of_an_unknown_call_result); + RUN_TEST(helm_unknown_break_spoils_the_rest_of_the_loop); + RUN_TEST(helm_skipped_set_after_redefinition); + 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(helm_deep_merge_over_a_change_below); + RUN_TEST(helm_and_or_with_an_unknown_operand); + RUN_TEST(helm_later_condition_of_a_skipped_if); + RUN_TEST(helm_sort_alpha_changes_its_list); + RUN_TEST(helm_parse_error_spoils); + RUN_TEST(helm_captures_after_a_huge_capture); + RUN_TEST(helm_includes_of_one_name_in_a_loop); 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);