Skip to content

Design: deterministic migration off legacy central modules#374

Draft
anshulsao wants to merge 3 commits into
mainfrom
legacy-module-migration
Draft

Design: deterministic migration off legacy central modules#374
anshulsao wants to merge 3 commits into
mainfrom
legacy-module-migration

Conversation

@anshulsao

Copy link
Copy Markdown
Contributor

Design spec only — no code, no modules yet. Opening as a draft so the approach can be reviewed before implementation.

The problem, restated precisely

"Old identification" turned out to be a specific flag in the IaC engine:

# capillary-cloud-tf/tfmain/scripts/generate.py:182
self.is_old = module_json.get("is_old", False) and data["PRE_ALPHA"]

# :305 — the terraform module address key
if meta.is_old:  key = meta.module_name + "_" + name   # module_name = DIRECTORY name
else:            key = meta.provides    + "_" + name   # provides    = intent

So migration always moves the terraform address, and the translation (dirname → provides) is deterministic from module.json. Flavor and version never enter the address.

Scope is smaller than it looks

count
module.json files in the tree 238
is_old: true 9
is_old: false (already migrated in-repo) 15
distinct intents in scope 5

Three findings that shaped the design

  1. The v3 catalog is not a migration target. Only 2 of 171 legacy (intent, flavor) pairs exist in facets-v3-modules + facets-modules-redesign. Modules are rewrapped old-posture instead: main.tf copied byte-identical, the three engine-injected templates materialized, facets.yaml synthesized from module.json. Import is then 0-diff by construction, not by verification.

  2. The in-repo precedent is only half the job. The 15 is_old: false modules bumped version and kept flavor (memorystorememorystore_redis_alpha @ 0.2). But their main.tf differs from the original, so they carry no zero-diff guarantee — and they remain module_source == "local", so they never touched registration.

  3. Renaming the flavor silently drops config. main.tf.mustache reads the advanced block by flavor key:

    advanced = lookup(lookup(local.input_{{key}}, "advanced", {}), "{{flavor}}", {})

    The script must rewrite flavor and advanced.<flavor> in lockstep, and assert no pre-rename key survives.

Pipeline

SCAN → JOIN → MAP → REWRAP → EMIT → GATE, where the gate is the existing praxis-zero-change-import bar (0 to change, 0 to destroy, nothing must be replaced) and is not modified. The mapping table accretes one project at a time rather than being built up front.

Pilot is the capillarycloud project on the root CP.

🤖 Generated with Claude Code

anshulsao and others added 3 commits July 10, 2026 11:18
Deterministic pipeline to move a project off the old central-module
architecture (facets-iac module.json, is_old addressing) onto new-style
registry modules, via a zero-change import into a new project.

Key findings that shaped the design:

- The predicate is generate.py's `is_old` flag, not module.json presence.
  is_old resources address as module.level2.module.<dirname>_<resource>;
  everything else as <intent>_<resource>. Migration always moves the
  address, and the translation (dirname -> provides) is deterministic.

- Scope is 9 modules across 5 intents, not the 238 module.json files in
  the tree. 15 more already carry is_old: false.

- Only 2 of 171 legacy (intent, flavor) pairs exist in facets-v3-modules
  or facets-modules-redesign, so mapping onto the v3 catalog cannot be
  the default strategy. Modules are rewrapped old-posture instead:
  main.tf copied byte-identical, the three engine-injected templates
  materialized, facets.yaml synthesized from module.json.

- Renaming the flavor to avoid a registry collision silently drops the
  advanced block, because main.tf.mustache reads it by flavor key. The
  script rewrites flavor and advanced.<flavor> in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Corrections to the first draft:

- The predicate is module_source == "local" (the module resolves out of the
  facets-iac repo), NOT is_old. is_old only decides how the terraform address
  is composed: <dirname>_<name> when true, <intent>_<name> when false. Both
  are legacy. Scope is 192 non-deprecated modules, of which 8 move address.

- The 29 modules under 0_input_config are system modules present in every
  project. They are legacy too.

Adds:

- project-type/old-style/project-type.yml — starts from cloud account only,
  no baseTemplatePath. Module list accretes per adopted project; seeded with
  the 24 (intent, flavor) pairs the capillary-cloud pilot actually uses.
  Flavors carry a _legacy suffix; output types namespaced @legacy/*.

- Module-reuse evaluation. Comparing terraform resource+module address sets
  (excluding actions.tf boilerplate): 18 of the pilot's 19 legacy module
  directories must be written as rewraps. The one candidate, helm_simple vs
  common/helm/k8s_standard/1.0, shares helm_release.external_helm_charts but
  its body differs — the gate decides, not inspection.

- Vendoring constraint. Legacy modules reference ../3_utility/* and even
  other legacy modules via relative sources. They only work in-tree. The
  rewrap must vendor the transitive closure and rewrite source paths;
  module block names stay fixed so addresses survive.

- Script inventory and per-environment adoption procedure. First environment
  is facetsdemo (RUNNING, a demo CP, not a customer's). 68 variables/secrets
  carry across; values never enter a transcript.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops the bin/ script plan. The pipeline is now run by hand per
(project, environment) from docs/sop-legacy-module-migration.md, which is
shaped after the existing facets-gcp-zero-change-import sop-template.

Commands verified against the installed raptor rather than assumed:

  raptor create project-type NAME --description
  raptor create resource-type-mapping PROJECT_TYPE --resource-type TYPE/FLAVOR
  raptor create environment NAME -p PROJECT --release-stream
  raptor create variable NAME -p PROJECT [--secret|--global|--env-values|-f]
  raptor create output-type @NAMESPACE/NAME -f FILE
  raptor create iac-module -f DIR --dry-run --block-remote-modules
  raptor create release -p P -e E --target TYPE/NAME --plan
  raptor apply resource KIND/FLAVOR/VERSION -n NAME --spec-file --input
  raptor apply override -p P -e E

--block-remote-modules turns out to be the machine check for the vendoring
closure: it rejects any non-local module source, so a passing dry-run proves
the ../3_utility/* graph was fully vendored.

Removing the scripts makes three failures silent rather than impossible, so
the SOP carries them as explicit guardrails G1-G5:

  G1  main.tf byte-identical to the legacy source (diff)
  G2  advanced.<flavor> renamed in lockstep with flavor (grep) — a miss
      resolves the block to {} with no error and possibly no plan diff
  G3  no `source = "../"` escapes the module dir (grep + --block-remote-modules)
  G4  is_old address translation recorded per resource
  G5  the gate is never loosened

mapping.yaml stays a checked-in, reviewed artifact — it is the state that
outlives any single migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant