Skip to content

Commit e7ea0af

Browse files
authored
Break-glass merge PR #2663
2 parents 5966b7f + 851096a commit e7ea0af

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
@@ -4432,6 +4432,77 @@ TEST(probe_helm_templated_objects_are_resources) {
44324432
PASS();
44334433
}
44344434

4435+
/* Every DEPLOYS edge out of a Chart and CONFIGURES edge into one, as
4436+
* "<source> -> <label>:<target> (<type>[ <ref>])", a File by its path,
4437+
* sorted, one per line. */
4438+
static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
4439+
GpfProj lp;
4440+
cbm_store_t *store = gpf_index_files(&lp, files, nfiles);
4441+
char *out = NULL;
4442+
if (store) {
4443+
sqlite3_stmt *st = NULL;
4444+
const char *sql =
4445+
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT CASE WHEN "
4446+
"s.label='File' THEN s.file_path ELSE s.name END || ' -> ' || t.label || ':' || "
4447+
"t.name || ' (' || e.type || coalesce(' ' || json_extract(e.properties,'$.ref'), '') "
4448+
"|| ')' AS x FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON "
4449+
"t.id=e.target_id WHERE e.project=?1 AND ((s.label='Chart' AND e.type='DEPLOYS') OR "
4450+
"(t.label='Chart' AND e.type='CONFIGURES')) ORDER BY x)";
4451+
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
4452+
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
4453+
if (sqlite3_step(st) == SQLITE_ROW) {
4454+
out = strdup((const char *)sqlite3_column_text(st, 0));
4455+
}
4456+
}
4457+
sqlite3_finalize(st);
4458+
}
4459+
gpf_cleanup(&lp, store);
4460+
return out;
4461+
}
4462+
4463+
/* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}->
4464+
* each object of its templates/ (a subchart's objects are the subchart's),
4465+
* and each Image those objects name literally ({ref: image}) -- a templated
4466+
* image names none, and no values default is read. Its values files
4467+
* (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file
4468+
* elsewhere (ci/) does not. */
4469+
TEST(probe_helm_chart_deploys_its_objects) {
4470+
static const GpfFile files[] = {
4471+
{"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\n"},
4472+
{"charts/web/values.yaml", "image:\n repository: nginx\n tag: \"1.27\"\nreplicas: 1\n"},
4473+
{"charts/web/values-prod.yaml", "replicas: 3\n"},
4474+
{"charts/web/ci/test-values.yaml", "replicas: 2\n"},
4475+
{"charts/web/templates/deploy.yaml",
4476+
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n"
4477+
" replicas: {{ .Values.replicas }}\n template:\n spec:\n containers:\n"
4478+
" - name: web\n"
4479+
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
4480+
" - name: side\n image: ghcr.io/acme/sidecar:1\n"},
4481+
{"charts/web/templates/svc.yaml",
4482+
"apiVersion: v1\nkind: Service\nmetadata:\n name: web\nspec:\n selector:\n"
4483+
" app: web\n"},
4484+
{"charts/web/charts/db/Chart.yaml", "apiVersion: v2\nname: db\nversion: 0.1.0\n"},
4485+
{"charts/web/charts/db/templates/sts.yaml",
4486+
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: db\nspec:\n template:\n"
4487+
" spec:\n containers:\n - name: db\n"
4488+
" image: ghcr.io/acme/db:16\n"}};
4489+
char *got = gpf_chart_edges(files, 8);
4490+
static const char want[] = "charts/web/values-prod.yaml -> Chart:web (CONFIGURES)\n"
4491+
"charts/web/values.yaml -> Chart:web (CONFIGURES)\n"
4492+
"db -> Image:ghcr.io/acme/db (DEPLOYS image)\n"
4493+
"db -> Resource:StatefulSet/db (DEPLOYS)\n"
4494+
"web -> Image:ghcr.io/acme/sidecar (DEPLOYS image)\n"
4495+
"web -> Resource:Deployment/web (DEPLOYS)\n"
4496+
"web -> Resource:Service/web (DEPLOYS)";
4497+
if (!got || strcmp(got, want) != 0) {
4498+
fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
4499+
free(got);
4500+
FAIL("Chart DEPLOYS / CONFIGURES edge set");
4501+
}
4502+
free(got);
4503+
PASS();
4504+
}
4505+
44354506
/* A template's `include "name"` calls the define of that name in its chart
44364507
* family -- the chart and its subcharts share one template namespace -- never
44374508
* a same-named define of an unrelated chart; a define may also be written in
@@ -6241,6 +6312,7 @@ SUITE(grammar_probe_f) {
62416312
RUN_TEST(probe_go_installer_deploys_charts);
62426313
RUN_TEST(probe_terraform_helm_release_deploys_its_chart);
62436314
RUN_TEST(probe_helm_templated_objects_are_resources);
6315+
RUN_TEST(probe_helm_chart_deploys_its_objects);
62446316
RUN_TEST(probe_helm_include_resolves_in_the_chart_family);
62456317
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
62466318
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)