From 246d1a0a4baca68daf9ebf6f77eb465919bf4b68 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 14 Oct 2026 06:16:28 +0200 Subject: [PATCH] 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 --- src/pipeline/pipeline_incremental.c | 26 +++++++++++++++ tests/test_pipeline.c | 50 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index f1de3b960..cd786062e 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -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) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 89989037d..07013890b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -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);