Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Nov 14, 2024
Merged
Prev Previous commit
Next Next commit
use caching of hcl.NewParser
  • Loading branch information
johnstcn committed Nov 11, 2024
commit 14a5901edf81ca22787445d2bb082968b7c2bf72
2 changes: 1 addition & 1 deletion provisioner/terraform/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags))
}

workspaceTags, err := tfparse.WorkspaceTags(ctx, s.logger, module)
workspaceTags, err := tfparse.WorkspaceTags(module)
if err != nil {
return provisionersdk.ParseErrorf("can't load workspace tags: %v", err)
}
Expand Down
87 changes: 56 additions & 31 deletions provisioner/terraform/tfparse/tfextract.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"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"
Expand All @@ -30,11 +31,25 @@ import (
// introducing a circular dependency
const maxFileSizeBytes = 10 * (10 << 20) // 10 MB

// hclparse.Parser by default keeps track of all files it has ever parsed
// by filename. By re-using the same parser instance we can avoid repeating
// previous work.
// NOTE: we can only do this if we _just_ use ParseHCLFile().
// The ParseHCL() method allows specifying the filename directly, which allows
// easily getting previously cached results. Whenever we parse HCL files from disk
// we do so in a unique file path.
// See: provisionersdk/session.go#L51
var hclFileParser ParseHCLFiler = hclparse.NewParser()

type ParseHCLFiler interface {
ParseHCLFile(filename string) (*hcl.File, hcl.Diagnostics)
}

// WorkspaceTags extracts tags from coder_workspace_tags data sources defined in module.
// Note that this only returns the lexical values of the data source, and does not
// evaluate variables and such. To do this, see evalProvisionerTags below.
func WorkspaceTags(ctx context.Context, module *tfconfig.Module) (map[string]string, error) {
workspaceTags := map[string]string{}
func WorkspaceTags(module *tfconfig.Module) (map[string]string, error) {
tags := map[string]string{}

for _, dataResource := range module.DataResources {
if dataResource.Type != "coder_workspace_tags" {
Expand All @@ -43,13 +58,12 @@ func WorkspaceTags(ctx context.Context, module *tfconfig.Module) (map[string]str

var file *hcl.File
var diags hcl.Diagnostics
parser := hclparse.NewParser()

if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
continue
}
// We know in which HCL file is the data resource defined.
file, diags = parser.ParseHCLFile(dataResource.Pos.Filename)
file, diags = hclFileParser.ParseHCLFile(dataResource.Pos.Filename)
if diags.HasErrors() {
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}
Expand Down Expand Up @@ -99,14 +113,14 @@ func WorkspaceTags(ctx context.Context, module *tfconfig.Module) (map[string]str
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}

if _, ok := workspaceTags[key]; ok {
if _, ok := tags[key]; ok {
return nil, xerrors.Errorf(`workspace tag %q is defined multiple times`, key)
}
workspaceTags[key] = value
tags[key] = value
}
}
}
return workspaceTags, nil
return tags, nil
}

// WorkspaceTagDefaultsFromFile extracts the default values for a `coder_workspace_tags` resource from the given
Expand All @@ -131,16 +145,20 @@ func WorkspaceTagDefaultsFromFile(ctx context.Context, logger slog.Logger, file

// This only gets us the expressions. We need to evaluate them.
// Example: var.region -> "us"
tags, err = WorkspaceTags(ctx, module)
tags, err = WorkspaceTags(module)
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, paramsDefaults, err := loadDefaults(module)
varsDefaults, err := loadVarsDefaults(maps.Values(module.Variables))
if err != nil {
return nil, xerrors.Errorf("load variable defaults: %w", err)
}
paramsDefaults, err := loadParamsDefaults(maps.Values(module.DataResources))
if err != nil {
return nil, xerrors.Errorf("load defaults: %w", err)
return nil, xerrors.Errorf("load parameter defaults: %w", err)
}

// Evaluate the tags expressions given the inputs.
Expand Down Expand Up @@ -205,46 +223,53 @@ func loadModuleFromFile(file []byte, mimetype string) (module *tfconfig.Module,
return module, cleanup, nil
}

// loadDefaults inspects the given module and returns the default values for
// all variables and coder_parameter data sources referenced there.
func loadDefaults(module *tfconfig.Module) (varsDefaults map[string]string, paramsDefaults map[string]string, err error) {
// iterate through module.Variables to get the default values for all
// loadVarsDefaults returns the default values for all variables passed to it.
func loadVarsDefaults(variables []*tfconfig.Variable) (map[string]string, error) {
// iterate through vars to get the default values for all
// variables.
varsDefaults = make(map[string]string)
for _, v := range module.Variables {
m := make(map[string]string)
for _, v := range variables {
if v == nil {
continue
}
sv, err := interfaceToString(v.Default)
if err != nil {
return nil, nil, xerrors.Errorf("can't convert variable default value to string: %v", err)
return nil, xerrors.Errorf("can't convert variable default value to string: %v", err)
}
varsDefaults[v.Name] = strings.Trim(sv, `"`)
m[v.Name] = strings.Trim(sv, `"`)
}
return m, nil
}

// loadParamsDefaults returns the default values of all coder_parameter data sources data sources provided.
func loadParamsDefaults(dataSources []*tfconfig.Resource) (map[string]string, error) {
defaultsM := make(map[string]string)
for _, dataResource := range dataSources {
if dataResource == nil {
continue
}

// iterate through module.DataResources to get the default values for all
// coder_parameter data resources.
paramsDefaults = make(map[string]string)
for _, dataResource := range module.DataResources {
if dataResource.Type != "coder_parameter" {
continue
}

var file *hcl.File
var diags hcl.Diagnostics
parser := hclparse.NewParser()

if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
continue
}

// We know in which HCL file is the data resource defined.
file, diags = parser.ParseHCLFile(dataResource.Pos.Filename)
file, diags = hclFileParser.ParseHCLFile(dataResource.Pos.Filename)
if diags.HasErrors() {
return nil, nil, xerrors.Errorf("can't parse the resource file %q: %s", dataResource.Pos.Filename, diags.Error())
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, nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
}

// Iterate over blocks to locate the exact "coder_parameter" data resource.
Expand All @@ -256,22 +281,22 @@ func loadDefaults(module *tfconfig.Module) (varsDefaults map[string]string, para
// Parse "coder_parameter" to find the default value.
resContent, _, diags := block.Body.PartialContent(coderParameterSchema)
if diags.HasErrors() {
return nil, nil, xerrors.Errorf(`can't parse the coder_parameter: %s`, diags.Error())
return nil, xerrors.Errorf(`can't parse the coder_parameter: %s`, diags.Error())
}

if _, ok := resContent.Attributes["default"]; !ok {
paramsDefaults[dataResource.Name] = ""
defaultsM[dataResource.Name] = ""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we specify if the default is "" or actually not present?

This would make a tag "" if no default is provided. Should we error instead? Is that a breaking change if we error?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. The whole reason for doing this is to make sure that we have some default defined for the workspace_tags data source. So it probably should be an error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon closer inspection, this is only for parsing coder_parameters. It is legit to have an empty default value for a coder_parameter. We do not want to block this behaviour.
However, when using that coder_parameter as a transitive source for a coder_workspace_tags value, we want to ensure that that tag is set. This is checked in WorkspaceTagDefaults and has its own corresponding unit test.

} else {
expr := resContent.Attributes["default"].Expr
value, err := previewFileContent(expr.Range())
if err != nil {
return nil, nil, xerrors.Errorf("can't preview the resource file: %v", err)
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
}
paramsDefaults[dataResource.Name] = strings.Trim(value, `"`)
defaultsM[dataResource.Name] = strings.Trim(value, `"`)
}
}
}
return varsDefaults, paramsDefaults, nil
return defaultsM, nil
}

// EvalProvisionerTags evaluates the given workspaceTags based on the given
Expand Down
6 changes: 3 additions & 3 deletions provisioner/terraform/tfparse/tfparse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,13 @@ func createZip(t testing.TB, files map[string]string) []byte {
return za
}

// Current benchmark results before any changes / caching.
// 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 766 1493850 ns/op 339935 B/op 2238 allocs/op
// BenchmarkWorkspaceTagDefaultsFromFile/Zip-16 706 1633258 ns/op 389421 B/op 2296 allocs/op
// BenchmarkWorkspaceTagDefaultsFromFile/Tar-16 1147 1073487 ns/op 200266 B/op 1309 allocs/op
// BenchmarkWorkspaceTagDefaultsFromFile/Zip-16 991 1030536 ns/op 248063 B/op 1364 allocs/op
// PASS
func BenchmarkWorkspaceTagDefaultsFromFile(b *testing.B) {
files := map[string]string{
Expand Down