feat(provisioner/terraform/tfparse): implement WorkspaceTagDefaultsFromFile (#15236)

Relates to https://github.com/coder/coder/issues/15087 and
https://github.com/coder/coder/issues/15427

Adds functionality to `provisioner/terraform/tfparse` to extract the
default values for a `coder_workspace_tags` data source from a given
file.
This commit is contained in:
Cian Johnston
2024-11-14 12:24:48 +00:00
committed by GitHub
parent 08216aaad6
commit bebc38ee94
4 changed files with 1029 additions and 185 deletions
+3 -3
View File
@@ -21,17 +21,17 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
defer span.End()
// Load the module and print any parse errors.
module, diags := tfconfig.LoadModule(sess.WorkDirectory)
parser, diags := tfparse.New(sess.WorkDirectory, tfparse.WithLogger(s.logger.Named("tfparse")))
if diags.HasErrors() {
return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags))
}
workspaceTags, err := tfparse.WorkspaceTags(ctx, s.logger, module)
workspaceTags, err := parser.WorkspaceTags(ctx)
if err != nil {
return provisionersdk.ParseErrorf("can't load workspace tags: %v", err)
}
templateVariables, err := tfparse.LoadTerraformVariables(module)
templateVariables, err := parser.TemplateVariables()
if err != nil {
return provisionersdk.ParseErrorf("can't load template variables: %v", err)
}
-182
View File
@@ -1,182 +0,0 @@
package tfparse
import (
"context"
"encoding/json"
"os"
"slices"
"sort"
"strings"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"golang.org/x/xerrors"
"cdr.dev/slog"
)
// WorkspaceTags extracts tags from coder_workspace_tags data sources defined in module.
func WorkspaceTags(ctx context.Context, logger slog.Logger, module *tfconfig.Module) (map[string]string, error) {
workspaceTags := map[string]string{}
for _, dataResource := range module.DataResources {
if dataResource.Type != "coder_workspace_tags" {
logger.Debug(ctx, "skip resource as it is not a coder_workspace_tags", "resource_name", dataResource.Name, "resource_type", dataResource.Type)
continue
}
var file *hcl.File
var diags hcl.Diagnostics
parser := hclparse.NewParser()
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
logger.Debug(ctx, "only .tf files can be parsed", "filename", dataResource.Pos.Filename)
continue
}
// We know in which HCL file is the data resource defined.
file, diags = parser.ParseHCLFile(dataResource.Pos.Filename)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
// Parse root to find "coder_workspace_tags".
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
// Iterate over blocks to locate the exact "coder_workspace_tags" data resource.
for _, block := range content.Blocks {
if !slices.Equal(block.Labels, []string{"coder_workspace_tags", dataResource.Name}) {
continue
}
// Parse "coder_workspace_tags" to find all key-value tags.
resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error())
}
if resContent == nil {
continue // workspace tags are not present
}
if _, ok := resContent.Attributes["tags"]; !ok {
return nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`)
}
expr := resContent.Attributes["tags"].Expr
tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr)
if !ok {
return nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`)
}
// Parse key-value entries in "coder_workspace_tags"
for _, tagItem := range tagsExpr.Items {
key, err := previewFileContent(tagItem.KeyExpr.Range())
if err != nil {
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
key = strings.Trim(key, `"`)
value, err := previewFileContent(tagItem.ValueExpr.Range())
if err != nil {
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
logger.Info(ctx, "workspace tag found", "key", key, "value", value)
if _, ok := workspaceTags[key]; ok {
return nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key)
}
workspaceTags[key] = value
}
}
}
return workspaceTags, nil
}
var rootTemplateSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "data",
LabelNames: []string{"type", "name"},
},
},
}
var coderWorkspaceTagsSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "tags",
},
},
}
func previewFileContent(fileRange hcl.Range) (string, error) {
body, err := os.ReadFile(fileRange.Filename)
if err != nil {
return "", err
}
return string(fileRange.SliceBytes(body)), nil
}
// LoadTerraformVariables extracts all Terraform variables from module and converts them
// to template variables. The variables are sorted by source position.
func LoadTerraformVariables(module *tfconfig.Module) ([]*proto.TemplateVariable, error) {
// Sort variables by (filename, line) to make the ordering consistent
variables := make([]*tfconfig.Variable, 0, len(module.Variables))
for _, v := range module.Variables {
variables = append(variables, v)
}
sort.Slice(variables, func(i, j int) bool {
return compareSourcePos(variables[i].Pos, variables[j].Pos)
})
var templateVariables []*proto.TemplateVariable
for _, v := range variables {
mv, err := convertTerraformVariable(v)
if err != nil {
return nil, err
}
templateVariables = append(templateVariables, mv)
}
return templateVariables, nil
}
// convertTerraformVariable converts a Terraform variable to a template-wide variable, processed by Coder.
func convertTerraformVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
var defaultData string
if variable.Default != nil {
var valid bool
defaultData, valid = variable.Default.(string)
if !valid {
defaultDataRaw, err := json.Marshal(variable.Default)
if err != nil {
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
}
defaultData = string(defaultDataRaw)
}
}
return &proto.TemplateVariable{
Name: variable.Name,
Description: variable.Description,
Type: variable.Type,
DefaultValue: defaultData,
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
Required: variable.Default == nil,
Sensitive: variable.Sensitive,
}, nil
}
func compareSourcePos(x, y tfconfig.SourcePos) bool {
if x.Filename != y.Filename {
return x.Filename < y.Filename
}
return x.Line < y.Line
}
+526
View File
@@ -0,0 +1,526 @@
package tfparse
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"os"
"slices"
"sort"
"strconv"
"strings"
"github.com/coder/coder/v2/archive"
"github.com/coder/coder/v2/provisionersdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/zclconf/go-cty/cty"
"golang.org/x/exp/maps"
"golang.org/x/xerrors"
"cdr.dev/slog"
)
// NOTE: This is duplicated from coderd but we can't import it here without
// introducing a circular dependency
const maxFileSizeBytes = 10 * (10 << 20) // 10 MB
// parseHCLFiler is the actual interface of *hclparse.Parser we use
// to parse HCL. This is extracted to an interface so we can more
// easily swap this out for an alternative implementation later on.
type parseHCLFiler interface {
ParseHCLFile(filename string) (*hcl.File, hcl.Diagnostics)
}
// Parser parses a Terraform module on disk.
type Parser struct {
logger slog.Logger
underlying parseHCLFiler
module *tfconfig.Module
workdir string
}
// Option is an option for a new instance of Parser.
type Option func(*Parser)
// WithLogger sets the logger to be used by Parser
func WithLogger(logger slog.Logger) Option {
return func(p *Parser) {
p.logger = logger
}
}
// New returns a new instance of Parser, as well as any diagnostics
// encountered while parsing the module.
func New(workdir string, opts ...Option) (*Parser, tfconfig.Diagnostics) {
p := Parser{
logger: slog.Make(),
underlying: hclparse.NewParser(),
workdir: workdir,
module: nil,
}
for _, o := range opts {
o(&p)
}
var diags tfconfig.Diagnostics
if p.module == nil {
m, ds := tfconfig.LoadModule(workdir)
diags = ds
p.module = m
}
return &p, diags
}
// WorkspaceTags looks for all coder_workspace_tags datasource in the module
// and returns the raw values for the tags. Use
func (p *Parser) WorkspaceTags(ctx context.Context) (map[string]string, error) {
tags := map[string]string{}
var skipped []string
for _, dataResource := range p.module.DataResources {
if dataResource.Type != "coder_workspace_tags" {
skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, "."))
continue
}
var file *hcl.File
var diags hcl.Diagnostics
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
continue
}
// We know in which HCL file is the data resource defined.
file, diags = p.underlying.ParseHCLFile(dataResource.Pos.Filename)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
// Parse root to find "coder_workspace_tags".
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
// Iterate over blocks to locate the exact "coder_workspace_tags" data resource.
for _, block := range content.Blocks {
if !slices.Equal(block.Labels, []string{"coder_workspace_tags", dataResource.Name}) {
continue
}
// Parse "coder_workspace_tags" to find all key-value tags.
resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error())
}
if resContent == nil {
continue // workspace tags are not present
}
if _, ok := resContent.Attributes["tags"]; !ok {
return nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`)
}
expr := resContent.Attributes["tags"].Expr
tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr)
if !ok {
return nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`)
}
// Parse key-value entries in "coder_workspace_tags"
for _, tagItem := range tagsExpr.Items {
key, err := previewFileContent(tagItem.KeyExpr.Range())
if err != nil {
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
key = strings.Trim(key, `"`)
value, err := previewFileContent(tagItem.ValueExpr.Range())
if err != nil {
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
if _, ok := tags[key]; ok {
return nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key)
}
tags[key] = value
}
}
}
p.logger.Debug(ctx, "found workspace tags", slog.F("tags", maps.Keys(tags)), slog.F("skipped", skipped))
return tags, nil
}
func (p *Parser) WorkspaceTagDefaults(ctx context.Context) (map[string]string, error) {
// This only gets us the expressions. We need to evaluate them.
// Example: var.region -> "us"
tags, err := p.WorkspaceTags(ctx)
if err != nil {
return nil, xerrors.Errorf("extract workspace tags: %w", err)
}
// To evaluate the expressions, we need to load the default values for
// variables and parameters.
varsDefaults, err := p.VariableDefaults(ctx)
if err != nil {
return nil, xerrors.Errorf("load variable defaults: %w", err)
}
paramsDefaults, err := p.CoderParameterDefaults(ctx)
if err != nil {
return nil, xerrors.Errorf("load parameter defaults: %w", err)
}
// Evaluate the tags expressions given the inputs.
// This will resolve any variables or parameters to their default
// values.
evalTags, err := evaluateWorkspaceTags(varsDefaults, paramsDefaults, tags)
if err != nil {
return nil, xerrors.Errorf("eval provisioner tags: %w", err)
}
// Ensure that none of the tag values are empty after evaluation.
for k, v := range evalTags {
if len(strings.TrimSpace(v)) > 0 {
continue
}
return nil, xerrors.Errorf("provisioner tag %q evaluated to an empty value, please set a default value", k)
}
return evalTags, nil
}
// TemplateVariables returns all of the Terraform variables in the module
// as TemplateVariables.
func (p *Parser) TemplateVariables() ([]*proto.TemplateVariable, error) {
// Sort variables by (filename, line) to make the ordering consistent
variables := make([]*tfconfig.Variable, 0, len(p.module.Variables))
for _, v := range p.module.Variables {
variables = append(variables, v)
}
sort.Slice(variables, func(i, j int) bool {
return compareSourcePos(variables[i].Pos, variables[j].Pos)
})
var templateVariables []*proto.TemplateVariable
for _, v := range variables {
mv, err := convertTerraformVariable(v)
if err != nil {
return nil, err
}
templateVariables = append(templateVariables, mv)
}
return templateVariables, nil
}
// WriteArchive is a helper function to write a in-memory archive
// with the given mimetype to disk. Only zip and tar archives
// are currently supported.
func WriteArchive(bs []byte, mimetype string, path string) error {
// Check if we need to convert the file first!
var rdr io.Reader
switch mimetype {
case "application/x-tar":
rdr = bytes.NewReader(bs)
case "application/zip":
if zr, err := zip.NewReader(bytes.NewReader(bs), int64(len(bs))); err != nil {
return xerrors.Errorf("read zip file: %w", err)
} else if tarBytes, err := archive.CreateTarFromZip(zr, maxFileSizeBytes); err != nil {
return xerrors.Errorf("convert zip to tar: %w", err)
} else {
rdr = bytes.NewReader(tarBytes)
}
default:
return xerrors.Errorf("unsupported mimetype: %s", mimetype)
}
// Untar the file into the temporary directory
if err := provisionersdk.Untar(path, rdr); err != nil {
return xerrors.Errorf("untar: %w", err)
}
return nil
}
// VariableDefaults returns the default values for all variables passed to it.
func (p *Parser) VariableDefaults(ctx context.Context) (map[string]string, error) {
// iterate through vars to get the default values for all
// variables.
m := make(map[string]string)
for _, v := range p.module.Variables {
if v == nil {
continue
}
sv, err := interfaceToString(v.Default)
if err != nil {
return nil, xerrors.Errorf("can't convert variable default value to string: %v", err)
}
m[v.Name] = strings.Trim(sv, `"`)
}
p.logger.Debug(ctx, "found default values for variables", slog.F("defaults", m))
return m, nil
}
// CoderParameterDefaults returns the default values of all coder_parameter data sources
// in the parsed module.
func (p *Parser) CoderParameterDefaults(ctx context.Context) (map[string]string, error) {
defaultsM := make(map[string]string)
var (
skipped []string
file *hcl.File
diags hcl.Diagnostics
)
for _, dataResource := range p.module.DataResources {
if dataResource == nil {
continue
}
if dataResource.Type != "coder_parameter" {
skipped = append(skipped, strings.Join([]string{"data", dataResource.Type, dataResource.Name}, "."))
continue
}
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
continue
}
// We know in which HCL file is the data resource defined.
// NOTE: hclparse.Parser will cache multiple successive calls to parse the same file.
file, diags = p.underlying.ParseHCLFile(dataResource.Pos.Filename)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file %q: %s", dataResource.Pos.Filename, diags.Error())
}
// Parse root to find "coder_parameter".
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
// Iterate over blocks to locate the exact "coder_parameter" data resource.
for _, block := range content.Blocks {
if !slices.Equal(block.Labels, []string{"coder_parameter", dataResource.Name}) {
continue
}
// Parse "coder_parameter" to find the default value.
resContent, _, diags := block.Body.PartialContent(coderParameterSchema)
if diags.HasErrors() {
return nil, xerrors.Errorf(`can't parse the coder_parameter: %s`, diags.Error())
}
if _, ok := resContent.Attributes["default"]; !ok {
defaultsM[dataResource.Name] = ""
} else {
expr := resContent.Attributes["default"].Expr
value, err := previewFileContent(expr.Range())
if err != nil {
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
defaultsM[dataResource.Name] = strings.Trim(value, `"`)
}
}
}
p.logger.Debug(ctx, "found default values for parameters", slog.F("defaults", defaultsM), slog.F("skipped", skipped))
return defaultsM, nil
}
// evaluateWorkspaceTags evaluates the given workspaceTags based on the given
// default values for variables and coder_parameter data sources.
func evaluateWorkspaceTags(varsDefaults, paramsDefaults, workspaceTags map[string]string) (map[string]string, error) {
// Filter only allowed data sources for preflight check.
// This is not strictly required but provides a friendlier error.
if err := validWorkspaceTagValues(workspaceTags); err != nil {
return nil, err
}
// We only add variables and coder_parameter data sources. Anything else will be
// undefined and will raise a Terraform error.
evalCtx := buildEvalContext(varsDefaults, paramsDefaults)
tags := make(map[string]string)
for workspaceTagKey, workspaceTagValue := range workspaceTags {
expr, diags := hclsyntax.ParseExpression([]byte(workspaceTagValue), "expression.hcl", hcl.InitialPos)
if diags.HasErrors() {
return nil, xerrors.Errorf("failed to parse workspace tag key %q value %q: %s", workspaceTagKey, workspaceTagValue, diags.Error())
}
val, diags := expr.Value(evalCtx)
if diags.HasErrors() {
return nil, xerrors.Errorf("failed to evaluate workspace tag key %q value %q: %s", workspaceTagKey, workspaceTagValue, diags.Error())
}
// Do not use "val.AsString()" as it can panic
str, err := ctyValueString(val)
if err != nil {
return nil, xerrors.Errorf("failed to marshal workspace tag key %q value %q as string: %s", workspaceTagKey, workspaceTagValue, err)
}
tags[workspaceTagKey] = str
}
return tags, nil
}
// validWorkspaceTagValues returns an error if any value of the given tags map
// evaluates to a datasource other than "coder_parameter".
// This only serves to provide a friendly error if a user attempts to reference
// a data source other than "coder_parameter" in "coder_workspace_tags".
func validWorkspaceTagValues(tags map[string]string) error {
for _, v := range tags {
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
continue
}
if parts[0] == "data" && parts[1] != "coder_parameter" {
return xerrors.Errorf("invalid workspace tag value %q: only the \"coder_parameter\" data source is supported here", v)
}
}
return nil
}
func buildEvalContext(varDefaults map[string]string, paramDefaults map[string]string) *hcl.EvalContext {
varDefaultsM := map[string]cty.Value{}
for varName, varDefault := range varDefaults {
varDefaultsM[varName] = cty.MapVal(map[string]cty.Value{
"value": cty.StringVal(varDefault),
})
}
paramDefaultsM := map[string]cty.Value{}
for paramName, paramDefault := range paramDefaults {
paramDefaultsM[paramName] = cty.MapVal(map[string]cty.Value{
"value": cty.StringVal(paramDefault),
})
}
evalCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{},
// NOTE: we do not currently support function execution here.
// The default function map for Terraform is not exposed, so we would essentially
// have to re-implement or copy the entire map or a subset thereof.
// ref: https://github.com/hashicorp/terraform/blob/e044e569c5bc81f82e9a4d7891f37c6fbb0a8a10/internal/lang/functions.go#L54
Functions: nil,
}
if len(varDefaultsM) != 0 {
evalCtx.Variables["var"] = cty.MapVal(varDefaultsM)
}
if len(paramDefaultsM) != 0 {
evalCtx.Variables["data"] = cty.MapVal(map[string]cty.Value{
"coder_parameter": cty.MapVal(paramDefaultsM),
})
}
return evalCtx
}
var rootTemplateSchema = &hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "data",
LabelNames: []string{"type", "name"},
},
},
}
var coderWorkspaceTagsSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "tags",
},
},
}
var coderParameterSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "default",
},
},
}
func previewFileContent(fileRange hcl.Range) (string, error) {
body, err := os.ReadFile(fileRange.Filename)
if err != nil {
return "", err
}
return string(fileRange.SliceBytes(body)), nil
}
// convertTerraformVariable converts a Terraform variable to a template-wide variable, processed by Coder.
func convertTerraformVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
var defaultData string
if variable.Default != nil {
var valid bool
defaultData, valid = variable.Default.(string)
if !valid {
defaultDataRaw, err := json.Marshal(variable.Default)
if err != nil {
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
}
defaultData = string(defaultDataRaw)
}
}
return &proto.TemplateVariable{
Name: variable.Name,
Description: variable.Description,
Type: variable.Type,
DefaultValue: defaultData,
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
Required: variable.Default == nil,
Sensitive: variable.Sensitive,
}, nil
}
func compareSourcePos(x, y tfconfig.SourcePos) bool {
if x.Filename != y.Filename {
return x.Filename < y.Filename
}
return x.Line < y.Line
}
func ctyValueString(val cty.Value) (string, error) {
switch val.Type() {
case cty.Bool:
if val.True() {
return "true", nil
} else {
return "false", nil
}
case cty.Number:
return val.AsBigFloat().String(), nil
case cty.String:
return val.AsString(), nil
// We may also have a map[string]interface{} with key "value".
case cty.Map(cty.String):
valval, ok := val.AsValueMap()["value"]
if !ok {
return "", xerrors.Errorf("map does not have key 'value'")
}
return ctyValueString(valval)
default:
return "", xerrors.Errorf("only primitive types are supported - bool, number, and string")
}
}
func interfaceToString(i interface{}) (string, error) {
switch v := i.(type) {
case nil:
return "", nil
case string:
return v, nil
case []byte:
return string(v), nil
case int:
return strconv.FormatInt(int64(v), 10), nil
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), nil
case bool:
return strconv.FormatBool(v), nil
default:
return "", xerrors.Errorf("unsupported type %T", v)
}
}
@@ -0,0 +1,500 @@
package tfparse_test
import (
"archive/tar"
"bytes"
"context"
"io"
"log"
"testing"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/archive"
"github.com/coder/coder/v2/provisioner/terraform/tfparse"
"github.com/coder/coder/v2/testutil"
"github.com/stretchr/testify/require"
)
func Test_WorkspaceTagDefaultsFromFile(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
files map[string]string
expectTags map[string]string
expectError string
}{
{
name: "empty",
files: map[string]string{},
expectTags: map[string]string{},
expectError: "",
},
{
name: "single text file",
files: map[string]string{
"file.txt": `
hello world`,
},
expectTags: map[string]string{},
expectError: "",
},
{
name: "main.tf with no workspace_tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}`,
},
expectTags: map[string]string{},
expectError: "",
},
{
name: "main.tf with empty workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_workspace_tags" "tags" {}`,
},
expectTags: map[string]string{},
expectError: `"tags" attribute is required by coder_workspace_tags`,
},
{
name: "main.tf with valid workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}`,
},
expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a"},
expectError: "",
},
{
name: "main.tf with multiple valid workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
variable "region2" {
type = string
default = "eu"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_parameter" "az2" {
name = "az2"
type = "string"
default = "b"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}
data "coder_workspace_tags" "more_tags" {
tags = {
"foo" = "bar"
}
}`,
},
expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a", "foo": "bar"},
expectError: "",
},
{
name: "main.tf with missing parameter default value for workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}`,
},
expectError: `provisioner tag "az" evaluated to an empty value, please set a default value`,
},
{
name: "main.tf with missing parameter default value outside workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_parameter" "notaz" {
name = "notaz"
type = "string"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}`,
},
expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a"},
expectError: ``,
},
{
name: "main.tf with missing variable default value outside workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
variable "notregion" {
type = string
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}`,
},
expectTags: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "us", "az": "a"},
expectError: ``,
},
{
name: "main.tf with disallowed data source for workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {
name = "foobar"
}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "local_file" "hostname" {
filename = "/etc/hostname"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
"hostname" = data.local_file.hostname.content
}
}`,
},
expectTags: nil,
expectError: `invalid workspace tag value "data.local_file.hostname.content": only the "coder_parameter" data source is supported here`,
},
{
name: "main.tf with disallowed resource for workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {
name = "foobar"
}
variable "region" {
type = string
default = "us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
"foobarbaz" = foo_bar.baz.name
}
}`,
},
expectTags: nil,
// TODO: this error isn't great, but it has the desired effect.
expectError: `There is no variable named "foo_bar"`,
},
{
name: "main.tf with functions in workspace tags",
files: map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {
name = "foobar"
}
variable "region" {
type = string
default = "region.us"
}
data "base" "ours" {
all = true
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "az.a"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = try(split(".", var.region)[1], "placeholder")
"az" = try(split(".", data.coder_parameter.az.value)[1], "placeholder")
}
}`,
},
expectTags: nil,
expectError: `Function calls not allowed; Functions may not be called here.`,
},
} {
tc := tc
t.Run(tc.name+"/tar", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
tar := createTar(t, tc.files)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
tmpDir := t.TempDir()
tfparse.WriteArchive(tar, "application/x-tar", tmpDir)
parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger))
require.NoError(t, diags.Err())
tags, err := parser.WorkspaceTagDefaults(ctx)
if tc.expectError != "" {
require.NotNil(t, err)
require.Contains(t, err.Error(), tc.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectTags, tags)
}
})
t.Run(tc.name+"/zip", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
zip := createZip(t, tc.files)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
tmpDir := t.TempDir()
tfparse.WriteArchive(zip, "application/zip", tmpDir)
parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger))
require.NoError(t, diags.Err())
tags, err := parser.WorkspaceTagDefaults(ctx)
if tc.expectError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectTags, tags)
}
})
}
}
func createTar(t testing.TB, files map[string]string) []byte {
var buffer bytes.Buffer
writer := tar.NewWriter(&buffer)
for path, content := range files {
err := writer.WriteHeader(&tar.Header{
Name: path,
Size: int64(len(content)),
Uid: 65534, // nobody
Gid: 65534, // nogroup
Mode: 0o666, // -rw-rw-rw-
})
require.NoError(t, err)
_, err = writer.Write([]byte(content))
require.NoError(t, err)
}
err := writer.Flush()
require.NoError(t, err)
return buffer.Bytes()
}
func createZip(t testing.TB, files map[string]string) []byte {
ta := createTar(t, files)
tr := tar.NewReader(bytes.NewReader(ta))
za, err := archive.CreateZipFromTar(tr, int64(len(ta)))
require.NoError(t, err)
return za
}
// Last run results:
// goos: linux
// goarch: amd64
// pkg: github.com/coder/coder/v2/provisioner/terraform/tfparse
// cpu: AMD EPYC 7502P 32-Core Processor
// BenchmarkWorkspaceTagDefaultsFromFile/Tar-16 1922 847236 ns/op 176257 B/op 1073 allocs/op
// BenchmarkWorkspaceTagDefaultsFromFile/Zip-16 1273 946910 ns/op 225293 B/op 1130 allocs/op
// PASS
func BenchmarkWorkspaceTagDefaultsFromFile(b *testing.B) {
files := map[string]string{
"main.tf": `
provider "foo" {}
resource "foo_bar" "baz" {}
variable "region" {
type = string
default = "us"
}
data "coder_parameter" "az" {
name = "az"
type = "string"
default = "a"
}
data "coder_workspace_tags" "tags" {
tags = {
"platform" = "kubernetes",
"cluster" = "${"devel"}${"opers"}"
"region" = var.region
"az" = data.coder_parameter.az.value
}
}`,
}
tarFile := createTar(b, files)
zipFile := createZip(b, files)
logger := discardLogger(b)
b.ResetTimer()
b.Run("Tar", func(b *testing.B) {
ctx := context.Background()
for i := 0; i < b.N; i++ {
tmpDir := b.TempDir()
tfparse.WriteArchive(tarFile, "application/x-tar", tmpDir)
parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger))
require.NoError(b, diags.Err())
_, err := parser.WorkspaceTags(ctx)
if err != nil {
b.Fatal(err)
}
}
})
b.Run("Zip", func(b *testing.B) {
ctx := context.Background()
for i := 0; i < b.N; i++ {
tmpDir := b.TempDir()
tfparse.WriteArchive(zipFile, "application/zip", tmpDir)
parser, diags := tfparse.New(tmpDir, tfparse.WithLogger(logger))
require.NoError(b, diags.Err())
_, err := parser.WorkspaceTags(ctx)
if err != nil {
b.Fatal(err)
}
}
})
}
func discardLogger(_ testing.TB) slog.Logger {
l := slog.Make(sloghuman.Sink(io.Discard))
log.SetOutput(slog.Stdlib(context.Background(), l, slog.LevelInfo).Writer())
return l
}