From 851096ad373459e8ebfca68d62b91cedc0bbece5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 16 Oct 2026 18:07:31 +0200 Subject: [PATCH] feat(k8s): a Helm chart deploys its objects, images and values A Chart node had one kind of outgoing edge, DEPENDS_ON for a dependency its Chart.yaml declares. A chart without dependencies, most of them, was an isolated leaf: nothing linked it to the objects its templates render, to its values files, or to the images it runs. On 20 pinned infra corpora, 238 of 260 Charts had no outgoing edge, while 2,626 objects sat in chart templates/ directories. - Chart -DEPLOYS {via: chart}-> each Resource of /templates/, for its nearest enclosing chart: a subchart's objects are the subchart's. The same edge a Flux Kustomization draws to what it applies. - values.yaml / values-.yaml beside Chart.yaml -CONFIGURES {strategy: helm_values}-> the Chart. - Chart -DEPLOYS {via: chart, ref: image}-> each Image its objects name literally, read after pass_docker.c moved references onto component Images. A templated image names no Image, and no values default is read. An incremental closure repair re-runs the K8s pass over its plan only, so a values file or a template edited alone would lose the chart's edge; such an edit now declines to the full rebuild, like a Chart.yaml edit. Signed-off-by: Martin Vogel --- src/pipeline/pass_k8s.c | 142 ++++++++++++++++++++++++++++ src/pipeline/pipeline_incremental.c | 40 ++++++++ src/pipeline/pipeline_internal.h | 3 + tests/test_grammar_probe_f.c | 72 ++++++++++++++ tests/test_pipeline.c | 31 ++++++ 5 files changed, 288 insertions(+) diff --git a/src/pipeline/pass_k8s.c b/src/pipeline/pass_k8s.c index 1af48be43..a9e92acc7 100644 --- a/src/pipeline/pass_k8s.c +++ b/src/pipeline/pass_k8s.c @@ -3631,6 +3631,145 @@ static int64_t k8s_external_chart(k8s_dep_link_t *l, const char *name, const cha return cbm_pipeline_external_chart(l->ctx, name, url, file); } +/* ── A chart's own edges (PHASE 2 P2.4) ────────────────────────────── */ + +bool cbm_helm_values_name(const char *base) { + size_t vl = K8S_SLEN("values"); + if (!base || strncmp(base, "values", vl) != 0) { + return false; + } + const char *r = base + vl; + if (*r == '-') { + const char *dot = strrchr(r, '.'); + if (!dot || dot == r + SKIP_ONE) { + return false; + } + r = dot; + } + return strcmp(r, ".yaml") == 0 || strcmp(r, ".yml") == 0; +} + +/* Is `rel` a values file directly in chart dir `dir`? */ +static bool k8s_chart_values_file(const char *rel, const char *dir) { + size_t dl = strlen(dir); + if (dl && (strncmp(rel, dir, dl) != 0 || rel[dl] != '/')) { + return false; + } + const char *base = rel + (dl ? dl + SKIP_ONE : 0); + return !strchr(base, '/') && cbm_helm_values_name(base); +} + +/* 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. */ +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; + } + k8s_dep_link_t l = {.ctx = ctx, .charts = k8s_index_by_file(ctx, "Chart")}; + if (!l.charts) { + return; + } + int configures = 0; + static const char *const labels[] = {"Resource", "File"}; + for (size_t k = 0; k < sizeof(labels) / sizeof(labels[0]); k++) { + const cbm_gbuf_node_t **nodes = NULL; + int n = 0; + if (cbm_gbuf_find_by_label(ctx->gbuf, labels[k], &nodes, &n) != 0) { + continue; + } + for (int i = 0; i < n; i++) { + const char *fp = nodes[i]->file_path; + char dir[CBM_SZ_1K]; + int64_t chart = 0; + if (fp && k8s_chart_of(b, fp, dir, sizeof(dir))) { + chart = k8s_chart_node(&l, dir); + } + if (chart <= 0) { + continue; + } + if (k == 0) { + char tpl[CBM_SZ_1K]; + snprintf(tpl, sizeof(tpl), "%s%stemplates/", dir, dir[0] ? "/" : ""); + if (strncmp(fp, tpl, strlen(tpl)) == 0) { + k8s_deploys_edge(&l, chart, nodes[i]->id, "{\"via\":\"chart\"}"); + } + } else if (k8s_chart_values_file(fp, dir)) { + cbm_gbuf_insert_edge(ctx->gbuf, nodes[i]->id, chart, "CONFIGURES", + "{\"strategy\":\"helm_values\"}"); + configures++; + } + } + } + k8s_free_index(l.charts); + if (l.edges + configures > 0) { + cbm_log_info("pass.k8s.chart_contents", "deploys", itoa_k8s(l.edges), "values", + itoa_k8s(configures)); + } +} + +/* Each Image a chart's objects name literally (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. */ +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; + int cap = 0; + for (int c = 0; c < nc; c++) { + const cbm_gbuf_edge_t **dep = NULL; + int nd = 0; + if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, charts[c]->id, "DEPLOYS", &dep, &nd) != + 0) { + continue; + } + for (int d = 0; d < nd; d++) { + const char *dp = dep[d]->properties_json; + if (!dp || !strstr(dp, "\"via\":\"chart\"") || strstr(dp, "\"ref\":")) { + continue; + } + const cbm_gbuf_edge_t **use = NULL; + int nu = 0; + if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, dep[d]->target_id, "USAGE", &use, + &nu) != 0) { + continue; + } + for (int u = 0; u < nu; u++) { + const cbm_gbuf_node_t *img = cbm_gbuf_find_by_id(ctx->gbuf, use[u]->target_id); + const char *up = use[u]->properties_json; + if (!img || !img->label || strcmp(img->label, "Image") != 0 || !up || + !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) { + break; + } + pairs = grown; + cap = ncap; + } + 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\"}"); + } + cbm_free(CBM_MEM_CLASS_OTHER, pairs); +} + /* A HelmRelease whose chartRef names OCIRepository `oci` (its USAGE edge * {"ref":"chartRef"}, resolved with the references) deploys the chart that * repository fetches: the external Chart of that OCI reference (its last @@ -4517,6 +4656,7 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, * then the templates' includes, which see those dependencies' defines. */ 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); k8s_kusts_free(&kusts); /* Shell and Make deploy commands, against the nodes linked above. */ @@ -4524,6 +4664,8 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, cbm_pipeline_pass_terraform(ctx, files, file_count); 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. */ + k8s_link_chart_images(ctx); /* The variables a container sets, to the readers in its own image. */ (void)cbm_pipeline_env_join(ctx); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 519dac749..99df053e4 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -2456,6 +2456,32 @@ 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. */ +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, '/'); + for (;;) { + if (cut) { + *cut = '\0'; + } else { + out[0] = '\0'; + } + static const char *const names[] = {"Chart.yaml", "Chart.yml"}; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) { + char path[CBM_SZ_4K]; + snprintf(path, sizeof(path), "%s/%s%s%s", repo_path, out, out[0] ? "/" : "", names[i]); + if (cbm_file_exists(path)) { + return true; + } + } + if (!out[0]) { + return false; + } + cut = strrchr(out, '/'); + } +} + /* 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 @@ -2475,6 +2501,20 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project, if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0) { return "helm_chart_changed"; } + /* A chart's values file CONFIGURES it and its templates/ are what it + * DEPLOYS: edges the K8s pass draws from the chart directory, which a + * repair of this file alone does not hold. */ + char chart[CBM_SZ_4K]; + if (closure_chart_dir_of(repo_path, rel, chart, sizeof(chart))) { + size_t cl = strlen(chart); + const char *in_chart = rel + (cl ? cl + SKIP_ONE : 0); + if (cbm_helm_values_name(in_chart)) { + return "helm_values_changed"; + } + if (strncmp(in_chart, "templates/", strlen("templates/")) == 0) { + return "helm_template_changed"; + } + } if (strcmp(base, ".env") == 0 && closure_dir_has_compose(repo_path, rel, base)) { return "compose_env_changed"; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index a54dea245..b4f9c08c9 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -1057,6 +1057,9 @@ int cbm_parse_compile_commands(const char *json_data, const char *repo_path, cha /* File identification helpers */ bool cbm_is_kustomize_file(const char *name); 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); /* Secret detection */ bool cbm_is_secret_binding(const char *key, const char *value); diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index 6450ab5b5..a917939fa 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -4372,6 +4372,77 @@ TEST(probe_helm_templated_objects_are_resources) { PASS(); } +/* Every DEPLOYS edge out of a Chart and CONFIGURES edge into one, as + * " ->