Skip to content

Commit 9c2dc97

Browse files
Redirect Terraform S3 backend and remote-state to LocalStack (#328)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1ed2d11 commit 9c2dc97

24 files changed

Lines changed: 2040 additions & 78 deletions

File tree

cmd/terraform.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,21 @@ Examples:
7575
return emitValidationError(sink, err)
7676
}
7777

78-
// Unproxied subcommands (fmt/validate/version) never touch the
79-
// endpoint, so they run without requiring a running emulator.
80-
if tfcli.IsUnproxied(tfArgs) {
78+
workdir, err := os.Getwd()
79+
if err != nil {
80+
return fmt.Errorf("resolving working directory: %w", err)
81+
}
82+
// -chdir switches terraform into another directory, so the S3-backend
83+
// detection that decides whether an emulator is required must inspect
84+
// that directory too (matching the resolution Run does internally).
85+
if chdir != "" {
86+
workdir = tfcli.ResolveChdir(workdir, chdir)
87+
}
88+
89+
// Commands that don't need the emulator (fmt/validate/version, and
90+
// init when no S3 backend is declared) run without bringing up or
91+
// requiring a running emulator.
92+
if !tfcli.RequiresEmulator(tfArgs, workdir, logger) {
8193
return tfcli.Run(cmd.Context(), "", region, account, chdir, sink, logger, tfArgs)
8294
}
8395

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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

Comments
 (0)