Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/pipeline/pass_k8s.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chart>/templates/ -- its nearest enclosing chart, so a
* subchart's objects are the subchart's. Its values files (values.yaml,
* values-<x>.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
Expand Down Expand Up @@ -4517,13 +4656,16 @@ 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. */
cbm_pipeline_pass_shell(ctx, files, file_count);
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);

Expand Down
40 changes: 40 additions & 0 deletions src/pipeline/pipeline_incremental.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
}
Expand Down
3 changes: 3 additions & 0 deletions src/pipeline/pipeline_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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-<x>.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);
Expand Down
72 changes: 72 additions & 0 deletions tests/test_grammar_probe_f.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
* "<source> -> <label>:<target> (<type>[ <ref>])", a File by its path,
* sorted, one per line. */
static char *gpf_chart_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 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'), '') "
"|| ')' 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)";
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;
}

/* 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
* (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file
* elsewhere (ci/) does not. */
TEST(probe_helm_chart_deploys_its_objects) {
static const GpfFile files[] = {
{"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\n"},
{"charts/web/values.yaml", "image:\n repository: nginx\n tag: \"1.27\"\nreplicas: 1\n"},
{"charts/web/values-prod.yaml", "replicas: 3\n"},
{"charts/web/ci/test-values.yaml", "replicas: 2\n"},
{"charts/web/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n"
" replicas: {{ .Values.replicas }}\n template:\n spec:\n containers:\n"
" - name: web\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
" - name: side\n image: ghcr.io/acme/sidecar:1\n"},
{"charts/web/templates/svc.yaml",
"apiVersion: v1\nkind: Service\nmetadata:\n name: web\nspec:\n selector:\n"
" app: web\n"},
{"charts/web/charts/db/Chart.yaml", "apiVersion: v2\nname: db\nversion: 0.1.0\n"},
{"charts/web/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"
" image: ghcr.io/acme/db:16\n"}};
char *got = gpf_chart_edges(files, 8);
static const char want[] = "charts/web/values-prod.yaml -> Chart:web (CONFIGURES)\n"
"charts/web/values.yaml -> Chart:web (CONFIGURES)\n"
"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 -> Resource:Deployment/web (DEPLOYS)\n"
"web -> Resource:Service/web (DEPLOYS)";
if (!got || strcmp(got, want) != 0) {
fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
free(got);
FAIL("Chart DEPLOYS / CONFIGURES edge set");
}
free(got);
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
Expand Down Expand Up @@ -6180,6 +6251,7 @@ SUITE(grammar_probe_f) {
RUN_TEST(probe_go_installer_deploys_charts);
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_include_resolves_in_the_chart_family);
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
RUN_TEST(probe_k8s_selector_within_kustomize_build);
Expand Down
31 changes: 31 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -4175,6 +4175,36 @@ TEST(incremental_image_split_change_rebuilds) {
PASS();
}

/* A chart's values file CONFIGURES its Chart (pass_k8s.c). The edge is drawn
* by the K8s pass from the chart directory, which a closure repair of the
* values file alone does not see: an edit of it rebuilds, and the edge stays. */
TEST(incremental_helm_values_edit_keeps_chart_edge) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_helmvalues_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_temp_file(tmp, "chart/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\n");
write_temp_file(tmp, "chart/values.yaml", "replicas: 1\n");
write_temp_file(tmp, "chart/templates/svc.yaml",
"apiVersion: v1\nkind: Service\nmetadata:\n name: web\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/helmvalues.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='chart/values.yaml' AND "
"t.label='Chart'";
int before = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, edge) : -1;
write_temp_file(tmp, "chart/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 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. */
Expand Down Expand Up @@ -23899,6 +23929,7 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(incremental_compose_edit_replaces_build_edges);
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_compose_dotenv_change_rebuilds);
RUN_TEST(incremental_dockerfile_edit_keeps_docker_build_edges);
RUN_TEST(incremental_second_build_declarer_keeps_the_edge);
Expand Down
Loading