Skip to content

Commit 851096a

Browse files
committed
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 <chart>/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-<x>.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 <martin.vogel.tech@gmail.com>
1 parent ec6427c commit 851096a

5 files changed

Lines changed: 288 additions & 0 deletions

File tree

‎src/pipeline/pass_k8s.c‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3631,6 +3631,145 @@ static int64_t k8s_external_chart(k8s_dep_link_t *l, const char *name, const cha
36313631
return cbm_pipeline_external_chart(l->ctx, name, url, file);
36323632
}
36333633

3634+
/* ── A chart's own edges (PHASE 2 P2.4) ────────────────────────────── */
3635+
3636+
bool cbm_helm_values_name(const char *base) {
3637+
size_t vl = K8S_SLEN("values");
3638+
if (!base || strncmp(base, "values", vl) != 0) {
3639+
return false;
3640+
}
3641+
const char *r = base + vl;
3642+
if (*r == '-') {
3643+
const char *dot = strrchr(r, '.');
3644+
if (!dot || dot == r + SKIP_ONE) {
3645+
return false;
3646+
}
3647+
r = dot;
3648+
}
3649+
return strcmp(r, ".yaml") == 0 || strcmp(r, ".yml") == 0;
3650+
}
3651+
3652+
/* Is `rel` a values file directly in chart dir `dir`? */
3653+
static bool k8s_chart_values_file(const char *rel, const char *dir) {
3654+
size_t dl = strlen(dir);
3655+
if (dl && (strncmp(rel, dir, dl) != 0 || rel[dl] != '/')) {
3656+
return false;
3657+
}
3658+
const char *base = rel + (dl ? dl + SKIP_ONE : 0);
3659+
return !strchr(base, '/') && cbm_helm_values_name(base);
3660+
}
3661+
3662+
/* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}->
3663+
* each Resource of <chart>/templates/ -- its nearest enclosing chart, so a
3664+
* subchart's objects are the subchart's. Its values files (values.yaml,
3665+
* values-<x>.yaml beside Chart.yaml) CONFIGURE {strategy: helm_values} it. */
3666+
static void k8s_link_chart_contents(cbm_pipeline_ctx_t *ctx, k8s_bundles_t *b) {
3667+
if (!b->charts || cbm_ht_count(b->charts) == 0) {
3668+
return;
3669+
}
3670+
k8s_dep_link_t l = {.ctx = ctx, .charts = k8s_index_by_file(ctx, "Chart")};
3671+
if (!l.charts) {
3672+
return;
3673+
}
3674+
int configures = 0;
3675+
static const char *const labels[] = {"Resource", "File"};
3676+
for (size_t k = 0; k < sizeof(labels) / sizeof(labels[0]); k++) {
3677+
const cbm_gbuf_node_t **nodes = NULL;
3678+
int n = 0;
3679+
if (cbm_gbuf_find_by_label(ctx->gbuf, labels[k], &nodes, &n) != 0) {
3680+
continue;
3681+
}
3682+
for (int i = 0; i < n; i++) {
3683+
const char *fp = nodes[i]->file_path;
3684+
char dir[CBM_SZ_1K];
3685+
int64_t chart = 0;
3686+
if (fp && k8s_chart_of(b, fp, dir, sizeof(dir))) {
3687+
chart = k8s_chart_node(&l, dir);
3688+
}
3689+
if (chart <= 0) {
3690+
continue;
3691+
}
3692+
if (k == 0) {
3693+
char tpl[CBM_SZ_1K];
3694+
snprintf(tpl, sizeof(tpl), "%s%stemplates/", dir, dir[0] ? "/" : "");
3695+
if (strncmp(fp, tpl, strlen(tpl)) == 0) {
3696+
k8s_deploys_edge(&l, chart, nodes[i]->id, "{\"via\":\"chart\"}");
3697+
}
3698+
} else if (k8s_chart_values_file(fp, dir)) {
3699+
cbm_gbuf_insert_edge(ctx->gbuf, nodes[i]->id, chart, "CONFIGURES",
3700+
"{\"strategy\":\"helm_values\"}");
3701+
configures++;
3702+
}
3703+
}
3704+
}
3705+
k8s_free_index(l.charts);
3706+
if (l.edges + configures > 0) {
3707+
cbm_log_info("pass.k8s.chart_contents", "deploys", itoa_k8s(l.edges), "values",
3708+
itoa_k8s(configures));
3709+
}
3710+
}
3711+
3712+
/* Each Image a chart's objects name literally (their USAGE {ref: image}, after
3713+
* pass_docker.c moved references onto component Images): Chart -DEPLOYS
3714+
* {via: chart, ref: image}-> it. A templated image names no Image, and no
3715+
* values default is read. Edges are collected first: inserting while walking
3716+
* a chart's own DEPLOYS edges would grow the list being read. */
3717+
static void k8s_link_chart_images(cbm_pipeline_ctx_t *ctx) {
3718+
const cbm_gbuf_node_t **charts = NULL;
3719+
int nc = 0;
3720+
if (cbm_gbuf_find_by_label(ctx->gbuf, "Chart", &charts, &nc) != 0 || nc == 0) {
3721+
return;
3722+
}
3723+
int64_t *pairs = NULL;
3724+
int np = 0;
3725+
int cap = 0;
3726+
for (int c = 0; c < nc; c++) {
3727+
const cbm_gbuf_edge_t **dep = NULL;
3728+
int nd = 0;
3729+
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, charts[c]->id, "DEPLOYS", &dep, &nd) !=
3730+
0) {
3731+
continue;
3732+
}
3733+
for (int d = 0; d < nd; d++) {
3734+
const char *dp = dep[d]->properties_json;
3735+
if (!dp || !strstr(dp, "\"via\":\"chart\"") || strstr(dp, "\"ref\":")) {
3736+
continue;
3737+
}
3738+
const cbm_gbuf_edge_t **use = NULL;
3739+
int nu = 0;
3740+
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, dep[d]->target_id, "USAGE", &use,
3741+
&nu) != 0) {
3742+
continue;
3743+
}
3744+
for (int u = 0; u < nu; u++) {
3745+
const cbm_gbuf_node_t *img = cbm_gbuf_find_by_id(ctx->gbuf, use[u]->target_id);
3746+
const char *up = use[u]->properties_json;
3747+
if (!img || !img->label || strcmp(img->label, "Image") != 0 || !up ||
3748+
!strstr(up, "\"ref\":\"image\"")) {
3749+
continue;
3750+
}
3751+
if (np + PAIR_LEN > cap) {
3752+
int ncap = cap ? cap * PAIR_LEN : CBM_SZ_64;
3753+
int64_t *grown =
3754+
cbm_realloc(CBM_MEM_CLASS_OTHER, pairs, (size_t)ncap * sizeof(*pairs));
3755+
if (!grown) {
3756+
break;
3757+
}
3758+
pairs = grown;
3759+
cap = ncap;
3760+
}
3761+
pairs[np++] = charts[c]->id;
3762+
pairs[np++] = img->id;
3763+
}
3764+
}
3765+
}
3766+
for (int i = 0; i + SKIP_ONE < np; i += PAIR_LEN) {
3767+
cbm_gbuf_insert_edge(ctx->gbuf, pairs[i], pairs[i + SKIP_ONE], "DEPLOYS",
3768+
"{\"via\":\"chart\",\"ref\":\"image\"}");
3769+
}
3770+
cbm_free(CBM_MEM_CLASS_OTHER, pairs);
3771+
}
3772+
36343773
/* A HelmRelease whose chartRef names OCIRepository `oci` (its USAGE edge
36353774
* {"ref":"chartRef"}, resolved with the references) deploys the chart that
36363775
* repository fetches: the external Chart of that OCI reference (its last
@@ -4517,13 +4656,16 @@ int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
45174656
* then the templates' includes, which see those dependencies' defines. */
45184657
k8s_link_local_deps(ctx, &local_deps);
45194658
k8s_link_includes(ctx, &bundles, files, file_count);
4659+
k8s_link_chart_contents(ctx, &bundles);
45204660
k8s_bundles_free(&bundles);
45214661
k8s_kusts_free(&kusts);
45224662
/* Shell and Make deploy commands, against the nodes linked above. */
45234663
cbm_pipeline_pass_shell(ctx, files, file_count);
45244664
cbm_pipeline_pass_terraform(ctx, files, file_count);
45254665
cbm_pipeline_pass_ansible(ctx, files, file_count);
45264666
cbm_pipeline_pass_docker(ctx, files, file_count);
4667+
/* The Images a chart's objects use, once the image hubs moved them. */
4668+
k8s_link_chart_images(ctx);
45274669
/* The variables a container sets, to the readers in its own image. */
45284670
(void)cbm_pipeline_env_join(ctx);
45294671

‎src/pipeline/pipeline_incremental.c‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2456,6 +2456,32 @@ static bool closure_dir_has_compose(const char *repo_path, const char *rel, cons
24562456
return found;
24572457
}
24582458

2459+
/* The nearest directory at or above `rel`'s that holds a Chart.yaml, into out
2460+
* ("" for the repository root); false when none does. */
2461+
static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *out, size_t sz) {
2462+
snprintf(out, sz, "%s", rel);
2463+
char *cut = strrchr(out, '/');
2464+
for (;;) {
2465+
if (cut) {
2466+
*cut = '\0';
2467+
} else {
2468+
out[0] = '\0';
2469+
}
2470+
static const char *const names[] = {"Chart.yaml", "Chart.yml"};
2471+
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
2472+
char path[CBM_SZ_4K];
2473+
snprintf(path, sizeof(path), "%s/%s%s%s", repo_path, out, out[0] ? "/" : "", names[i]);
2474+
if (cbm_file_exists(path)) {
2475+
return true;
2476+
}
2477+
}
2478+
if (!out[0]) {
2479+
return false;
2480+
}
2481+
cut = strrchr(out, '/');
2482+
}
2483+
}
2484+
24592485
/* Kubernetes inputs reach past their own file (pass_k8s.c): a kustomization
24602486
* decides which manifests deploy together and in which namespace, a
24612487
* 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,
24752501
if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0) {
24762502
return "helm_chart_changed";
24772503
}
2504+
/* A chart's values file CONFIGURES it and its templates/ are what it
2505+
* DEPLOYS: edges the K8s pass draws from the chart directory, which a
2506+
* repair of this file alone does not hold. */
2507+
char chart[CBM_SZ_4K];
2508+
if (closure_chart_dir_of(repo_path, rel, chart, sizeof(chart))) {
2509+
size_t cl = strlen(chart);
2510+
const char *in_chart = rel + (cl ? cl + SKIP_ONE : 0);
2511+
if (cbm_helm_values_name(in_chart)) {
2512+
return "helm_values_changed";
2513+
}
2514+
if (strncmp(in_chart, "templates/", strlen("templates/")) == 0) {
2515+
return "helm_template_changed";
2516+
}
2517+
}
24782518
if (strcmp(base, ".env") == 0 && closure_dir_has_compose(repo_path, rel, base)) {
24792519
return "compose_env_changed";
24802520
}

‎src/pipeline/pipeline_internal.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,9 @@ int cbm_parse_compile_commands(const char *json_data, const char *repo_path, cha
10571057
/* File identification helpers */
10581058
bool cbm_is_kustomize_file(const char *name);
10591059
bool cbm_is_k8s_manifest(const char *name, const char *content);
1060+
/* A Helm values file name: values.yaml / .yml, values-<x>.yaml / .yml
1061+
* (pass_k8s.c; beside Chart.yaml it CONFIGURES the chart). */
1062+
bool cbm_helm_values_name(const char *base);
10601063

10611064
/* Secret detection */
10621065
bool cbm_is_secret_binding(const char *key, const char *value);

‎tests/test_grammar_probe_f.c‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4372,6 +4372,77 @@ TEST(probe_helm_templated_objects_are_resources) {
43724372
PASS();
43734373
}
43744374

4375+
/* Every DEPLOYS edge out of a Chart and CONFIGURES edge into one, as
4376+
* "<source> -> <label>:<target> (<type>[ <ref>])", a File by its path,
4377+
* sorted, one per line. */
4378+
static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
4379+
GpfProj lp;
4380+
cbm_store_t *store = gpf_index_files(&lp, files, nfiles);
4381+
char *out = NULL;
4382+
if (store) {
4383+
sqlite3_stmt *st = NULL;
4384+
const char *sql =
4385+
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT CASE WHEN "
4386+
"s.label='File' THEN s.file_path ELSE s.name END || ' -> ' || t.label || ':' || "
4387+
"t.name || ' (' || e.type || coalesce(' ' || json_extract(e.properties,'$.ref'), '') "
4388+
"|| ')' AS x FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON "
4389+
"t.id=e.target_id WHERE e.project=?1 AND ((s.label='Chart' AND e.type='DEPLOYS') OR "
4390+
"(t.label='Chart' AND e.type='CONFIGURES')) ORDER BY x)";
4391+
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
4392+
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
4393+
if (sqlite3_step(st) == SQLITE_ROW) {
4394+
out = strdup((const char *)sqlite3_column_text(st, 0));
4395+
}
4396+
}
4397+
sqlite3_finalize(st);
4398+
}
4399+
gpf_cleanup(&lp, store);
4400+
return out;
4401+
}
4402+
4403+
/* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}->
4404+
* each object of its templates/ (a subchart's objects are the subchart's),
4405+
* and each Image those objects name literally ({ref: image}) -- a templated
4406+
* image names none, and no values default is read. Its values files
4407+
* (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file
4408+
* elsewhere (ci/) does not. */
4409+
TEST(probe_helm_chart_deploys_its_objects) {
4410+
static const GpfFile files[] = {
4411+
{"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\n"},
4412+
{"charts/web/values.yaml", "image:\n repository: nginx\n tag: \"1.27\"\nreplicas: 1\n"},
4413+
{"charts/web/values-prod.yaml", "replicas: 3\n"},
4414+
{"charts/web/ci/test-values.yaml", "replicas: 2\n"},
4415+
{"charts/web/templates/deploy.yaml",
4416+
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n"
4417+
" replicas: {{ .Values.replicas }}\n template:\n spec:\n containers:\n"
4418+
" - name: web\n"
4419+
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
4420+
" - name: side\n image: ghcr.io/acme/sidecar:1\n"},
4421+
{"charts/web/templates/svc.yaml",
4422+
"apiVersion: v1\nkind: Service\nmetadata:\n name: web\nspec:\n selector:\n"
4423+
" app: web\n"},
4424+
{"charts/web/charts/db/Chart.yaml", "apiVersion: v2\nname: db\nversion: 0.1.0\n"},
4425+
{"charts/web/charts/db/templates/sts.yaml",
4426+
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: db\nspec:\n template:\n"
4427+
" spec:\n containers:\n - name: db\n"
4428+
" image: ghcr.io/acme/db:16\n"}};
4429+
char *got = gpf_chart_edges(files, 8);
4430+
static const char want[] = "charts/web/values-prod.yaml -> Chart:web (CONFIGURES)\n"
4431+
"charts/web/values.yaml -> Chart:web (CONFIGURES)\n"
4432+
"db -> Image:ghcr.io/acme/db (DEPLOYS image)\n"
4433+
"db -> Resource:StatefulSet/db (DEPLOYS)\n"
4434+
"web -> Image:ghcr.io/acme/sidecar (DEPLOYS image)\n"
4435+
"web -> Resource:Deployment/web (DEPLOYS)\n"
4436+
"web -> Resource:Service/web (DEPLOYS)";
4437+
if (!got || strcmp(got, want) != 0) {
4438+
fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
4439+
free(got);
4440+
FAIL("Chart DEPLOYS / CONFIGURES edge set");
4441+
}
4442+
free(got);
4443+
PASS();
4444+
}
4445+
43754446
/* A template's `include "name"` calls the define of that name in its chart
43764447
* family -- the chart and its subcharts share one template namespace -- never
43774448
* a same-named define of an unrelated chart; a define may also be written in
@@ -6180,6 +6251,7 @@ SUITE(grammar_probe_f) {
61806251
RUN_TEST(probe_go_installer_deploys_charts);
61816252
RUN_TEST(probe_terraform_helm_release_deploys_its_chart);
61826253
RUN_TEST(probe_helm_templated_objects_are_resources);
6254+
RUN_TEST(probe_helm_chart_deploys_its_objects);
61836255
RUN_TEST(probe_helm_include_resolves_in_the_chart_family);
61846256
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
61856257
RUN_TEST(probe_k8s_selector_within_kustomize_build);

‎tests/test_pipeline.c‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4175,6 +4175,36 @@ TEST(incremental_image_split_change_rebuilds) {
41754175
PASS();
41764176
}
41774177

4178+
/* A chart's values file CONFIGURES its Chart (pass_k8s.c). The edge is drawn
4179+
* by the K8s pass from the chart directory, which a closure repair of the
4180+
* values file alone does not see: an edit of it rebuilds, and the edge stays. */
4181+
TEST(incremental_helm_values_edit_keeps_chart_edge) {
4182+
char tmp[256];
4183+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_helmvalues_XXXXXX");
4184+
if (!cbm_mkdtemp(tmp)) {
4185+
FAIL("tmpdir");
4186+
}
4187+
write_temp_file(tmp, "chart/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\n");
4188+
write_temp_file(tmp, "chart/values.yaml", "replicas: 1\n");
4189+
write_temp_file(tmp, "chart/templates/svc.yaml",
4190+
"apiVersion: v1\nkind: Service\nmetadata:\n name: web\n");
4191+
char db_path[512];
4192+
snprintf(db_path, sizeof(db_path), "%s/helmvalues.db", tmp);
4193+
static const char edge[] =
4194+
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON "
4195+
"t.id=e.target_id WHERE e.type='CONFIGURES' AND s.file_path='chart/values.yaml' AND "
4196+
"t.label='Chart'";
4197+
int before = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, edge) : -1;
4198+
write_temp_file(tmp, "chart/values.yaml", "replicas: 3\n");
4199+
int after = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, edge) : -1;
4200+
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
4201+
th_rmtree(tmp);
4202+
ASSERT_EQ(before, 1);
4203+
ASSERT_EQ(after, 1);
4204+
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_FORCED_FULL);
4205+
PASS();
4206+
}
4207+
41784208
/* A compose project's .env feeds its compose file (variables in image,
41794209
* build): changing it is no closure repair -- the unchanged compose file's
41804210
* services would keep the old image. It rebuilds; no stale image remains. */
@@ -23899,6 +23929,7 @@ SUITE(pipeline_semantic_manifest_repro) {
2389923929
RUN_TEST(incremental_compose_edit_replaces_build_edges);
2390023930
RUN_TEST(incremental_env_edit_replaces_scoped_envvars);
2390123931
RUN_TEST(incremental_image_split_change_rebuilds);
23932+
RUN_TEST(incremental_helm_values_edit_keeps_chart_edge);
2390223933
RUN_TEST(incremental_compose_dotenv_change_rebuilds);
2390323934
RUN_TEST(incremental_dockerfile_edit_keeps_docker_build_edges);
2390423935
RUN_TEST(incremental_second_build_declarer_keeps_the_edge);

0 commit comments

Comments
 (0)