diff --git a/internal/api/federation_bundle_modules.go b/internal/api/federation_bundle_modules.go deleted file mode 100644 index 0901592bc..000000000 --- a/internal/api/federation_bundle_modules.go +++ /dev/null @@ -1,85 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "fmt" - "io/fs" - "strings" - - cudlyiac "github.com/LeanerCloud/cloud-commitments-platform/iac" - cudlytf "github.com/LeanerCloud/cloud-commitments-platform/terraform" -) - -// bundledModule is a Terraform module that a federation bundle calls from -// outside its own embedded directory. The bundle ships a copy of it under -// terraform/ and points the call at that copy, so `terraform init` -// works from the unzipped bundle (#1431). -type bundledModule struct { - repoSource string // the module source as written in the repo's .tf files - fsys fs.ReadFileFS // where the module's files are embedded - dir string // the module's directory in fsys - zipDir string // its directory in the zip, relative to terraform/ -} - -// bundleExtraModules returns the out-of-tree modules a bundle's Terraform -// calls. Only the Azure bundle has one: the custom reservation-purchaser role, -// shared with the host side and checked by scripts/check-azure-role-parity.sh. -func bundleExtraModules(target string) []bundledModule { - if target != "azure" { - return nil - } - return []bundledModule{{ - repoSource: "../../../../terraform/modules/iam/azure/cudly-reservation-role", - fsys: cudlytf.ReservationRoleModule, - dir: cudlytf.ReservationRoleModuleDir, - zipDir: "modules/cudly-reservation-role", - }} -} - -// addBundleTerraformDir writes the embedded Terraform directory srcDir to the -// zip under terraform/, followed by every module in mods, with each module -// call rewritten to the shipped copy. A module whose repoSource no .tf file -// calls is an error: the rewrite would otherwise silently stop applying after -// the call is moved. -func addBundleTerraformDir(zw *zip.Writer, srcDir string, mods []bundledModule) error { - used := make([]bool, len(mods)) - err := fs.WalkDir(cudlyiac.Modules, srcDir, func(p string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - b, err := cudlyiac.Modules.ReadFile(p) - if err != nil { - return err - } - if strings.HasSuffix(p, ".tf") { - b = rewriteModuleSources(b, mods, used) - } - return addBytesToZip(zw, "terraform/"+strings.TrimPrefix(p, srcDir+"/"), b) - }) - if err != nil { - return err - } - for i, m := range mods { - if !used[i] { - return fmt.Errorf("no module under %s calls %q", srcDir, m.repoSource) - } - if err := addDirToZip(zw, m.fsys, m.dir, "terraform/"+m.zipDir); err != nil { - return fmt.Errorf("module %s: %w", m.zipDir, err) - } - } - return nil -} - -// rewriteModuleSources points each quoted repoSource in src at the module's -// copy in the zip, and marks the modules it rewrote in used. -func rewriteModuleSources(src []byte, mods []bundledModule, used []bool) []byte { - for i, m := range mods { - quoted := []byte(`"` + m.repoSource + `"`) - if bytes.Contains(src, quoted) { - src = bytes.ReplaceAll(src, quoted, []byte(`"./`+m.zipDir+`"`)) - used[i] = true - } - } - return src -} diff --git a/internal/api/federation_bundle_modules_test.go b/internal/api/federation_bundle_modules_test.go index 0fb741f72..d03256b3d 100644 --- a/internal/api/federation_bundle_modules_test.go +++ b/internal/api/federation_bundle_modules_test.go @@ -4,85 +4,26 @@ import ( "os" "path" "path/filepath" - "regexp" "sort" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" -) - -// localModuleSourceRe matches a module source that is a local path. Registry -// and provider sources never start with "./" or "../". -var localModuleSourceRe = regexp.MustCompile(`(?m)^\s*source\s*=\s*"(\.\.?/[^"]+)"`) -// unresolvedLocalModules returns "file: source" for every local module source -// in a bundle's .tf files that does not point at a directory of .tf files -// inside the bundle. terraform init fails on each of them with "Unreadable -// module directory" (#1431). -func unresolvedLocalModules(files map[string][]byte) []string { - tfDirs := map[string]bool{} - for name := range files { - if strings.HasSuffix(name, ".tf") { - tfDirs[path.Dir(name)] = true - } - } - var bad []string - for name, content := range files { - if !strings.HasSuffix(name, ".tf") { - continue - } - for _, m := range localModuleSourceRe.FindAllSubmatch(content, -1) { - if !tfDirs[path.Join(path.Dir(name), string(m[1]))] { - bad = append(bad, name+": "+string(m[1])) - } - } - } - sort.Strings(bad) - return bad -} + "github.com/LeanerCloud/cloud-commitments-platform/internal/iacbundle" +) func TestFederationBundles_LocalModuleSourcesResolveInsideZip(t *testing.T) { for _, c := range federationBundleCombos { t.Run(c.module, func(t *testing.T) { files := renderBundleFiles(t, c.target, c.source, c.sourceCloud) - assert.Empty(t, unresolvedLocalModules(files), + assert.Empty(t, iacbundle.UnresolvedLocalModules(files), "every local module source in the %s bundle must resolve to a directory inside the zip", c.module) }) } } -// TestUnresolvedLocalModules_DetectsEscape proves the guard above can fail: a -// source that leaves the bundle, or names a directory the zip lacks, is -// reported, and one that resolves inside the zip is not. -func TestUnresolvedLocalModules_DetectsEscape(t *testing.T) { - files := map[string][]byte{ - "terraform/main.tf": []byte(`module "a" { - source = "../../../../terraform/modules/iam/azure/cudly-reservation-role" -} -module "b" { - source = "./modules/missing" -} -module "c" { - source = "./modules/present" -} -terraform { - required_providers { - azurerm = { - source = "hashicorp/azurerm" - } - } -} -`), - "terraform/modules/present/main.tf": []byte(`resource "null_resource" "x" {}`), - } - assert.Equal(t, []string{ - "terraform/main.tf: ../../../../terraform/modules/iam/azure/cudly-reservation-role", - "terraform/main.tf: ./modules/missing", - }, unresolvedLocalModules(files)) -} - // TestAzureBundle_RoleModuleMatchesSharedModule keeps the role module shipped // in the Azure bundle identical to terraform/modules/iam/azure/ // cudly-reservation-role, which the host side and scripts/check-azure-role- diff --git a/internal/api/handler_federation.go b/internal/api/handler_federation.go index 28613f409..32d8b2797 100644 --- a/internal/api/handler_federation.go +++ b/internal/api/handler_federation.go @@ -7,7 +7,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io/fs" "reflect" "regexp" "strings" @@ -18,6 +17,7 @@ import ( "github.com/google/uuid" cudlyiac "github.com/LeanerCloud/cloud-commitments-platform/iac" + "github.com/LeanerCloud/cloud-commitments-platform/internal/iacbundle" "github.com/LeanerCloud/cloud-commitments-platform/internal/iacfiles" "github.com/aws/aws-lambda-go/events" ) @@ -665,7 +665,7 @@ func buildAzureTemplateReadme(data federationIaCData, format string) string { // addBundleTerraform adds the Terraform module files and generated .tfvars to the zip. func addBundleTerraform(zw *zip.Writer, data federationIaCData, target, source, slug string) error { tfDir := bundleModuleDir(target, source) + "/terraform" - if err := addBundleTerraformDir(zw, tfDir, bundleExtraModules(target)); err != nil { + if err := iacbundle.AddTerraformDir(zw, cudlyiac.Modules, tfDir, iacbundle.ExtraModules(target)); err != nil { return fmt.Errorf("bundle: terraform dir: %w", err) } tfvarsTmpl, tfvarsName := bundleTfvarsSpec(target, source, slug) @@ -875,21 +875,6 @@ func buildCFParamsJSON(data federationIaCData, source string) (string, error) { // Zip helpers // --------------------------------------------------------------------------- -func addDirToZip(zw *zip.Writer, fsys fs.ReadFileFS, srcDir, destPrefix string) error { - return fs.WalkDir(fsys, srcDir, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - b, err := fsys.ReadFile(path) - if err != nil { - return err - } - // Compute the relative path from srcDir to this file. - rel := strings.TrimPrefix(path, srcDir+"/") - return addBytesToZip(zw, destPrefix+"/"+rel, b) - }) -} - func addBytesToZip(zw *zip.Writer, name string, content []byte) error { f, err := zw.Create(name) if err != nil { diff --git a/internal/iacbundle/bundle.go b/internal/iacbundle/bundle.go new file mode 100644 index 000000000..c312916e0 --- /dev/null +++ b/internal/iacbundle/bundle.go @@ -0,0 +1,152 @@ +// Package iacbundle writes the Terraform part of a customer federation bundle. +// Both bundle builders use it, the API (GET /api/federation/iac?format=bundle) +// and scripts/generate-federation-iac.go --format bundle, so every bundle ships +// the out-of-tree modules its Terraform calls and `terraform init` works from +// the unzipped files (#1431, #1442). +package iacbundle + +import ( + "archive/zip" + "bytes" + "fmt" + "io/fs" + "path" + "regexp" + "sort" + "strings" + + cudlytf "github.com/LeanerCloud/cloud-commitments-platform/terraform" +) + +// Module is a Terraform module that a federation bundle calls from outside its +// own Terraform directory. The bundle ships a copy of it under +// terraform/ and points the call at that copy. +type Module struct { + RepoSource string // the module source as written in the repo's .tf files + FS fs.FS // where the module's files are + Dir string // the module's directory in FS + ZipDir string // its directory in the zip, relative to terraform/ +} + +// ExtraModules returns the out-of-tree modules the target's bundle calls. Only +// the Azure bundle has one: the custom reservation-purchaser role, shared with +// the host side and checked by scripts/check-azure-role-parity.sh. +func ExtraModules(target string) []Module { + if target != "azure" { + return nil + } + return []Module{{ + RepoSource: "../../../../terraform/modules/iam/azure/cudly-reservation-role", + FS: cudlytf.ReservationRoleModule, + Dir: cudlytf.ReservationRoleModuleDir, + ZipDir: "modules/cudly-reservation-role", + }} +} + +// AddTerraformDir writes srcDir in fsys to the zip under terraform/, followed +// by every module in mods, with each module call rewritten to the shipped +// copy. Hidden files and directories (.terraform.lock.hcl, a local +// .terraform/) are left out, as go:embed leaves them out of the API's copy. A +// module that no .tf file calls is an error: the rewrite would otherwise +// silently stop applying after the call is moved. +func AddTerraformDir(zw *zip.Writer, fsys fs.FS, srcDir string, mods []Module) error { + used := make([]bool, len(mods)) + err := walkVisible(fsys, srcDir, func(p string, b []byte) error { + if strings.HasSuffix(p, ".tf") { + b = rewriteModuleSources(b, mods, used) + } + return addFile(zw, "terraform/"+strings.TrimPrefix(p, srcDir+"/"), b) + }) + if err != nil { + return err + } + for i, m := range mods { + if !used[i] { + return fmt.Errorf("no module under %s calls %q", srcDir, m.RepoSource) + } + err := walkVisible(m.FS, m.Dir, func(p string, b []byte) error { + return addFile(zw, "terraform/"+m.ZipDir+"/"+strings.TrimPrefix(p, m.Dir+"/"), b) + }) + if err != nil { + return fmt.Errorf("module %s: %w", m.ZipDir, err) + } + } + return nil +} + +// walkVisible calls fn with the path and content of every regular file under +// dir in fsys, skipping hidden files and directories. +func walkVisible(fsys fs.FS, dir string, fn func(p string, b []byte) error) error { + return fs.WalkDir(fsys, dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p != dir && strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + b, err := fs.ReadFile(fsys, p) + if err != nil { + return err + } + return fn(p, b) + }) +} + +// rewriteModuleSources points each quoted RepoSource in src at the module's +// copy in the zip, and marks the modules it rewrote in used. +func rewriteModuleSources(src []byte, mods []Module, used []bool) []byte { + for i, m := range mods { + quoted := []byte(`"` + m.RepoSource + `"`) + if bytes.Contains(src, quoted) { + src = bytes.ReplaceAll(src, quoted, []byte(`"./`+m.ZipDir+`"`)) + used[i] = true + } + } + return src +} + +func addFile(zw *zip.Writer, name string, content []byte) error { + f, err := zw.Create(name) + if err != nil { + return err + } + _, err = f.Write(content) + return err +} + +// localModuleSourceRe matches a module source that is a local path. Registry +// and provider sources never start with "./" or "../". +var localModuleSourceRe = regexp.MustCompile(`(?m)^\s*source\s*=\s*"(\.\.?/[^"]+)"`) + +// UnresolvedLocalModules returns "file: source" for every local module source +// in a bundle's .tf files (keyed by their path in the zip) that does not point +// at a directory of .tf files inside the bundle. terraform init fails on each +// of them with "Unreadable module directory" (#1431). Both bundle builders' +// tests run their output through it. +func UnresolvedLocalModules(files map[string][]byte) []string { + tfDirs := map[string]bool{} + for name := range files { + if strings.HasSuffix(name, ".tf") { + tfDirs[path.Dir(name)] = true + } + } + var bad []string + for name, content := range files { + if !strings.HasSuffix(name, ".tf") { + continue + } + for _, m := range localModuleSourceRe.FindAllSubmatch(content, -1) { + if !tfDirs[path.Join(path.Dir(name), string(m[1]))] { + bad = append(bad, name+": "+string(m[1])) + } + } + } + sort.Strings(bad) + return bad +} diff --git a/internal/iacbundle/bundle_test.go b/internal/iacbundle/bundle_test.go new file mode 100644 index 000000000..a5c4b968b --- /dev/null +++ b/internal/iacbundle/bundle_test.go @@ -0,0 +1,107 @@ +package iacbundle + +import ( + "archive/zip" + "bytes" + "io" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// unzip returns the files in a zip archive, keyed by name. +func unzip(t *testing.T, b []byte) map[string][]byte { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + require.NoError(t, err) + files := map[string][]byte{} + for _, f := range zr.File { + rc, err := f.Open() + require.NoError(t, err) + data, err := io.ReadAll(rc) + require.NoError(t, rc.Close()) + require.NoError(t, err) + files[f.Name] = data + } + return files +} + +const roleCall = `module "role" { + source = "../../../../terraform/modules/iam/azure/cudly-reservation-role" +} +` + +// TestAddTerraformDir_ShipsAndRewritesModule builds the Azure bundle's +// Terraform from a disk-like tree, the way scripts/generate-federation-iac.go +// does: the role module ships under terraform/modules/, the call points at +// it, hidden files stay out, and every local source resolves in the zip. +func TestAddTerraformDir_ShipsAndRewritesModule(t *testing.T) { + src := fstest.MapFS{ + "azure-target/terraform/main.tf": {Data: []byte(roleCall)}, + "azure-target/terraform/variables.tf": {Data: []byte(`variable "x" {}`)}, + "azure-target/terraform/tests/guards.tftest.hcl": {Data: []byte(`run "x" {}`)}, + "azure-target/terraform/.terraform.lock.hcl": {Data: []byte(`lock`)}, + "azure-target/terraform/.terraform/modules/modules.json": {Data: []byte(`{}`)}, + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + require.NoError(t, AddTerraformDir(zw, src, "azure-target/terraform", ExtraModules("azure"))) + require.NoError(t, zw.Close()) + files := unzip(t, buf.Bytes()) + + assert.Contains(t, string(files["terraform/main.tf"]), `source = "./modules/cudly-reservation-role"`) + assert.Contains(t, files, "terraform/variables.tf") + assert.Contains(t, files, "terraform/tests/guards.tftest.hcl") + assert.Contains(t, files, "terraform/modules/cudly-reservation-role/main.tf") + assert.NotContains(t, files, "terraform/.terraform.lock.hcl") + assert.NotContains(t, files, "terraform/.terraform/modules/modules.json") + assert.Empty(t, UnresolvedLocalModules(files)) +} + +// TestAddTerraformDir_UncalledModuleIsAnError keeps the rewrite honest: a +// module nothing calls means the call moved and the rewrite stopped applying. +func TestAddTerraformDir_UncalledModuleIsAnError(t *testing.T) { + src := fstest.MapFS{"azure-target/terraform/main.tf": {Data: []byte(`variable "x" {}`)}} + zw := zip.NewWriter(io.Discard) + err := AddTerraformDir(zw, src, "azure-target/terraform", ExtraModules("azure")) + require.ErrorContains(t, err, "cudly-reservation-role") +} + +func TestExtraModules_OnlyAzure(t *testing.T) { + for _, target := range []string{"aws", "gcp", ""} { + assert.Empty(t, ExtraModules(target), target) + } + assert.Len(t, ExtraModules("azure"), 1) +} + +// TestUnresolvedLocalModules_DetectsEscape proves the guard can fail: a +// source that leaves the bundle, or names a directory the zip lacks, is +// reported, and one that resolves inside the zip is not. +func TestUnresolvedLocalModules_DetectsEscape(t *testing.T) { + files := map[string][]byte{ + "terraform/main.tf": []byte(`module "a" { + source = "../../../../terraform/modules/iam/azure/cudly-reservation-role" +} +module "b" { + source = "./modules/missing" +} +module "c" { + source = "./modules/present" +} +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + } + } +} +`), + "terraform/modules/present/main.tf": []byte(`resource "null_resource" "x" {}`), + } + assert.Equal(t, []string{ + "terraform/main.tf: ../../../../terraform/modules/iam/azure/cudly-reservation-role", + "terraform/main.tf: ./modules/missing", + }, UnresolvedLocalModules(files)) +} diff --git a/scripts/generate-federation-iac.go b/scripts/generate-federation-iac.go index aab02e98e..5d0466140 100644 --- a/scripts/generate-federation-iac.go +++ b/scripts/generate-federation-iac.go @@ -106,6 +106,8 @@ import ( "strings" "text/template" "unicode" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/iacbundle" ) // iacData is this script's copy of federationIaCData in @@ -296,51 +298,41 @@ func addToZip(zw *zip.Writer, name string, content []byte) error { return err } -// bundleModuleSpec returns the Terraform module directory, tfvars template path, -// and zip destination name for the given target/source combination. -func bundleModuleSpec(target, source, slug, templDir, modulesDir string) (moduleDir, tfvarsTmpl, tfvarsName string) { +// bundleModuleSpec returns the Terraform module directory (slash-separated, +// relative to the modules directory), tfvars template path, and zip +// destination name for the given target/source combination. +func bundleModuleSpec(target, source, slug, templDir string) (moduleDir, tfvarsTmpl, tfvarsName string) { switch { case target == "aws" && source == "aws": - return filepath.Join(modulesDir, "aws-cross-account", "terraform"), + return "aws-cross-account/terraform", filepath.Join(templDir, "aws-cross-account.tfvars.tmpl"), "terraform/" + slug + "-aws-cross-account.tfvars" case target == "aws": - return filepath.Join(modulesDir, "aws-target", "terraform"), + return "aws-target/terraform", filepath.Join(templDir, "aws-wif.tfvars.tmpl"), "terraform/" + slug + "-aws-wif.tfvars" case target == "azure": - return filepath.Join(modulesDir, "azure-target", "terraform"), + return "azure-target/terraform", filepath.Join(templDir, "azure-wif.tfvars.tmpl"), "terraform/" + slug + "-azure-wif.tfvars" case target == "gcp" && source == "gcp": - return filepath.Join(modulesDir, "gcp-sa-impersonation", "terraform"), + return "gcp-sa-impersonation/terraform", filepath.Join(templDir, "gcp-sa-impersonation.tfvars.tmpl"), "terraform/" + slug + "-gcp-sa-impersonation.tfvars" default: // gcp, non-gcp source - return filepath.Join(modulesDir, "gcp-target", "terraform"), + return "gcp-target/terraform", filepath.Join(templDir, "gcp-wif.tfvars.tmpl"), "terraform/" + slug + "-gcp-wif.tfvars" } } -// addTerraformToBundle writes the Terraform module files and generated .tfvars into zw. +// addTerraformToBundle writes the Terraform module files and generated .tfvars +// into zw. It goes through internal/iacbundle, as the API's bundle does, so the +// out-of-tree modules the Terraform calls ship inside the zip too (#1442). func addTerraformToBundle(zw *zip.Writer, data iacData, target, source, slug, templDir, modulesDir string) error { - moduleDir, tfvarsTmpl, tfvarsName := bundleModuleSpec(target, source, slug, templDir, modulesDir) - entries, err := os.ReadDir(moduleDir) - if err != nil { - return fmt.Errorf("read module dir %s: %w\n\nRun from the repository root.", moduleDir, err) - } - for _, entry := range entries { - if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - b, err := os.ReadFile(filepath.Join(moduleDir, entry.Name())) - if err != nil { - return err - } - if err = addToZip(zw, "terraform/"+entry.Name(), b); err != nil { - return err - } + moduleDir, tfvarsTmpl, tfvarsName := bundleModuleSpec(target, source, slug, templDir) + if err := iacbundle.AddTerraformDir(zw, os.DirFS(modulesDir), moduleDir, iacbundle.ExtraModules(target)); err != nil { + return fmt.Errorf("bundle %s: %w\n\nRun from the repository root.", filepath.Join(modulesDir, moduleDir), err) } content, err := renderTmpl(tfvarsTmpl, data) if err != nil { diff --git a/scripts/generate_federation_iac_bundle_test.go b/scripts/generate_federation_iac_bundle_test.go new file mode 100644 index 000000000..101f95f7e --- /dev/null +++ b/scripts/generate_federation_iac_bundle_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "archive/zip" + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/iacbundle" +) + +// TestGenerator_BundleLocalModulesResolveInsideZip is the regression test for +// #1442: the script's own --format bundle path zipped only the target's +// Terraform directory, so the Azure bundle's main.tf still called +// ../../../../terraform/modules/iam/azure/cudly-reservation-role and +// `terraform init` on the unzipped bundle failed with "Unreadable module +// directory". It runs every bundle the script can build through the same check +// the API's bundles go through (internal/api, #1431). +func TestGenerator_BundleLocalModulesResolveInsideZip(t *testing.T) { + combos := []struct { + name string + args []string + }{ + {"aws-cross-account", []string{"--target", "aws", "--source", "aws", + "--account-id", "123456789012", "--source-account-id", "111122223333"}}, + {"aws-target", []string{"--target", "aws", "--source", "gcp", + "--account-id", "123456789012", "--oidc-subject-claim", "123456789012345678901"}}, + {"azure-target", []string{"--target", "azure", "--source", "gcp", + "--account-id", "11111111-2222-3333-4444-555555555555", + "--tenant-id", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "--cudly-api-url", "https://cudly.example.com"}}, + {"gcp-sa-impersonation", []string{"--target", "gcp", "--source", "gcp", "--account-id", "my-project"}}, + {"gcp-target", []string{"--target", "gcp", "--source", "aws", + "--account-id", "my-project", "--source-account-id", "111122223333"}}, + } + for _, c := range combos { + t.Run(c.name, func(t *testing.T) { + out := filepath.Join(t.TempDir(), "bundle.zip") + args := append([]string{"--account-name", "Acme", "--format", "bundle", "--output", out}, c.args...) + res := runGenerator(t, args...) + require.Zero(t, res.exitCode, "stderr: %s", res.stderr) + + files := readZip(t, out) + assert.Empty(t, iacbundle.UnresolvedLocalModules(files), + "every local module source in the %s bundle must resolve to a directory inside the zip", c.name) + for name := range files { + assert.NotRegexp(t, `(^|/)\.`, name, "hidden file shipped in the %s bundle", c.name) + } + }) + } +} + +// readZip returns the files in the zip at path, keyed by name. +func readZip(t *testing.T, path string) map[string][]byte { + t.Helper() + zr, err := zip.OpenReader(path) + require.NoError(t, err) + defer zr.Close() + files := map[string][]byte{} + for _, f := range zr.File { + rc, err := f.Open() + require.NoError(t, err) + data, err := io.ReadAll(rc) + require.NoError(t, rc.Close()) + require.NoError(t, err) + files[f.Name] = data + } + return files +} diff --git a/terraform/embed.go b/terraform/embed.go index 1f0dbc54a..434107092 100644 --- a/terraform/embed.go +++ b/terraform/embed.go @@ -1,5 +1,6 @@ // Package terraform embeds the shared Terraform modules that ship inside the -// customer federation bundles built by internal/api. +// customer federation bundles built by internal/iacbundle (for both the API +// and scripts/generate-federation-iac.go). package terraform import "embed"