fix(incremental): a Terraform input change declines to the full rebuild

A Terraform module's output read can live in one file (outputs.tf:
module.net.vpc_id) and the module's source in another (main.tf). When
only main.tf changed -- its source pointed elsewhere -- the incremental
repair recomputed that file's edges alone, so the read, held by two
unchanged files, stayed in the graph although a full reindex no longer
has it. Measured on kube-hetzner: 18 stale output reads after moving
one module's source.

Terraform, OpenTofu and Terragrunt inputs reach past their own file the
way Kubernetes bundles do: the files of a module directory resolve each
other's references, a module call decides what another file reads, a
.tfvars file sets the variables beside it. A change to a .tf, .tofu,
.tfvars or .hcl file now declines the repair to the full rebuild, as a
kustomization or manifest change already does.

The incremental re-index now equals a full index in all 18 Terraform
scenarios, the stale-read one included; a Terraform edit costs a full
rebuild (fabric: 1.64 -> 1.90 s). A pipeline test moves a module's
source and requires the read to go.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-14 06:16:28 +02:00
1 parent 949e875337
commit 246d1a0a4b
2 files changed
+76

No files matched your search

+26
View File
@@ -2516,6 +2516,28 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project,
return NULL;
}
/* Terraform / OpenTofu / Terragrunt inputs reach past their own file
* (pass_terraform.c): the files of a module directory resolve each other's
* references, a module call's source decides which outputs another file of
* the directory reads, a .tfvars file sets the variables beside it, and a
* Terragrunt unit includes and reads other .hcl files. The repair recomputes
* the changed files' edges only, so a delta touching any of them -- an edge
* held by two unchanged files may hang on it -- declines to the full
* rebuild. Returns the reason, or NULL. */
static const char *closure_terraform_decline(char **paths, int n) {
static const char *const exts[] = {".tf", ".tofu", ".tfvars", ".hcl"};
for (int i = 0; i < n; i++) {
const char *slash = strrchr(paths[i], '/');
const char *dot = strrchr(slash ? slash + SKIP_ONE : paths[i], '.');
for (size_t k = 0; dot && k < sizeof(exts) / sizeof(exts[0]); k++) {
if (strcmp(dot, exts[k]) == 0) {
return "terraform_input_changed";
}
}
}
return NULL;
}
/* The env join (pass_envjoin.c) reads each image's files from its
* Dockerfile's Module, stored with the ignore patterns of its context: an
* ignore file changing, or a deletion of a file an image copies (which
@@ -2737,6 +2759,10 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p
if (decline) {
goto done;
}
decline = closure_terraform_decline(changed_paths, n_changed + n_deleted);
if (decline) {
goto done;
}
decline =
closure_stem_mark_decline(project, files, file_count, changed_paths + n_changed, n_deleted);
if (decline) {
+50
View File
@@ -1490,6 +1490,55 @@ TEST(pipeline_incremental_preserves_cross_file_calls) {
PASS();
}
static void write_temp_file(const char *dir, const char *name, const char *content);
/* Regression: a Terraform module's output read lives in one file
* (infra/outputs.tf: module.net.vpc_id) and the module's source in another
* (infra/main.tf). Pointing the source elsewhere edits main.tf alone; the
* repair route recomputed the changed file's edges only, so the read -- held
* by two UNCHANGED files, outputs.tf and modules/net/main.tf -- stayed in the
* graph, which a full reindex no longer has. A Terraform input change now
* declines to the full rebuild (closure_terraform_decline). */
TEST(pipeline_incremental_drops_terraform_reads_of_a_moved_module) {
if (setup_test_repo() != 0) {
FAIL("failed to create temp dir");
}
write_temp_file(g_tmpdir, "infra/main.tf", "module \"net\" {\n source = \"./modules/net\"\n}\n");
write_temp_file(g_tmpdir, "infra/outputs.tf",
"output \"net_vpc\" {\n value = module.net.vpc_id\n}\n");
write_temp_file(g_tmpdir, "infra/modules/net/main.tf",
"output \"vpc_id\" {\n value = \"x\"\n}\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/test_incr_tf.db", g_tmpdir);
cbm_pipeline_t *p1 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p1);
ASSERT_EQ(cbm_pipeline_run(p1), 0);
cbm_store_t *s1 = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s1);
ASSERT_TRUE(cross_file_edge_exists(s1, cbm_pipeline_project_name(p1), "output.net_vpc",
"output.vpc_id", "USAGE"));
cbm_store_close(s1);
cbm_pipeline_free(p1);
/* the module's source moves to a directory that does not exist */
write_temp_file(g_tmpdir, "infra/main.tf",
"module \"net\" {\n source = \"./modules/none\"\n}\n");
cbm_pipeline_t *p2 = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p2);
ASSERT_EQ(cbm_pipeline_run(p2), 0);
cbm_store_t *s2 = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s2);
ASSERT_FALSE(cross_file_edge_exists(s2, cbm_pipeline_project_name(p2), "output.net_vpc",
"output.vpc_id", "USAGE"));
cbm_store_close(s2);
cbm_pipeline_free(p2);
teardown_test_repo();
PASS();
}
/* TS/JS receiver-aware weak-strategy suppression (#592/#606 direction; Perl
* precedent #477). A member call x.foo() whose receiver TYPE the TS-LSP cannot
* resolve (a regex literal `re.test()`) must NOT be bound to a same-named
@@ -23143,6 +23192,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_nix_scoped_binding_calls_resolve);
RUN_TEST(pipeline_hcl_block_reference_resolves_to_its_block);
RUN_TEST(pipeline_incremental_preserves_cross_file_calls);
RUN_TEST(pipeline_incremental_drops_terraform_reads_of_a_moved_module);
RUN_TEST(pipeline_field_named_like_method_keeps_its_reads_and_writes);
RUN_TEST(pipeline_objectscript_export_preserves_calls_sequential_parallel);
RUN_TEST(pipeline_objectscript_export_incremental_matches_full_relationships);