Skip to content

Commit 2b53f0c

Browse files
authored
feat: support Terraform override files in template preview (#196)
Implement Terraform's override file semantics (override.tf, *_override.tf) by merging override blocks into primary files before evaluation. Related to: coder/coder#21991
1 parent c07d684 commit 2b53f0c

File tree

8 files changed

+1516
-1
lines changed

8 files changed

+1516
-1
lines changed

override.go

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
package preview
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"path"
8+
"strings"
9+
10+
"github.com/hashicorp/hcl/v2"
11+
"github.com/hashicorp/hcl/v2/hclwrite"
12+
)
13+
14+
// primaryState tracks a parsed primary .tf file during override
15+
// merging.
16+
type primaryState struct {
17+
path string
18+
file *hclwrite.File
19+
modified bool
20+
}
21+
22+
// mergeOverrides scans the filesystem for .tf Terraform override files
23+
// and returns a new FS where override content has been merged into primary
24+
// files using Terraform's override semantics.
25+
// If no override files are found, the original FS is returned unchanged.
26+
// If an error is encountered, diagnostics are returned in addition to a
27+
// non-nil error.
28+
// Warning diagnostics may also be returned on success (e.g. for skipped
29+
// .tf.json files).
30+
//
31+
// Override files are identified by Terraform's naming convention:
32+
// "override.tf", "*_override.tf", and their .tf.json variants. We only support
33+
// .tf files; .tf.json files get a diagnostic warning and are excluded from
34+
// override merging.
35+
//
36+
// Ref: https://developer.hashicorp.com/terraform/language/files/override
37+
func mergeOverrides(origFS fs.FS) (fs.FS, hcl.Diagnostics, error) {
38+
// Group files by directory, separating primary from override files.
39+
// Walk the entire tree, not just the root directory, because Trivy's
40+
// EvaluateAll processes all modules, so we need to pre-merge overrides at
41+
// every level before Trivy sees the FS.
42+
type dirFiles struct {
43+
primaries []string
44+
overrides []string
45+
// Used to generate warnings at merge stage.
46+
jsonPrimaries []string
47+
}
48+
dirs := make(map[string]*dirFiles)
49+
50+
var warnings hcl.Diagnostics
51+
52+
err := fs.WalkDir(origFS, ".", func(p string, d fs.DirEntry, err error) error {
53+
if err != nil {
54+
return err
55+
}
56+
// Skip dirs; we deal with them by acting on their files.
57+
if d.IsDir() {
58+
return nil
59+
}
60+
61+
ext := tfFileExt(d.Name())
62+
if ext == "" {
63+
return nil
64+
}
65+
66+
dir := path.Dir(p)
67+
if dirs[dir] == nil {
68+
dirs[dir] = &dirFiles{}
69+
}
70+
71+
// We don't support parsing .tf.json files. They remain in the
72+
// FS for Trivy to parse directly but never participate in
73+
// override merging.
74+
if ext == ".tf.json" {
75+
if isOverrideFile(d.Name()) {
76+
warnings = warnings.Append(&hcl.Diagnostic{
77+
Severity: hcl.DiagWarning,
78+
Summary: "Override file uses unsupported .tf.json format",
79+
Detail: fmt.Sprintf("%s skipped for override merging", p),
80+
})
81+
} else {
82+
// Save the name of the .tf.json primary so we issue a
83+
// warning only if we do merging for the dir (less noise).
84+
dirs[dir].jsonPrimaries = append(dirs[dir].jsonPrimaries, p)
85+
}
86+
return nil
87+
}
88+
89+
if isOverrideFile(d.Name()) {
90+
dirs[dir].overrides = append(dirs[dir].overrides, p)
91+
} else {
92+
dirs[dir].primaries = append(dirs[dir].primaries, p)
93+
}
94+
return nil
95+
})
96+
if err != nil {
97+
return nil, warnings, fmt.Errorf("error reading template files: %w", err)
98+
}
99+
100+
hasOverrides := false
101+
for _, dir := range dirs {
102+
if len(dir.overrides) > 0 {
103+
hasOverrides = true
104+
break
105+
}
106+
}
107+
if !hasOverrides {
108+
// We are a no-op if there are no supported override files at
109+
// all. Include warnings so callers know about ignored
110+
// .tf.json files.
111+
return origFS, warnings, nil
112+
}
113+
114+
replaced := make(map[string][]byte)
115+
hidden := make(map[string]bool)
116+
117+
for _, dir := range dirs {
118+
if len(dir.overrides) == 0 {
119+
continue
120+
}
121+
122+
for _, jp := range dir.jsonPrimaries {
123+
warnings = warnings.Append(&hcl.Diagnostic{
124+
Severity: hcl.DiagWarning,
125+
Summary: "Primary file uses .tf.json format",
126+
Detail: fmt.Sprintf("%s skipped for override merging", jp),
127+
})
128+
}
129+
130+
// Parse all primary files upfront so override files can be applied
131+
// sequentially, each merging into the already-merged result.
132+
primaries := make([]*primaryState, 0, len(dir.primaries))
133+
for _, path := range dir.primaries {
134+
content, err := fs.ReadFile(origFS, path)
135+
if err != nil {
136+
return nil, warnings, fmt.Errorf("error reading file %s: %w", path, err)
137+
}
138+
f, diags := hclwrite.ParseConfig(content, path, hcl.Pos{Line: 1, Column: 1})
139+
if diags.HasErrors() {
140+
return nil, warnings.Extend(diags), errors.New("error parsing file")
141+
}
142+
primaries = append(primaries, &primaryState{path: path, file: f})
143+
}
144+
145+
// Process each override file sequentially. If multiple override files
146+
// define the same block, each merges into the already-merged primary,
147+
// matching Terraform's behavior.
148+
for _, path := range dir.overrides {
149+
content, err := fs.ReadFile(origFS, path)
150+
if err != nil {
151+
return nil, warnings, fmt.Errorf("error reading file %s: %w", path, err)
152+
}
153+
154+
f, diags := hclwrite.ParseConfig(content, path, hcl.Pos{Line: 1, Column: 1})
155+
if diags.HasErrors() {
156+
return nil, warnings.Extend(diags), errors.New("error parsing file")
157+
}
158+
159+
for _, oblock := range f.Body().Blocks() {
160+
// "locals" blocks are label-less and Terraform merges
161+
// them at the individual attribute level, not at the
162+
// block level.
163+
if oblock.Type() == "locals" {
164+
diags := mergeLocalsBlock(primaries, oblock, path)
165+
if diags.HasErrors() {
166+
return nil, warnings.Extend(diags), errors.New("error merging 'locals' block")
167+
}
168+
continue
169+
}
170+
// 'terraform' block override semantics are too nuanced
171+
// to implement right now. Hopefully they are rare in
172+
// practice.
173+
if oblock.Type() == "terraform" {
174+
warnings = warnings.Append(&hcl.Diagnostic{
175+
Severity: hcl.DiagWarning,
176+
Summary: "Override file has unsupported 'terraform' block",
177+
Detail: fmt.Sprintf("'terraform' block in %s skipped for override merging", path),
178+
})
179+
continue
180+
}
181+
182+
key := blockKey(oblock.Type(), oblock.Labels())
183+
matched := false
184+
for _, primary := range primaries {
185+
for _, pblock := range primary.file.Body().Blocks() {
186+
if blockKey(pblock.Type(), pblock.Labels()) == key {
187+
mergeBlock(pblock, oblock)
188+
primary.modified = true
189+
matched = true
190+
break
191+
}
192+
}
193+
if matched {
194+
break
195+
}
196+
}
197+
if !matched {
198+
// Terraform requires every override block to have a corresponding
199+
// primary block — override files can only modify, not create.
200+
return nil, warnings, fmt.Errorf("override block %q in %s has no matching block in a primary file", key, path)
201+
}
202+
}
203+
204+
hidden[path] = true
205+
}
206+
207+
// Collect modified primary files.
208+
for _, p := range primaries {
209+
if p.modified {
210+
replaced[p.path] = p.file.Bytes()
211+
}
212+
}
213+
}
214+
215+
return &overrideFS{
216+
base: origFS,
217+
replaced: replaced,
218+
hidden: hidden,
219+
}, warnings, nil
220+
}
221+
222+
// mergeBlock applies override attributes and child blocks to a primary block
223+
// using Terraform's prepareContent semantics.
224+
//
225+
// - Attributes: each override attribute replaces the corresponding primary
226+
// attribute, or is inserted if it does not exist in the primary block.
227+
//
228+
// - Child blocks: if override has any block of type X (including dynamic "X"),
229+
// all blocks of type X and dynamic "X" are removed from primary. Then all
230+
// override child blocks are appended — both replacing suppressed types and
231+
// introducing entirely new block types not present in the primary.
232+
//
233+
// Ref: https://github.com/hashicorp/terraform/blob/7960f60d2147d43f5cf675a898438f6a6693da1b/internal/configs/module_merge_body.go#L76-L121
234+
func mergeBlock(primary, override *hclwrite.Block) {
235+
// hclwrite preserves the formatting of the original block. If the
236+
// primary body is empty and inline (e.g. `variable "x" {}`),
237+
// inserting attributes places them on the same line as the
238+
// opening brace, which HCL rejects. A newline defensively forces
239+
// multi-line formatting.
240+
if len(primary.Body().Attributes()) == 0 && len(primary.Body().Blocks()) == 0 {
241+
primary.Body().AppendNewline()
242+
}
243+
244+
// Merge attributes: override clobbers base.
245+
for name, attr := range override.Body().Attributes() {
246+
primary.Body().SetAttributeRaw(name, attr.Expr().BuildTokens(nil))
247+
}
248+
249+
// Merge blocks: determine which child (nested) block types are
250+
// overridden.
251+
overriddenBlockTypes := make(map[string]bool)
252+
for _, child := range override.Body().Blocks() {
253+
// E.g. `dynamic "option" {...}`
254+
if child.Type() == "dynamic" && len(child.Labels()) > 0 {
255+
overriddenBlockTypes[child.Labels()[0]] = true
256+
} else {
257+
overriddenBlockTypes[child.Type()] = true
258+
}
259+
}
260+
261+
if len(overriddenBlockTypes) == 0 {
262+
return
263+
}
264+
265+
// Remove overridden block types from primary.
266+
// Collect blocks to remove first to avoid modifying during iteration.
267+
var toRemove []*hclwrite.Block
268+
for _, child := range primary.Body().Blocks() {
269+
shouldRemove := false
270+
if child.Type() == "dynamic" && len(child.Labels()) > 0 {
271+
shouldRemove = overriddenBlockTypes[child.Labels()[0]]
272+
} else {
273+
shouldRemove = overriddenBlockTypes[child.Type()]
274+
}
275+
if shouldRemove {
276+
toRemove = append(toRemove, child)
277+
}
278+
}
279+
for _, block := range toRemove {
280+
primary.Body().RemoveBlock(block)
281+
}
282+
283+
// Append all override child blocks.
284+
for _, child := range override.Body().Blocks() {
285+
primary.Body().AppendBlock(child)
286+
}
287+
}
288+
289+
// mergeLocalsBlock merges an override locals block into the primaries
290+
// at the individual attribute level. Each override attribute replaces
291+
// the matching attribute in whichever primary locals block defines
292+
// it. Attributes not found in any primary block produce an error,
293+
// matching Terraform's "Missing base local value definition to
294+
// override" behavior.
295+
// Ref: https://github.com/hashicorp/terraform/blob/7960f60d2147d43f5cf675a898438f6a6693da1b/internal/configs/module.go#L772-L784
296+
func mergeLocalsBlock(primaries []*primaryState, override *hclwrite.Block, overridePath string) hcl.Diagnostics {
297+
var diags hcl.Diagnostics
298+
for name, attr := range override.Body().Attributes() {
299+
found := false
300+
for _, primary := range primaries {
301+
for _, pblock := range primary.file.Body().Blocks() {
302+
if pblock.Type() != "locals" {
303+
continue
304+
}
305+
// NOTE: We don't insert new attrs into an empty body.
306+
// If that ever changes, empty inline blocks (e.g.
307+
// `locals {}`) would need the same AppendNewline fix
308+
// as mergeBlock to avoid same line usage that breaks
309+
// HCL.
310+
if _, exists := pblock.Body().Attributes()[name]; exists {
311+
pblock.Body().SetAttributeRaw(name, attr.Expr().BuildTokens(nil))
312+
primary.modified = true
313+
found = true
314+
break
315+
}
316+
}
317+
if found {
318+
break
319+
}
320+
}
321+
if !found {
322+
diags = diags.Append(&hcl.Diagnostic{
323+
Severity: hcl.DiagError,
324+
Summary: "Missing base local value definition to override",
325+
Detail: fmt.Sprintf("Local %q in %s has no base definition to override", name, overridePath),
326+
})
327+
}
328+
}
329+
return diags
330+
}
331+
332+
// isOverrideFile returns true if the filename matches Terraform's override
333+
// file naming convention: "override.tf", "*_override.tf", and .tf.json variants.
334+
//
335+
// Ref: https://github.com/hashicorp/terraform/blob/7960f60d2147d43f5cf675a898438f6a6693da1b/internal/configs/parser_file_matcher.go#L161-L170
336+
func isOverrideFile(filename string) bool {
337+
name := path.Base(filename)
338+
ext := tfFileExt(name)
339+
if ext == "" {
340+
return false
341+
}
342+
baseName := name[:len(name)-len(ext)]
343+
return baseName == "override" || strings.HasSuffix(baseName, "_override")
344+
}
345+
346+
// tfFileExt returns the Terraform file extension (".tf" or ".tf.json") if
347+
// present, or "" otherwise.
348+
func tfFileExt(name string) string {
349+
if strings.HasSuffix(name, ".tf.json") {
350+
return ".tf.json"
351+
}
352+
if strings.HasSuffix(name, ".tf") {
353+
return ".tf"
354+
}
355+
return ""
356+
}
357+
358+
// blockKey returns a string that uniquely identifies a block for override
359+
// matching purposes. Two blocks with the same key represent the same logical
360+
// entity (one primary, one override).
361+
func blockKey(blockType string, labels []string) string {
362+
if len(labels) == 0 {
363+
return blockType
364+
}
365+
return blockType + "." + strings.Join(labels, ".")
366+
}

0 commit comments

Comments
 (0)