Skip to content

fix(iac/federation): ship the role module inside the Azure Terraform bundle so terraform init works - #1440

Merged
IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1431
Oct 26, 2026
Merged

IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-1431

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #1431

Root cause

iac/federation/azure-target/terraform/main.tf calls the custom reservation-purchaser role module at ../../../../terraform/modules/iam/azure/cudly-reservation-role. The server embeds only iac/federation (iac/embed.go), and addBundleTerraform zips only federation/azure-target/terraform. The module directory was never in the zip, so terraform init on the unzipped Azure "Terraform bundle" failed with Unreadable module directory. This has been broken since #744.

Fix

  • terraform/embed.go (new package terraform) embeds modules/iam/azure/cudly-reservation-role/*.tf. go:embed can't reach outside iac/, so the module is embedded from the directory that owns it, and the repo still has a single copy.
  • internal/api/federation_bundle_modules.go: addBundleTerraformDir replaces the old addDirToZip call for the bundle's Terraform dir. For the Azure bundle it writes the module to terraform/modules/cudly-reservation-role/ and rewrites the call in the bundled main.tf to ./modules/cudly-reservation-role. If no .tf file contains the expected source string any more, building the bundle returns an error instead of shipping a broken path. The other bundles are unchanged.
  • The Azure bundle README's "Contents" list now mentions terraform/modules/.
  • I didn't touch the repo's main.tf, the host-side callers (terraform/environments/azure/ci-cd-permissions, the container-apps module) or scripts/check-azure-role-parity.sh. They all still use the shared module. That's also why I chose embed-and-rewrite over keeping a second copy under iac/federation.

Tests

  • TestFederationBundles_LocalModuleSourcesResolveInsideZip (unit): renders every federation bundle through getFederationIaC and checks that every local module source in every .tf file points at a directory of .tf files inside the zip. TestUnresolvedLocalModules_DetectsEscape proves this check can fail.
  • TestAzureBundle_RoleModuleMatchesSharedModule (unit): the bundle ships exactly the shared module's .tf files, byte for byte.
  • TestFederationBundles_TerraformInitFromUnzippedBundle (-tags integration): writes each rendered bundle to an empty temp dir and runs terraform get in terraform/, which is where the bug fails. It needs no network, so it runs under the package's no-network guard (internal/api tests call real AWS Cost Explorer and probe IMDS #855). With CUDLY_TEST_TERRAFORM_REGISTRY=1 it also runs terraform init -backend=false and terraform validate, which download providers from the public registry, so those runs get the environment without the guard's proxy variables. It skips when terraform isn't on PATH. Nothing is planned or applied.

Verification

All local, linux/amd64, Terraform 1.10.5.

  • Fail-before on origin/main 6b586cc:
    • go test ./internal/api/ -run 'LocalModuleSources|RoleModuleMatches': FAIL, with terraform/main.tf: ../../../../terraform/modules/iam/azure/cudly-reservation-role unresolved, and the bundle has no role module files.
    • go test -tags integration ./internal/api/ -run TestFederationBundles_TerraformInitFromUnzippedBundle: azure-target FAILs with Error: Unreadable module directory. The other four bundles pass.
  • Pass-after on this branch:
    • Both unit tests pass.
    • The integration test passes for all five bundles.
    • CUDLY_TEST_TERRAFORM_REGISTRY=1 go test -tags integration ... -run TestFederationBundles_TerraformInitFromUnzippedBundle: terraform init -backend=false and terraform validate pass for all five unzipped bundles (aws-cross-account, aws-target, azure-target, gcp-sa-impersonation, gcp-target). Total 310 s, mostly provider downloads.
  • go build ./... && go vet ./... && go vet -tags integration ./... && (cd tests/e2e && go vet -tags e2e ./...): OK.
  • golangci-lint run ./internal/api/... ./terraform/ ./iac/..., with and without --build-tags integration: 0 issues. gocyclo -over 10 on the new non-test files: nothing.
  • go test -race -short -count=1 ./internal/api/... ./terraform/ ./iac/...: ok. go test -race -tags integration -count=1 ./internal/api/ ./terraform/ ./iac/: ok (internal/api 384 s).
  • scripts/check-azure-role-parity.sh: OK. scripts/test-azure-role-parity.sh: 78 passed. Secrets scan on the changed files: clean. scripts/test-git-secrets-allowlist.sh: 183 PASS.

Not verified:

  • Applying the bundle in a real Azure subscription. That's for a human to confirm, as the issue says.
  • CI's integration job doesn't install Terraform as far as I can see, so the terraform part of the integration test will probably skip there. The unit tests above cover the bug in CI either way.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 26, 2026 05:23
…bundle

The Azure bundle's main.tf calls the custom reservation-purchaser role
module at ../../../../terraform/modules/iam/azure/cudly-reservation-role,
but the bundle zips only iac/federation/azure-target/terraform, so
`terraform init` on the unzipped bundle failed with "Unreadable module
directory" (LeanerCloud#1431).

The module is now embedded from its one source of truth (terraform/
embed.go) and written to terraform/modules/cudly-reservation-role in the
zip, and the bundle's copy of main.tf points at ./modules/
cudly-reservation-role. The repo's main.tf, the host-side callers and
scripts/check-azure-role-parity.sh keep using the shared module, so the
customer role cannot drift from the audited one. If the call in main.tf
stops matching, building the bundle fails rather than shipping the
broken path.

Tests: every bundle's local module sources must resolve inside the zip,
and the bundled role module must be byte-identical to the shared one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds each bundle through the API handler, writes it to an empty
directory and runs `terraform get` there, which is where a module call
that leaves the bundle fails (LeanerCloud#1431). It needs no network, so it runs
under the package's no-network guard. With
CUDLY_TEST_TERRAFORM_REGISTRY=1 it also runs `terraform init
-backend=false` and `terraform validate`, which download providers from
the public registry. Skips when terraform is not on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect triaged Item has been triaged labels Oct 26, 2026
@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 5 billable files and costs up to $1.25.

Or wait 58 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between c8bb869 and b9b735c.

Files selected for processing: 5
  • internal/api/federation_bundle_modules.go
  • internal/api/federation_bundle_modules_test.go
  • internal/api/federation_bundle_terraform_integration_test.go
  • internal/api/handler_federation.go
  • terraform/embed.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Opened at head b9b735cf30deb12573c6789d932fed78529a4961 (2 commits: the fix with its unit tests, then the integration test that runs terraform on each unzipped bundle). Local fail-before/pass-after evidence is in the PR body. CI is running.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE b9b735cf30deb12573c6789d932fed78529a4961

Blocking:

  • none

Non-blocking:

  • scripts/generate-federation-iac.go:312,327: the generator's --format bundle --target azure has the same bug. addTerraformToBundle zips only the top-level files of iac/federation/azure-target/terraform and leaves main.tf's source = "../../../../terraform/modules/iam/azure/cudly-reservation-role" unchanged, so terraform init on that zip still fails with "Unreadable module directory". The issue's Impact section is about the UI download, which this PR fixes and tests across all five server bundles, so Closes is fair. AC1 says "every federation bundle", though, and the generator is a second bundle path. It needs a follow-up issue (or bundleExtraModules reused there).
  • terraform/embed.go turns terraform/ into a Go package with a non-test file. I checked this is safe: the existing tests there are package terraform_test, the Dockerfile does COPY . ., and .dockerignore doesn't exclude terraform/. Anyone who later narrows the Docker build context must keep terraform/modules/iam/azure/cudly-reservation-role/*.tf in it, or go build breaks.
  • CI's integration job probably has no terraform binary, so TestFederationBundles_TerraformInitFromUnzippedBundle will skip there, as the body says. The unit tests still catch a regression.

Attacked:

  • Fail-before: reverted the one-line call site in addBundleTerraform back to addDirToZip. LocalModuleSourcesResolveInsideZip/azure-target, RoleModuleMatchesSharedModule and the -tags integration TerraformInitFromUnzippedBundle/azure-target (terraform get: "Unreadable module directory") all failed. Restored, and all pass. With CUDLY_TEST_TERRAFORM_REGISTRY=1, terraform init -backend=false + validate passed on the unzipped azure-target bundle (TF 1.10.5).
  • Checked that the rewrite only touches .tf files, so tests/identity_guards.tftest.hcl is untouched. The bundle ships exactly the shared module's .tf files and no lock file. The module has no file() or relative references of its own, and the unused-rewrite error path fails closed.
  • go build ./..., and go vet with and without -tags integration on ./internal/api ./terraform ./iac: OK. go test -race -short -count=1 on the same: ok. go test -race -tags integration -count=1 -timeout 40m ./internal/api/: ok in 248 s. The first run hit the default 10m timeout in the unrelated TestIntegration_HistorySummary_* on a busy machine. ./terraform and ./iac with -tags integration: ok. golangci-lint with and without integration tags: 0 issues. gocyclo -over 10 on the new files: nothing.

Body-stale:

  • none

Reviewer: rC2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE b9b735cf30deb12573c6789d932fed78529a4961

Blocking:

  • none

Non-blocking:

  • internal/api/federation_bundle_modules.go:56: the source rewrite only applies to .tf files. The bundle also ships terraform/tests/identity_guards.tftest.hcl. It doesn't call the role module today, but a future .tftest.hcl that uses the repo-relative path would ship unrewritten, and nothing guards that, because unresolvedLocalModules only scans .tf files either.
  • The CI integration job doesn't install Terraform (setup-terraform only appears in the separate Terraform job, ci.yml:971), so TestFederationBundles_TerraformInitFromUnzippedBundle will skip in CI. The PR body says so. The unit tests still cover the regression in CI.

Attacked:

  • Build context: the new terraform Go package is imported by internal/api. Checked that the Dockerfile uses COPY . . and that .dockerignore excludes only .terraform/, so the image build still sees terraform/embed.go and the module's .tf files. bundleModuleDir maps every Azure source to azure-target, so the "no module calls repoSource" error can't fire for a valid Azure combo. The module is self-contained, with no nested local sources or file() paths.
  • Fail-before: I put handler_federation.go back to origin/main (the old addDirToZip call). LocalModuleSourcesResolveInsideZip/azure-target, RoleModuleMatchesSharedModule and the integration test's azure-target subtest (Error: Unreadable module directory) all fail. With the change restored, all pass.
  • go build ./...; go vet (plain and -tags integration) on ./internal/api/ and ./terraform/; go test -race -short -count=1 ./internal/api/ ./terraform/ ./iac/: ok. go test -race -tags integration on the four new tests: ok, Terraform 1.10.5. CUDLY_TEST_TERRAFORM_REGISTRY=1: init -backend=false and validate on the unzipped azure-target bundle pass. golangci-lint, with and without integration tags: 0 issues. gocyclo -over 10: nothing. scripts/check-azure-role-parity.sh: OK.

Body-stale:

  • none

Reviewer: rC3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at b9b735cf30deb12573c6789d932fed78529a4961 merged onto main (d87b96ff), together with #1437, #1438, #1439, #1440, #1441 in that order (combined tree 37f4d0bb, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 07:05
@IAmJSD
IAmJSD merged commit 1b77c37 into LeanerCloud:main Oct 26, 2026
41 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on b9b735cf (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly b9b735cf30deb12573c6789d932fed78529a4961.

IAmJSD added a commit that referenced this pull request Oct 26, 2026
…undles (#1467)

* refactor(iacbundle): share the bundle Terraform writer between API and script

The API's federation bundle ships the out-of-tree Azure role module (#1431),
but the helper that does it is unexported in internal/api, so
scripts/generate-federation-iac.go cannot use it and still builds an Azure
bundle that calls a module outside the zip (#1442). Move the writer, the
module list and the "local module sources resolve inside the zip" check into
a small package both builders can import. It reads any fs.FS so the script
can pass its on-disk modules directory, and skips hidden files and
directories so a developer's .terraform/ or lock file never lands in a zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(api): build the bundle Terraform through internal/iacbundle

Use the shared writer for the API's federation bundles so the API and the
developer script cannot drift apart again. Behaviour is unchanged: the
embedded FS has no hidden files, and the module set and rewrite are the
same. The bundle test keeps running every combo through the shared
unresolved-module check; the guard's own self-test moved with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scripts): ship the Azure role module in generate-federation-iac bundles

--format bundle --target azure zipped only iac/federation/azure-target/
terraform, whose main.tf calls ../../../../terraform/modules/iam/azure/
cudly-reservation-role, so `terraform init` on the unzipped bundle failed
with "Unreadable module directory". Build the Terraform part through
internal/iacbundle, as the API does since #1440: the module ships at
terraform/modules/cudly-reservation-role and the call points at it. Like the
API's bundle it now also carries the target's terraform/tests/ directory.

The new test runs every bundle the script builds through the same
resolves-inside-the-zip check as the API's bundles; the Azure case fails
on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(iac/federation): azure-target Terraform bundle references a module outside the zip, so terraform init fails

1 participant