Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 405 additions & 0 deletions override.go

Large diffs are not rendered by default.

822 changes: 822 additions & 0 deletions override_test.go

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions overridefs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package preview

import (
"bytes"
"fmt"
"io/fs"
"path"
"time"
)

var (
_ fs.FS = (*overrideFS)(nil)
_ fs.File = (*memFile)(nil)
_ fs.FileInfo = (*memFileInfo)(nil)
_ fs.ReadDirFile = (*filteredDir)(nil)
)

// overrideFS wraps a base fs.FS, serving modified content for merged files
// and hiding consumed override files.
type overrideFS struct {
base fs.FS
replaced map[string][]byte // path -> merged content
hidden map[string]bool // paths to exclude
}

func (o *overrideFS) Open(name string) (fs.File, error) {
if o.hidden[name] {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}

if content, ok := o.replaced[name]; ok {
return &memFile{
name: path.Base(name), // Stat().Name() returns base name
content: bytes.NewReader(content),
size: int64(len(content)),
}, nil
}

f, err := o.base.Open(name)
if err != nil {
return nil, err
}

// If this is a directory, wrap it to filter hidden entries.
info, err := f.Stat()
if err != nil {
f.Close()
return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("stat: %w", err)}
}
if info.IsDir() {
if rdf, ok := f.(fs.ReadDirFile); ok {
return &filteredDir{ReadDirFile: rdf, hidden: o.hidden, dirPath: name}, nil
}
}

return f, nil
}

// memFile is an in-memory file that implements fs.File.
type memFile struct {
name string
content *bytes.Reader
size int64
}

func (f *memFile) Stat() (fs.FileInfo, error) {
return &memFileInfo{name: f.name, size: f.size}, nil
}
func (f *memFile) Read(b []byte) (int, error) { return f.content.Read(b) }
func (f *memFile) Close() error { return nil }

// memFileInfo implements fs.FileInfo for in-memory files.
type memFileInfo struct {
name string
size int64
}

func (fi *memFileInfo) Name() string { return fi.name }
func (fi *memFileInfo) Size() int64 { return fi.size }
func (fi *memFileInfo) Mode() fs.FileMode { return 0o444 }
func (fi *memFileInfo) ModTime() time.Time { return time.Time{} }
func (fi *memFileInfo) IsDir() bool { return false }
func (fi *memFileInfo) Sys() any { return nil }

// filteredDir wraps a ReadDirFile to filter out hidden entries.
type filteredDir struct {
fs.ReadDirFile
hidden map[string]bool
dirPath string
}

func (d *filteredDir) ReadDir(n int) ([]fs.DirEntry, error) {
entries, err := d.ReadDirFile.ReadDir(n)
filtered := d.filter(entries)

// If n > 0, an empty slice must have a non-nil error per the
// fs.ReadDirFile contract. Keep reading until we have results
// or the underlying reader signals EOF/error.
for n > 0 && len(filtered) == 0 && err == nil {
entries, err = d.ReadDirFile.ReadDir(n)
filtered = d.filter(entries)
}

return filtered, err
}

func (d *filteredDir) filter(entries []fs.DirEntry) []fs.DirEntry {
filtered := make([]fs.DirEntry, 0, len(entries))
for _, entry := range entries {
if !d.hidden[path.Join(d.dirPath, entry.Name())] {
filtered = append(filtered, entry)
}
}
return filtered
}
25 changes: 24 additions & 1 deletion preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
}
}()

// Merge override files into primary files before parsing, so
// Trivy sees post-merge content with no duplicate blocks. This
// replicates Terraform's override file semantics.
//
// TODO: It'd be nice if Trivy did this for us.
mergedDir, overrideDiags, err := mergeOverrides(dir)
// Override merging is best-effort; downgrade all override error
// diagnostics to warnings so they never abort the preview.
for _, d := range overrideDiags {
if d.Severity == hcl.DiagError {
d.Severity = hcl.DiagWarning
}
}
if err != nil {
overrideDiags = overrideDiags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Override file merging disabled due to an error",
Detail: err.Error(),
})
} else {
dir = mergedDir
}

varFiles, err := tfvars.TFVarFiles("", dir)
if err != nil {
return nil, hcl.Diagnostics{
Expand Down Expand Up @@ -267,7 +290,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
Presets: preValidPresets,
Files: p.Files(),
Variables: vars,
}, diags.Extend(rpDiags).Extend(tagDiags)
}, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags)
}

func (i Input) RichParameterValue(key string) (string, bool) {
Expand Down
21 changes: 21 additions & 0 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,27 @@ func Test_Extract(t *testing.T) {
prebuildCount(1),
},
},
{
name: "override",
dir: "override",
params: map[string]assertParam{
"region": ap().value("ap").def("ap").optVals("ap"),
"size": ap().value("50").def("50").optVals("10", "50", "100"),
"static_to_dynamic": ap().value("a").def("a").optVals("a", "b", "c"),
"dynamic_to_static": ap().value("x").def("x").optVals("x", "y"),
},
presets: map[string]assertPreset{
"dev-override": aPre().value("region", "ap"),
},
expTags: map[string]string{
"env": "production",
"team": "mango",
},
variables: map[string]assertVariable{
"string_to_number": av().def(cty.NumberIntVal(40)).typeEq(cty.Number),
"zones": av().def(cty.SetVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b"), cty.StringVal("c")})).typeEq(cty.Set(cty.String)),
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
Expand Down
80 changes: 80 additions & 0 deletions testdata/override/a_override.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
terraform {
# An override.
required_version = ">= 1.1"
}

# Override region's default (will be overridden again by override.tf).
data "coder_parameter" "region" {
default = "eu"
}

# Override size's options.
data "coder_parameter" "size" {
option {
name = "10GB"
value = 10
}
option {
name = "40GB"
value = 40
}
}

locals {
# Override the local value in main.tf.
default_size = 50
}

# Override size again in the same file — adds 50GB option that makes the
# overridden default valid.
data "coder_parameter" "size" {
option {
name = "10GB"
value = 10
}
option {
name = "50GB"
value = 50
}
option {
name = "100GB"
value = 100
}
}

# Override tags.
data "coder_workspace_tags" "tags" {
tags = {
"env" = "production"
"team" = "mango"
}
}

# Override static options with dynamic.
data "coder_parameter" "static_to_dynamic" {
dynamic "option" {
for_each = var.zones
content {
name = option.value
value = option.value
}
}
}

# Override dynamic options with static.
data "coder_parameter" "dynamic_to_static" {
option {
name = "X"
value = "x"
}
option {
name = "Y"
value = "y"
}
}

# Override variable.
variable "string_to_number" {
type = number
default = 40
}
108 changes: 108 additions & 0 deletions testdata/override/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = "2.4.0-pre0"
}
}
}

terraform {
required_version = ">= 1.0"
}

data "coder_parameter" "region" {
name = "region"
type = "string"
default = "us"

option {
name = "US"
value = "us"
}
option {
name = "EU"
value = "eu"
}
}

locals {
# Just so that we have >1 locals blocks.
foo = "bar"
}

locals {
# Will be overridden in a_override.tf
default_size = 70
}

data "coder_parameter" "size" {
name = "size"
type = "number"
# Invalid value should become valid once the options and locals are overridden.
default = local.default_size

option {
name = "10GB"
value = 10
}
option {
name = "20GB"
value = 20
}
}

data "coder_workspace_preset" "dev" {
name = "dev"
parameters = {
region = "us"
}
}

data "coder_workspace_tags" "tags" {
tags = {
"env" = "staging"
}
}

variable "zones" {
type = set(string)
default = ["a", "b", "c"]
}

# Static options, will be overridden by dynamic "option" in a_override.
data "coder_parameter" "static_to_dynamic" {
name = "static_to_dynamic"
type = "string"
default = "a"

option {
name = "A"
value = "a"
}
option {
name = "B"
value = "b"
}
}

# Dynamic options, will be overridden by static option blocks in a_override.
data "coder_parameter" "dynamic_to_static" {
name = "dynamic_to_static"
type = "string"
# Invalid value should become valid once the options are overridden.
default = "x"

dynamic "option" {
for_each = var.zones
content {
name = option.value
value = option.value
}
}
}

variable "string_to_number" {
type = string
default = "foo"
}
17 changes: 17 additions & 0 deletions testdata/override/override.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Override region's default again (sequential: us -> eu -> ap).
data "coder_parameter" "region" {
default = "ap"

option {
name = "AP"
value = "ap"
}
}

# Override preset.
data "coder_workspace_preset" "dev" {
name = "dev-override"
parameters = {
region = "ap"
}
}
Loading