Skip to content

Commit fb35a81

Browse files
committed
chore(provisioner/terraform): extract terraform parsing logic to package tfextract
1 parent d9f1aaf commit fb35a81

File tree

2 files changed

+187
-172
lines changed

2 files changed

+187
-172
lines changed

provisioner/terraform/parse.go

Lines changed: 4 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
11
package terraform
22

33
import (
4-
"context"
5-
"encoding/json"
64
"fmt"
7-
"os"
85
"path/filepath"
9-
"slices"
10-
"sort"
116
"strings"
127

13-
"github.com/hashicorp/hcl/v2"
14-
"github.com/hashicorp/hcl/v2/hclparse"
15-
"github.com/hashicorp/hcl/v2/hclsyntax"
168
"github.com/hashicorp/terraform-config-inspect/tfconfig"
179
"github.com/mitchellh/go-wordwrap"
18-
"golang.org/x/xerrors"
1910

2011
"github.com/coder/coder/v2/coderd/tracing"
12+
"github.com/coder/coder/v2/provisioner/terraform/tfextract"
2113
"github.com/coder/coder/v2/provisionersdk"
2214
"github.com/coder/coder/v2/provisionersdk/proto"
2315
)
@@ -34,12 +26,12 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
3426
return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags))
3527
}
3628

37-
workspaceTags, err := s.loadWorkspaceTags(ctx, module)
29+
workspaceTags, err := tfextract.WorkspaceTags(ctx, s.logger, module)
3830
if err != nil {
3931
return provisionersdk.ParseErrorf("can't load workspace tags: %v", err)
4032
}
4133

42-
templateVariables, err := loadTerraformVariables(module)
34+
templateVariables, err := tfextract.LoadTerraformVariables(module)
4335
if err != nil {
4436
return provisionersdk.ParseErrorf("can't load template variables: %v", err)
4537
}
@@ -50,160 +42,7 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
5042
}
5143
}
5244

53-
var rootTemplateSchema = &hcl.BodySchema{
54-
Blocks: []hcl.BlockHeaderSchema{
55-
{
56-
Type: "data",
57-
LabelNames: []string{"type", "name"},
58-
},
59-
},
60-
}
61-
62-
var coderWorkspaceTagsSchema = &hcl.BodySchema{
63-
Attributes: []hcl.AttributeSchema{
64-
{
65-
Name: "tags",
66-
},
67-
},
68-
}
69-
70-
func (s *server) loadWorkspaceTags(ctx context.Context, module *tfconfig.Module) (map[string]string, error) {
71-
workspaceTags := map[string]string{}
72-
73-
for _, dataResource := range module.DataResources {
74-
if dataResource.Type != "coder_workspace_tags" {
75-
s.logger.Debug(ctx, "skip resource as it is not a coder_workspace_tags", "resource_name", dataResource.Name, "resource_type", dataResource.Type)
76-
continue
77-
}
78-
79-
var file *hcl.File
80-
var diags hcl.Diagnostics
81-
parser := hclparse.NewParser()
82-
83-
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
84-
s.logger.Debug(ctx, "only .tf files can be parsed", "filename", dataResource.Pos.Filename)
85-
continue
86-
}
87-
// We know in which HCL file is the data resource defined.
88-
file, diags = parser.ParseHCLFile(dataResource.Pos.Filename)
89-
90-
if diags.HasErrors() {
91-
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
92-
}
93-
94-
// Parse root to find "coder_workspace_tags".
95-
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
96-
if diags.HasErrors() {
97-
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
98-
}
99-
100-
// Iterate over blocks to locate the exact "coder_workspace_tags" data resource.
101-
for _, block := range content.Blocks {
102-
if !slices.Equal(block.Labels, []string{"coder_workspace_tags", dataResource.Name}) {
103-
continue
104-
}
105-
106-
// Parse "coder_workspace_tags" to find all key-value tags.
107-
resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema)
108-
if diags.HasErrors() {
109-
return nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error())
110-
}
111-
112-
if resContent == nil {
113-
continue // workspace tags are not present
114-
}
115-
116-
if _, ok := resContent.Attributes["tags"]; !ok {
117-
return nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`)
118-
}
119-
120-
expr := resContent.Attributes["tags"].Expr
121-
tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr)
122-
if !ok {
123-
return nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`)
124-
}
125-
126-
// Parse key-value entries in "coder_workspace_tags"
127-
for _, tagItem := range tagsExpr.Items {
128-
key, err := previewFileContent(tagItem.KeyExpr.Range())
129-
if err != nil {
130-
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
131-
}
132-
key = strings.Trim(key, `"`)
133-
134-
value, err := previewFileContent(tagItem.ValueExpr.Range())
135-
if err != nil {
136-
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
137-
}
138-
139-
s.logger.Info(ctx, "workspace tag found", "key", key, "value", value)
140-
141-
if _, ok := workspaceTags[key]; ok {
142-
return nil, xerrors.Errorf(`workspace tag "%s" is defined multiple times`, key)
143-
}
144-
workspaceTags[key] = value
145-
}
146-
}
147-
}
148-
return workspaceTags, nil
149-
}
150-
151-
func previewFileContent(fileRange hcl.Range) (string, error) {
152-
body, err := os.ReadFile(fileRange.Filename)
153-
if err != nil {
154-
return "", err
155-
}
156-
return string(fileRange.SliceBytes(body)), nil
157-
}
158-
159-
func loadTerraformVariables(module *tfconfig.Module) ([]*proto.TemplateVariable, error) {
160-
// Sort variables by (filename, line) to make the ordering consistent
161-
variables := make([]*tfconfig.Variable, 0, len(module.Variables))
162-
for _, v := range module.Variables {
163-
variables = append(variables, v)
164-
}
165-
sort.Slice(variables, func(i, j int) bool {
166-
return compareSourcePos(variables[i].Pos, variables[j].Pos)
167-
})
168-
169-
var templateVariables []*proto.TemplateVariable
170-
for _, v := range variables {
171-
mv, err := convertTerraformVariable(v)
172-
if err != nil {
173-
return nil, err
174-
}
175-
templateVariables = append(templateVariables, mv)
176-
}
177-
return templateVariables, nil
178-
}
179-
180-
// Converts a Terraform variable to a template-wide variable, processed by Coder.
181-
func convertTerraformVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
182-
var defaultData string
183-
if variable.Default != nil {
184-
var valid bool
185-
defaultData, valid = variable.Default.(string)
186-
if !valid {
187-
defaultDataRaw, err := json.Marshal(variable.Default)
188-
if err != nil {
189-
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
190-
}
191-
defaultData = string(defaultDataRaw)
192-
}
193-
}
194-
195-
return &proto.TemplateVariable{
196-
Name: variable.Name,
197-
Description: variable.Description,
198-
Type: variable.Type,
199-
DefaultValue: defaultData,
200-
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
201-
Required: variable.Default == nil,
202-
Sensitive: variable.Sensitive,
203-
}, nil
204-
}
205-
206-
// formatDiagnostics returns a nicely formatted string containing all of the
45+
// FormatDiagnostics returns a nicely formatted string containing all of the
20746
// error details within the tfconfig.Diagnostics. We need to use this because
20847
// the default format doesn't provide much useful information.
20948
func formatDiagnostics(baseDir string, diags tfconfig.Diagnostics) string {
@@ -246,10 +85,3 @@ func formatDiagnostics(baseDir string, diags tfconfig.Diagnostics) string {
24685

24786
return spacer + strings.TrimSpace(msgs.String())
24887
}
249-
250-
func compareSourcePos(x, y tfconfig.SourcePos) bool {
251-
if x.Filename != y.Filename {
252-
return x.Filename < y.Filename
253-
}
254-
return x.Line < y.Line
255-
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package tfextract
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"slices"
8+
"sort"
9+
"strings"
10+
11+
"github.com/coder/coder/v2/provisionersdk/proto"
12+
13+
"github.com/hashicorp/hcl/v2"
14+
"github.com/hashicorp/hcl/v2/hclparse"
15+
"github.com/hashicorp/hcl/v2/hclsyntax"
16+
"github.com/hashicorp/terraform-config-inspect/tfconfig"
17+
"golang.org/x/xerrors"
18+
19+
"cdr.dev/slog"
20+
)
21+
22+
// WorkspaceTags extracts tags from coder_workspace_tags data sources defined in module.
23+
func WorkspaceTags(ctx context.Context, logger slog.Logger, module *tfconfig.Module) (map[string]string, error) {
24+
workspaceTags := map[string]string{}
25+
26+
for _, dataResource := range module.DataResources {
27+
if dataResource.Type != "coder_workspace_tags" {
28+
logger.Debug(ctx, "skip resource as it is not a coder_workspace_tags", "resource_name", dataResource.Name, "resource_type", dataResource.Type)
29+
continue
30+
}
31+
32+
var file *hcl.File
33+
var diags hcl.Diagnostics
34+
parser := hclparse.NewParser()
35+
36+
if !strings.HasSuffix(dataResource.Pos.Filename, ".tf") {
37+
logger.Debug(ctx, "only .tf files can be parsed", "filename", dataResource.Pos.Filename)
38+
continue
39+
}
40+
// We know in which HCL file is the data resource defined.
41+
file, diags = parser.ParseHCLFile(dataResource.Pos.Filename)
42+
43+
if diags.HasErrors() {
44+
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
45+
}
46+
47+
// Parse root to find "coder_workspace_tags".
48+
content, _, diags := file.Body.PartialContent(rootTemplateSchema)
49+
if diags.HasErrors() {
50+
return nil, xerrors.Errorf("can't parse the resource file: %s", diags.Error())
51+
}
52+
53+
// Iterate over blocks to locate the exact "coder_workspace_tags" data resource.
54+
for _, block := range content.Blocks {
55+
if !slices.Equal(block.Labels, []string{"coder_workspace_tags", dataResource.Name}) {
56+
continue
57+
}
58+
59+
// Parse "coder_workspace_tags" to find all key-value tags.
60+
resContent, _, diags := block.Body.PartialContent(coderWorkspaceTagsSchema)
61+
if diags.HasErrors() {
62+
return nil, xerrors.Errorf(`can't parse the resource coder_workspace_tags: %s`, diags.Error())
63+
}
64+
65+
if resContent == nil {
66+
continue // workspace tags are not present
67+
}
68+
69+
if _, ok := resContent.Attributes["tags"]; !ok {
70+
return nil, xerrors.Errorf(`"tags" attribute is required by coder_workspace_tags`)
71+
}
72+
73+
expr := resContent.Attributes["tags"].Expr
74+
tagsExpr, ok := expr.(*hclsyntax.ObjectConsExpr)
75+
if !ok {
76+
return nil, xerrors.Errorf(`"tags" attribute is expected to be a key-value map`)
77+
}
78+
79+
// Parse key-value entries in "coder_workspace_tags"
80+
for _, tagItem := range tagsExpr.Items {
81+
key, err := previewFileContent(tagItem.KeyExpr.Range())
82+
if err != nil {
83+
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
84+
}
85+
key = strings.Trim(key, `"`)
86+
87+
value, err := previewFileContent(tagItem.ValueExpr.Range())
88+
if err != nil {
89+
return nil, xerrors.Errorf("can't preview the resource file: %v", err)
90+
}
91+
92+
logger.Info(ctx, "workspace tag found", "key", key, "value", value)
93+
94+
if _, ok := workspaceTags[key]; ok {
95+
return nil, xerrors.Errorf(`workspace tag "%s" is defined multiple times`, key)
96+
}
97+
workspaceTags[key] = value
98+
}
99+
}
100+
}
101+
return workspaceTags, nil
102+
}
103+
104+
var rootTemplateSchema = &hcl.BodySchema{
105+
Blocks: []hcl.BlockHeaderSchema{
106+
{
107+
Type: "data",
108+
LabelNames: []string{"type", "name"},
109+
},
110+
},
111+
}
112+
113+
var coderWorkspaceTagsSchema = &hcl.BodySchema{
114+
Attributes: []hcl.AttributeSchema{
115+
{
116+
Name: "tags",
117+
},
118+
},
119+
}
120+
121+
func previewFileContent(fileRange hcl.Range) (string, error) {
122+
body, err := os.ReadFile(fileRange.Filename)
123+
if err != nil {
124+
return "", err
125+
}
126+
return string(fileRange.SliceBytes(body)), nil
127+
}
128+
129+
// LoadTerraformVariables extracts all Terraform variables from module and converts them
130+
// to template variables. The variables are sorted by source position.
131+
func LoadTerraformVariables(module *tfconfig.Module) ([]*proto.TemplateVariable, error) {
132+
// Sort variables by (filename, line) to make the ordering consistent
133+
variables := make([]*tfconfig.Variable, 0, len(module.Variables))
134+
for _, v := range module.Variables {
135+
variables = append(variables, v)
136+
}
137+
sort.Slice(variables, func(i, j int) bool {
138+
return compareSourcePos(variables[i].Pos, variables[j].Pos)
139+
})
140+
141+
var templateVariables []*proto.TemplateVariable
142+
for _, v := range variables {
143+
mv, err := convertTerraformVariable(v)
144+
if err != nil {
145+
return nil, err
146+
}
147+
templateVariables = append(templateVariables, mv)
148+
}
149+
return templateVariables, nil
150+
}
151+
152+
// /convertTerraformVariable converts a Terraform variable to a template-wide variable, processed by Coder.
153+
func convertTerraformVariable(variable *tfconfig.Variable) (*proto.TemplateVariable, error) {
154+
var defaultData string
155+
if variable.Default != nil {
156+
var valid bool
157+
defaultData, valid = variable.Default.(string)
158+
if !valid {
159+
defaultDataRaw, err := json.Marshal(variable.Default)
160+
if err != nil {
161+
return nil, xerrors.Errorf("parse variable %q default: %w", variable.Name, err)
162+
}
163+
defaultData = string(defaultDataRaw)
164+
}
165+
}
166+
167+
return &proto.TemplateVariable{
168+
Name: variable.Name,
169+
Description: variable.Description,
170+
Type: variable.Type,
171+
DefaultValue: defaultData,
172+
// variable.Required is always false. Empty string is a valid default value, so it doesn't enforce required to be "true".
173+
Required: variable.Default == nil,
174+
Sensitive: variable.Sensitive,
175+
}, nil
176+
}
177+
178+
func compareSourcePos(x, y tfconfig.SourcePos) bool {
179+
if x.Filename != y.Filename {
180+
return x.Filename < y.Filename
181+
}
182+
return x.Line < y.Line
183+
}

0 commit comments

Comments
 (0)