diff --git a/internal/api/federation_bundle_providers.go b/internal/api/federation_bundle_providers.go new file mode 100644 index 00000000..b19ee2a2 --- /dev/null +++ b/internal/api/federation_bundle_providers.go @@ -0,0 +1,149 @@ +package api + +import ( + "fmt" + "regexp" + "strings" + + cudlyiac "github.com/LeanerCloud/cloud-commitments-platform/iac" +) + +// bundleProvider is one entry of a federation bundle's Terraform +// required_providers block. +type bundleProvider struct { + Name string // local name, e.g. "aws" + Source string // registry source, e.g. "hashicorp/aws" + Version string // version constraint exactly as written in main.tf +} + +var ( + tfRequiredProvidersOpen = regexp.MustCompile(`^required_providers\s*\{$`) + tfProviderEntryOpen = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_-]*)\s*=\s*\{$`) + tfProviderAttr = regexp.MustCompile(`^(source|version)\s*=\s*"([^"]*)"$`) +) + +type providerScanState int + +const ( + scanOutside providerScanState = iota + scanBlock + scanEntry + scanDone +) + +// providerScanner reads the required_providers block of a `terraform fmt` +// formatted main.tf line by line. It is deliberately strict: any line it does +// not recognize inside the block is an error, so a layout change in a bundle +// fails loudly instead of silently dropping a provider. +type providerScanner struct { + state providerScanState + cur bundleProvider + out []bundleProvider +} + +// parseRequiredProviders returns the required_providers entries of a bundle's +// main.tf, in file order. +func parseRequiredProviders(src []byte) ([]bundleProvider, error) { + s := &providerScanner{} + for i, line := range strings.Split(string(src), "\n") { + if err := s.feed(strings.TrimSpace(line)); err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + } + if s.state != scanDone { + return nil, fmt.Errorf("no complete required_providers block found") + } + return s.out, nil +} + +func (s *providerScanner) feed(line string) error { + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + return nil + } + switch s.state { + case scanOutside: + if tfRequiredProvidersOpen.MatchString(line) { + s.state = scanBlock + } + case scanBlock: + return s.feedBlock(line) + case scanEntry: + return s.feedEntry(line) + } + return nil +} + +func (s *providerScanner) feedBlock(line string) error { + if line == "}" { + s.state = scanDone + return nil + } + m := tfProviderEntryOpen.FindStringSubmatch(line) + if m == nil { + return fmt.Errorf("unexpected line in required_providers: %q", line) + } + s.cur = bundleProvider{Name: m[1]} + s.state = scanEntry + return nil +} + +func (s *providerScanner) feedEntry(line string) error { + if line == "}" { + if s.cur.Source == "" || s.cur.Version == "" { + return fmt.Errorf("provider %q needs both source and version", s.cur.Name) + } + s.out = append(s.out, s.cur) + s.state = scanBlock + return nil + } + m := tfProviderAttr.FindStringSubmatch(line) + if m == nil { + return fmt.Errorf("unexpected line in provider %q: %q", s.cur.Name, line) + } + if m[1] == "source" { + s.cur.Source = m[2] + } else { + s.cur.Version = m[2] + } + return nil +} + +// bundleProviders reads and parses the required_providers block that ships +// in the Terraform module of the given federation bundle. +func bundleProviders(target, source string) ([]bundleProvider, error) { + path := bundleModuleDir(target, source) + "/terraform/main.tf" + src, err := cudlyiac.Modules.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + providers, err := parseRequiredProviders(src) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return providers, nil +} + +// bundleProviderReadmeSection documents the provider version ranges a +// bundle's Terraform module declares, and how a customer moves to a newer +// range. The ranges are read from the embedded main.tf, so the README cannot +// drift from the constraints the customer actually runs. +func bundleProviderReadmeSection(target, source string) (string, error) { + providers, err := bundleProviders(target, source) + if err != nil { + return "", err + } + var sb strings.Builder + sb.WriteString("\nProvider versions (from required_providers in terraform/main.tf):\n") + for _, p := range providers { + fmt.Fprintf(&sb, " %-18s %s\n", p.Source, p.Version) + } + sb.WriteString("\nThe bundle ships without a .terraform.lock.hcl. The first `terraform init`\n" + + "picks the newest provider release inside each range and records it in\n" + + "terraform/.terraform.lock.hcl. Keep that lock file next to your state so\n" + + "later runs use the same provider versions.\n\n" + + "When CUDly moves a bundle to a newer provider line, download a fresh bundle,\n" + + "copy its .tf files over your existing ones (keep your state, backend\n" + + "configuration and lock file), then run `terraform init -upgrade` and review\n" + + "`terraform plan` before applying.\n") + return sb.String(), nil +} diff --git a/internal/api/federation_bundle_providers_test.go b/internal/api/federation_bundle_providers_test.go new file mode 100644 index 00000000..410d95d3 --- /dev/null +++ b/internal/api/federation_bundle_providers_test.go @@ -0,0 +1,348 @@ +package api + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// federationBundleCombos lists every target/source pair that renders a +// distinct Terraform bundle, with the CUDLY_SOURCE_CLOUD each needs to pass +// validateFederationTargetSource. +var federationBundleCombos = []struct { + target, source, sourceCloud, module string +}{ + {"aws", "aws", "aws", "aws-cross-account"}, + {"aws", "gcp", "gcp", "aws-target"}, + {"azure", "gcp", "gcp", "azure-target"}, + {"gcp", "gcp", "gcp", "gcp-sa-impersonation"}, + {"gcp", "aws", "aws", "gcp-target"}, +} + +// renderBundleFiles renders a bundle through the real API path and returns +// its files. +func renderBundleFiles(t *testing.T, target, source, sourceCloud string) map[string][]byte { + t.Helper() + t.Setenv("CUDLY_SOURCE_CLOUD", sourceCloud) + h := federationHandler(t) + res, err := h.getFederationIaC(context.Background(), federationReq(map[string]string{ + "target": target, "source": source, "format": "bundle", + })) + require.NoError(t, err) + return unzipResponse(t, res) +} + +// versionBounds is a constraint reduced to its lower and upper bound, each a +// normalized version with its operator ("" when unbounded). +type versionBounds struct { + Floor string + Ceiling string +} + +func (b versionBounds) String() string { + return fmt.Sprintf("floor=%q ceiling=%q", b.Floor, b.Ceiling) +} + +// normalizeVersion pads a numeric version to three segments, so "5" and +// "5.0" compare equal to "5.0.0". +func normalizeVersion(v string) ([]int, error) { + parts := strings.Split(strings.TrimSpace(v), ".") + if len(parts) > 3 { + return nil, fmt.Errorf("version %q has more than three segments", v) + } + out := make([]int, 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return nil, fmt.Errorf("version %q is not numeric", v) + } + out[i] = n + } + return out, nil +} + +func formatVersion(v []int) string { + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) +} + +// pessimisticCeiling returns the exclusive upper bound Terraform derives +// from "~> v": the second-to-last written segment is bumped. A single +// segment ("~> 5") has no upper bound, matching hashicorp/go-version. +func pessimisticCeiling(v string) (string, error) { + segs := len(strings.Split(strings.TrimSpace(v), ".")) + n, err := normalizeVersion(v) + if err != nil || segs == 1 { + return "", err + } + c := make([]int, 3) + copy(c, n[:segs-2]) + c[segs-2] = n[segs-2] + 1 + return "< " + formatVersion(c), nil +} + +func splitConstraintOp(term string) (op, version string) { + for _, candidate := range []string{">=", "<=", "~>", "!=", ">", "<", "="} { + if strings.HasPrefix(term, candidate) { + return candidate, strings.TrimSpace(strings.TrimPrefix(term, candidate)) + } + } + return "=", term +} + +// applyConstraintTerm folds one comma-separated term of a constraint into b. +func applyConstraintTerm(b *versionBounds, term string) error { + op, version := splitConstraintOp(strings.TrimSpace(term)) + n, err := normalizeVersion(version) + if err != nil { + return err + } + switch op { + case ">=", "=": + b.Floor = ">= " + formatVersion(n) + case ">": + b.Floor = "> " + formatVersion(n) + case "<", "<=": + b.Ceiling = op + " " + formatVersion(n) + case "~>": + b.Floor = ">= " + formatVersion(n) + ceiling, cerr := pessimisticCeiling(version) + if cerr != nil { + return cerr + } + if ceiling != "" { + b.Ceiling = ceiling + } + default: + return fmt.Errorf("unsupported operator %q", op) + } + return nil +} + +// constraintBounds reduces a Terraform version constraint to its bounds, so +// "~> 4.0" and ">= 4.0, < 5.0" compare equal. +func constraintBounds(constraint string) (versionBounds, error) { + var b versionBounds + for _, term := range strings.Split(constraint, ",") { + if err := applyConstraintTerm(&b, term); err != nil { + return versionBounds{}, fmt.Errorf("constraint %q: %w", constraint, err) + } + } + return b, nil +} + +// providerBoundsDisagreements returns one message per provider source whose +// floor or ceiling differs between bundles. providers maps a bundle name to +// its required_providers entries. +func providerBoundsDisagreements(providers map[string][]bundleProvider) ([]string, error) { + seen := map[string]map[versionBounds][]string{} // source -> bounds -> bundles + for bundle, entries := range providers { + for _, p := range entries { + b, err := constraintBounds(p.Version) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", bundle, p.Source, err) + } + if seen[p.Source] == nil { + seen[p.Source] = map[versionBounds][]string{} + } + seen[p.Source][b] = append(seen[p.Source][b], bundle+" ("+p.Version+")") + } + } + var out []string + for source, byBounds := range seen { + if len(byBounds) < 2 { + continue + } + var groups []string + for b, bundles := range byBounds { + sort.Strings(bundles) + groups = append(groups, fmt.Sprintf("%s: %s", b, strings.Join(bundles, ", "))) + } + sort.Strings(groups) + out = append(out, fmt.Sprintf("%s has %d different bounds across bundles: %s", + source, len(byBounds), strings.Join(groups, "; "))) + } + sort.Strings(out) + return out, nil +} + +// TestFederationBundles_ProviderBoundsAgreeAcrossBundles renders every +// customer federation bundle through the API and fails if two bundles declare +// a different floor or ceiling for the same provider (#55). A customer who +// downloads the AWS and GCP bundles, or onboards a second account later, must +// get one version policy per provider. +func TestFederationBundles_ProviderBoundsAgreeAcrossBundles(t *testing.T) { + providers := map[string][]bundleProvider{} + for _, c := range federationBundleCombos { + files := renderBundleFiles(t, c.target, c.source, c.sourceCloud) + mainTF, ok := files["terraform/main.tf"] + require.True(t, ok, "%s bundle must contain terraform/main.tf", c.module) + entries, err := parseRequiredProviders(mainTF) + require.NoError(t, err, "%s: parse rendered terraform/main.tf", c.module) + require.NotEmpty(t, entries, "%s: no required_providers entries", c.module) + providers[c.module] = entries + } + + disagreements, err := providerBoundsDisagreements(providers) + require.NoError(t, err) + assert.Empty(t, disagreements, + "federation bundles must declare the same floor and ceiling for a shared provider") +} + +// TestProviderBoundsDisagreements_DetectsDivergence proves the guard above can +// fail: a floor or ceiling that differs in one bundle is reported. +func TestProviderBoundsDisagreements_DetectsDivergence(t *testing.T) { + aws := func(v string) bundleProvider { return bundleProvider{Name: "aws", Source: "hashicorp/aws", Version: v} } + http := func(v string) bundleProvider { + return bundleProvider{Name: "http", Source: "hashicorp/http", Version: v} + } + + tests := []struct { + name string + providers map[string][]bundleProvider + want []string // substrings, one per expected disagreement + }{ + { + name: "aligned bundles", + providers: map[string][]bundleProvider{ + "a": {aws(">= 5.0, < 7.0"), http(">= 3.4")}, + "b": {aws(">= 5.0, < 7.0"), http(">= 3.4")}, + }, + }, + { + name: "equivalent spellings are aligned", + providers: map[string][]bundleProvider{ + "a": {http("~> 3.4")}, + "b": {http(">= 3.4.0, < 4")}, + }, + }, + { + name: "different floor", + providers: map[string][]bundleProvider{ + "a": {aws(">= 5.0")}, + "b": {aws(">= 6.0")}, + }, + want: []string{"hashicorp/aws has 2 different bounds"}, + }, + { + name: "ceiling on only one bundle", + providers: map[string][]bundleProvider{ + "a": {http(">= 3.4, < 4.0")}, + "b": {http(">= 3.4")}, + "c": {http(">= 3.4, < 4.0")}, + }, + want: []string{"hashicorp/http has 2 different bounds"}, + }, + { + name: "provider in one bundle only is not compared", + providers: map[string][]bundleProvider{ + "a": {aws(">= 5.0")}, + "b": {http(">= 3.4")}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := providerBoundsDisagreements(tt.providers) + require.NoError(t, err) + require.Len(t, got, len(tt.want), "disagreements: %v", got) + for i, w := range tt.want { + assert.Contains(t, got[i], w) + } + }) + } +} + +func TestConstraintBounds(t *testing.T) { + tests := []struct { + in string + want versionBounds + }{ + {">= 5.0", versionBounds{Floor: ">= 5.0.0"}}, + {">= 5.0, < 7.0", versionBounds{Floor: ">= 5.0.0", Ceiling: "< 7.0.0"}}, + {"~> 4.0", versionBounds{Floor: ">= 4.0.0", Ceiling: "< 5.0.0"}}, + {"~> 3.8", versionBounds{Floor: ">= 3.8.0", Ceiling: "< 4.0.0"}}, + {"~> 1.2.3", versionBounds{Floor: ">= 1.2.3", Ceiling: "< 1.3.0"}}, + {"~> 5", versionBounds{Floor: ">= 5.0.0"}}, + {"5.1.0", versionBounds{Floor: ">= 5.1.0"}}, + {"> 2.0, <= 3.0", versionBounds{Floor: "> 2.0.0", Ceiling: "<= 3.0.0"}}, + } + for _, tt := range tests { + got, err := constraintBounds(tt.in) + require.NoError(t, err, tt.in) + assert.Equal(t, tt.want, got, tt.in) + } + + for _, bad := range []string{">= 5.0-beta", "!= 5.0", ">= x", ""} { + _, err := constraintBounds(bad) + assert.Error(t, err, bad) + } +} + +func TestParseRequiredProviders(t *testing.T) { + src := []byte(`terraform { + required_version = ">= 1.5" + required_providers { + aws = { + # comment + source = "hashicorp/aws" + version = ">= 5.0, < 7.0" + } + + http = { + source = "hashicorp/http" + version = ">= 3.4" + } + } +} + +resource "x" "y" { + version = "ignored" +} +`) + got, err := parseRequiredProviders(src) + require.NoError(t, err) + assert.Equal(t, []bundleProvider{ + {Name: "aws", Source: "hashicorp/aws", Version: ">= 5.0, < 7.0"}, + {Name: "http", Source: "hashicorp/http", Version: ">= 3.4"}, + }, got) + + bad := map[string]string{ + "no block": "terraform {\n required_version = \">= 1.5\"\n}\n", + "unterminated": "terraform {\n required_providers {\n aws = {\n source = \"hashicorp/aws\"\n", + "inline entry": "required_providers {\n aws = { source = \"hashicorp/aws\", version = \">= 5.0\" }\n}\n", + "missing version": "required_providers {\n aws = {\n source = \"hashicorp/aws\"\n }\n}\n", + "unknown attr": "required_providers {\n aws = {\n source = \"hashicorp/aws\"\n version = \">= 5.0\"\n configuration_aliases = [aws.x]\n }\n}\n", + } + for name, s := range bad { + _, err := parseRequiredProviders([]byte(s)) + assert.Error(t, err, name) + } +} + +// TestFederationBundleReadme_ListsProviderConstraints checks that every +// bundle's README.txt names each provider range its terraform/main.tf +// declares, and tells the customer how to move to a newer range (#55). +func TestFederationBundleReadme_ListsProviderConstraints(t *testing.T) { + for _, c := range federationBundleCombos { + t.Run(c.module, func(t *testing.T) { + files := renderBundleFiles(t, c.target, c.source, c.sourceCloud) + entries, err := parseRequiredProviders(files["terraform/main.tf"]) + require.NoError(t, err) + readme := string(files["README.txt"]) + require.Contains(t, readme, "Provider versions") + for _, p := range entries { + assert.Regexp(t, `(?m)^ `+regexp.QuoteMeta(p.Source)+` +`+regexp.QuoteMeta(p.Version)+`$`, readme, + "README must list %s %s", p.Source, p.Version) + } + assert.Contains(t, readme, "terraform init -upgrade") + assert.Contains(t, readme, ".terraform.lock.hcl") + }) + } +} diff --git a/internal/api/handler_federation.go b/internal/api/handler_federation.go index ead506cd..0bed6691 100644 --- a/internal/api/handler_federation.go +++ b/internal/api/handler_federation.go @@ -525,7 +525,11 @@ func buildFederationBundle(data federationIaCData, target, source, slug string) return nil, "", err } - readme := buildBundleReadme(data, target, source) + providerNote, noteErr := bundleProviderReadmeSection(target, source) + if noteErr != nil { + return nil, "", fmt.Errorf("bundle: %w", noteErr) + } + readme := buildBundleReadme(data, target, source) + providerNote if err := addStringToZip(zw, "README.txt", readme); err != nil { return nil, "", fmt.Errorf("bundle: write readme: %w", err) }