|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "io/fs" |
| 6 | + "path/filepath" |
| 7 | + "sort" |
| 8 | + "strings" |
| 9 | + |
| 10 | + "github.com/hashicorp/hcl/v2" |
| 11 | + "github.com/hashicorp/hcl/v2/hclparse" |
| 12 | + "github.com/zclconf/go-cty/cty" |
| 13 | + |
| 14 | + "github.com/localstack/lstk/internal/log" |
| 15 | +) |
| 16 | + |
| 17 | +// backendEndpointServices is the fixed set of AWS service endpoints the S3 |
| 18 | +// backend (and terraform_remote_state) consults. Unlike the AWS provider — whose |
| 19 | +// endpoint keys are discovered from the provider schema — the backend exposes a |
| 20 | +// small, fixed surface. Only s3/dynamodb are exercised under lstk's forced mock |
| 21 | +// credentials; iam/sts/sso are emitted defensively so a stray credential path |
| 22 | +// can never reach real AWS. `sso` has no legacy flat key and is omitted there. |
| 23 | +var backendEndpointServices = []string{"s3", "dynamodb", "iam", "sts", "sso"} |
| 24 | + |
| 25 | +// managedBackendKeys are arguments lstk always sets itself on the generated |
| 26 | +// backend/remote-state blocks (credentials, skip flags, endpoints, path-style, |
| 27 | +// region). Any user-provided value for these is dropped from the copied-forward |
| 28 | +// config so lstk's LocalStack-targeting values win and real AWS is never |
| 29 | +// contacted — mirroring the self-contained provider override. |
| 30 | +// region is intentionally not managed: the user's region (the location of their |
| 31 | +// state bucket) is preserved verbatim via writeUserAttrs, and a resolved |
| 32 | +// fallback is injected only when the user omitted it. |
| 33 | +var managedBackendKeys = map[string]bool{ |
| 34 | + "access_key": true, |
| 35 | + "secret_key": true, |
| 36 | + "skip_credentials_validation": true, |
| 37 | + "skip_metadata_api_check": true, |
| 38 | + "skip_region_validation": true, |
| 39 | + "skip_requesting_account_id": true, |
| 40 | + "use_path_style": true, |
| 41 | + "force_path_style": true, |
| 42 | + "endpoint": true, |
| 43 | + "endpoints": true, |
| 44 | + "dynamodb_endpoint": true, |
| 45 | + "iam_endpoint": true, |
| 46 | + "sts_endpoint": true, |
| 47 | +} |
| 48 | + |
| 49 | +// s3Backend holds the parsed `terraform { backend "s3" {} }` configuration. attrs |
| 50 | +// retains every literal argument the user wrote (for full reproduction in the |
| 51 | +// override, since Terraform replaces — not merges — backend blocks). The named |
| 52 | +// fields are conveniences extracted from attrs for provisioning decisions. |
| 53 | +type s3Backend struct { |
| 54 | + attrs map[string]cty.Value |
| 55 | + bucket string |
| 56 | + region string |
| 57 | + dynamoDBTable string |
| 58 | +} |
| 59 | + |
| 60 | +var terraformBlockSchema = &hcl.BodySchema{ |
| 61 | + Blocks: []hcl.BlockHeaderSchema{{Type: "terraform"}}, |
| 62 | +} |
| 63 | + |
| 64 | +var backendBlockSchema = &hcl.BodySchema{ |
| 65 | + Blocks: []hcl.BlockHeaderSchema{{Type: "backend", LabelNames: []string{"type"}}}, |
| 66 | +} |
| 67 | + |
| 68 | +// HasS3Backend reports whether the working directory declares a |
| 69 | +// `terraform { backend "s3" {} }` block. It is what flips `init` onto the |
| 70 | +// proxied path (require emulator, generate backend override, provision). |
| 71 | +func HasS3Backend(workdir string, logger log.Logger) bool { |
| 72 | + return parseS3Backend(workdir, logger) != nil |
| 73 | +} |
| 74 | + |
| 75 | +// parseS3Backend scans the working-directory *.tf files for a |
| 76 | +// `terraform { backend "s3" {} }` block and returns its parsed form, or nil if |
| 77 | +// no S3 backend is declared. Backend blocks are only valid in the root module, |
| 78 | +// but the recursion (matching provider discovery) is harmless. The first S3 |
| 79 | +// backend found wins. |
| 80 | +func parseS3Backend(workdir string, logger log.Logger) *s3Backend { |
| 81 | + var found *s3Backend |
| 82 | + walkTFFiles(workdir, logger, func(file *hcl.File, path string) bool { |
| 83 | + content, _, _ := file.Body.PartialContent(terraformBlockSchema) |
| 84 | + for _, tfBlock := range content.Blocks { |
| 85 | + inner, _, _ := tfBlock.Body.PartialContent(backendBlockSchema) |
| 86 | + for _, backendBlock := range inner.Blocks { |
| 87 | + if len(backendBlock.Labels) == 0 || backendBlock.Labels[0] != "s3" { |
| 88 | + continue |
| 89 | + } |
| 90 | + found = backendFromBody(backendBlock.Body, path, logger) |
| 91 | + return false // stop walking |
| 92 | + } |
| 93 | + } |
| 94 | + return true |
| 95 | + }) |
| 96 | + return found |
| 97 | +} |
| 98 | + |
| 99 | +// backendFromBody extracts all literal attributes from an `backend "s3"` body. |
| 100 | +// Non-literal (computed) attributes and nested blocks (e.g. assume_role, which |
| 101 | +// is out of scope) are skipped with a log line; the common attribute-only |
| 102 | +// backend is reproduced faithfully. |
| 103 | +func backendFromBody(body hcl.Body, path string, logger log.Logger) *s3Backend { |
| 104 | + b := &s3Backend{attrs: map[string]cty.Value{}} |
| 105 | + attrs, diags := body.JustAttributes() |
| 106 | + if diags.HasErrors() { |
| 107 | + logger.Info("terraform: backend \"s3\" in %s has nested blocks or unsupported syntax (%v); copying its literal attributes only", path, diags) |
| 108 | + } |
| 109 | + for name, attr := range attrs { |
| 110 | + v, vd := attr.Expr.Value(nil) |
| 111 | + if vd.HasErrors() { |
| 112 | + logger.Info("terraform: skipping non-literal backend attribute %q in %s", name, path) |
| 113 | + continue |
| 114 | + } |
| 115 | + b.attrs[name] = v |
| 116 | + } |
| 117 | + b.bucket = stringAttr(b.attrs, "bucket") |
| 118 | + b.region = stringAttr(b.attrs, "region") |
| 119 | + b.dynamoDBTable = stringAttr(b.attrs, "dynamodb_table") |
| 120 | + return b |
| 121 | +} |
| 122 | + |
| 123 | +func stringAttr(attrs map[string]cty.Value, name string) string { |
| 124 | + if v, ok := attrs[name]; ok && v.Type() == cty.String && !v.IsNull() { |
| 125 | + return v.AsString() |
| 126 | + } |
| 127 | + return "" |
| 128 | +} |
| 129 | + |
| 130 | +// writeBackendBlock renders the full `terraform { backend "s3" {} }` override |
| 131 | +// section: every literal user argument copied forward, plus lstk's mock |
| 132 | +// credentials, region, skip flags, path-style, and the version-aware endpoint |
| 133 | +// set. The full block is reproduced (not a partial overlay) because Terraform |
| 134 | +// replaces backend blocks from override files wholesale. |
| 135 | +func writeBackendBlock(b *strings.Builder, backend *s3Backend, e endpointForm) { |
| 136 | + b.WriteString("terraform {\n") |
| 137 | + b.WriteString(" backend \"s3\" {\n") |
| 138 | + writeUserAttrs(b, " ", backend.attrs) |
| 139 | + writeManagedBackendArgs(b, " ", backend.region != "", e) |
| 140 | + b.WriteString(" }\n") |
| 141 | + b.WriteString("}\n\n") |
| 142 | +} |
| 143 | + |
| 144 | +// writeManagedBackendArgs emits the arguments lstk always sets on a backend or |
| 145 | +// remote-state block: mock credentials, the skip flags that prevent any contact |
| 146 | +// with real AWS, the version-aware path-style flag and endpoint set, and a |
| 147 | +// resolved region only when the user did not specify one (their region is |
| 148 | +// otherwise carried forward verbatim by writeUserAttrs). |
| 149 | +func writeManagedBackendArgs(b *strings.Builder, indent string, hasUserRegion bool, e endpointForm) { |
| 150 | + fmt.Fprintf(b, "%saccess_key = %q\n", indent, e.account) |
| 151 | + fmt.Fprintf(b, "%ssecret_key = \"test\"\n", indent) |
| 152 | + if !hasUserRegion { |
| 153 | + fmt.Fprintf(b, "%sregion = %q\n", indent, e.region) |
| 154 | + } |
| 155 | + fmt.Fprintf(b, "%sskip_credentials_validation = true\n", indent) |
| 156 | + fmt.Fprintf(b, "%sskip_metadata_api_check = true\n", indent) |
| 157 | + fmt.Fprintf(b, "%sskip_region_validation = true\n", indent) |
| 158 | + fmt.Fprintf(b, "%sskip_requesting_account_id = true\n", indent) |
| 159 | + writePathStyle(b, indent, e.legacy, e.pathStyle) |
| 160 | + writeBackendEndpoints(b, indent, e) |
| 161 | +} |
| 162 | + |
| 163 | +// endpointForm carries everything the version-aware endpoint rendering needs. |
| 164 | +type endpointForm struct { |
| 165 | + endpointURL string // bare LocalStack endpoint (used for non-s3 services) |
| 166 | + s3Endpoint string // s3-specific endpoint (carries the s3. host prefix when virtual-host) |
| 167 | + pathStyle bool |
| 168 | + legacy bool // emit flat *_endpoint keys instead of the endpoints {} map |
| 169 | + region string // resolved fallback region |
| 170 | + account string // resolved access_key |
| 171 | +} |
| 172 | + |
| 173 | +// writePathStyle emits the path-style argument under its version-appropriate |
| 174 | +// name: `use_path_style` for the modern backend, `force_path_style` for legacy. |
| 175 | +func writePathStyle(b *strings.Builder, indent string, legacy bool, pathStyle bool) { |
| 176 | + key := "use_path_style" |
| 177 | + if legacy { |
| 178 | + key = "force_path_style" |
| 179 | + } |
| 180 | + fmt.Fprintf(b, "%s%s = %t\n", indent, key, pathStyle) |
| 181 | +} |
| 182 | + |
| 183 | +// writeBackendEndpoints emits either the modern `endpoints = { ... }` map or the |
| 184 | +// legacy flat `*_endpoint` keys (no sso) at the given indent. The map form is |
| 185 | +// identical in shape for the backend block and the remote-state `config` map. |
| 186 | +func writeBackendEndpoints(b *strings.Builder, indent string, e endpointForm) { |
| 187 | + endpointFor := func(service string) string { |
| 188 | + if service == "s3" { |
| 189 | + return e.s3Endpoint |
| 190 | + } |
| 191 | + return e.endpointURL |
| 192 | + } |
| 193 | + if e.legacy { |
| 194 | + legacy := []struct{ key, service string }{ |
| 195 | + {"endpoint", "s3"}, |
| 196 | + {"dynamodb_endpoint", "dynamodb"}, |
| 197 | + {"iam_endpoint", "iam"}, |
| 198 | + {"sts_endpoint", "sts"}, |
| 199 | + } |
| 200 | + for _, l := range legacy { |
| 201 | + fmt.Fprintf(b, "%s%s = %q\n", indent, l.key, endpointFor(l.service)) |
| 202 | + } |
| 203 | + return |
| 204 | + } |
| 205 | + fmt.Fprintf(b, "%sendpoints = {\n", indent) |
| 206 | + for _, service := range backendEndpointServices { |
| 207 | + fmt.Fprintf(b, "%s %s = %q\n", indent, service, endpointFor(service)) |
| 208 | + } |
| 209 | + fmt.Fprintf(b, "%s}\n", indent) |
| 210 | +} |
| 211 | + |
| 212 | +// writeUserAttrs renders the user's literal backend/remote-state arguments |
| 213 | +// (those lstk does not manage itself), sorted for deterministic output. |
| 214 | +func writeUserAttrs(b *strings.Builder, indent string, attrs map[string]cty.Value) { |
| 215 | + names := make([]string, 0, len(attrs)) |
| 216 | + for name := range attrs { |
| 217 | + if managedBackendKeys[name] { |
| 218 | + continue |
| 219 | + } |
| 220 | + names = append(names, name) |
| 221 | + } |
| 222 | + sort.Strings(names) |
| 223 | + for _, name := range names { |
| 224 | + fmt.Fprintf(b, "%s%s = %s\n", indent, name, renderCtyValue(attrs[name])) |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +// renderCtyValue renders a literal HCL value (string, bool, number) as it should |
| 229 | +// appear in the generated config. Unsupported/complex values fall back to a |
| 230 | +// quoted string of their GoString so output stays valid HCL rather than |
| 231 | +// panicking; such values are rare in backend/remote-state config. |
| 232 | +func renderCtyValue(v cty.Value) string { |
| 233 | + if v.IsNull() { |
| 234 | + return "null" |
| 235 | + } |
| 236 | + switch v.Type() { |
| 237 | + case cty.String: |
| 238 | + return fmt.Sprintf("%q", v.AsString()) |
| 239 | + case cty.Bool: |
| 240 | + if v.True() { |
| 241 | + return "true" |
| 242 | + } |
| 243 | + return "false" |
| 244 | + case cty.Number: |
| 245 | + return v.AsBigFloat().Text('f', -1) |
| 246 | + default: |
| 247 | + return fmt.Sprintf("%q", v.GoString()) |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +// walkTFFiles parses each *.tf file under workdir and invokes fn with the parsed |
| 252 | +// file. It mirrors discoverAWSAliases' traversal rules: it recurses into |
| 253 | +// sub-directories, skips hidden directories such as .terraform and .git, skips |
| 254 | +// the generated override file, and skips files that fail to parse (logged). fn |
| 255 | +// returns false to stop the walk early. |
| 256 | +func walkTFFiles(workdir string, logger log.Logger, fn func(file *hcl.File, path string) bool) { |
| 257 | + parser := hclparse.NewParser() |
| 258 | + overrideName := overrideFileName() |
| 259 | + stop := false |
| 260 | + _ = filepath.WalkDir(workdir, func(path string, d fs.DirEntry, err error) error { |
| 261 | + if err != nil || stop { |
| 262 | + if stop { |
| 263 | + return filepath.SkipAll |
| 264 | + } |
| 265 | + return nil |
| 266 | + } |
| 267 | + if d.IsDir() { |
| 268 | + if path != workdir && strings.HasPrefix(d.Name(), ".") { |
| 269 | + return filepath.SkipDir |
| 270 | + } |
| 271 | + return nil |
| 272 | + } |
| 273 | + if !strings.HasSuffix(d.Name(), ".tf") || d.Name() == overrideName { |
| 274 | + return nil |
| 275 | + } |
| 276 | + file, diags := parser.ParseHCLFile(path) |
| 277 | + if diags.HasErrors() { |
| 278 | + logger.Info("terraform: could not parse %s (%v); skipping it", path, diags) |
| 279 | + return nil |
| 280 | + } |
| 281 | + if !fn(file, path) { |
| 282 | + stop = true |
| 283 | + return filepath.SkipAll |
| 284 | + } |
| 285 | + return nil |
| 286 | + }) |
| 287 | +} |
0 commit comments