mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: store coder_workspace_tags in the database (#13294)
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
package terraform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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/mitchellh/go-wordwrap"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -28,6 +34,129 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
|
||||
return provisionersdk.ParseErrorf("load module: %s", formatDiagnostics(sess.WorkDirectory, diags))
|
||||
}
|
||||
|
||||
workspaceTags, err := s.loadWorkspaceTags(ctx, module)
|
||||
if err != nil {
|
||||
return provisionersdk.ParseErrorf("can't load workspace tags: %v", err)
|
||||
}
|
||||
|
||||
templateVariables, err := loadTerraformVariables(module)
|
||||
if err != nil {
|
||||
return provisionersdk.ParseErrorf("can't load template variables: %v", err)
|
||||
}
|
||||
|
||||
return &proto.ParseComplete{
|
||||
TemplateVariables: templateVariables,
|
||||
WorkspaceTags: workspaceTags,
|
||||
}
|
||||
}
|
||||
|
||||
var rootTemplateSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{
|
||||
Type: "data",
|
||||
LabelNames: []string{"type", "name"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var coderWorkspaceTagsSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{
|
||||
Name: "tags",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func (s *server) loadWorkspaceTags(ctx context.Context, module *tfconfig.Module) (map[string]string, error) {
|
||||
workspaceTags := map[string]string{}
|
||||
|
||||
for _, dataResource := range module.DataResources {
|
||||
if dataResource.Type != "coder_workspace_tags" {
|
||||
s.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") {
|
||||
s.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)
|
||||
}
|
||||
|
||||
s.logger.Info(ctx, "workspace tag found", "key", key, "value", value)
|
||||
|
||||
if _, ok := workspaceTags[key]; ok {
|
||||
return nil, xerrors.Errorf(`workspace tag "%s" is defined multiple times`, key)
|
||||
}
|
||||
workspaceTags[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return workspaceTags, nil
|
||||
}
|
||||
|
||||
func previewFileContent(fileRange hcl.Range) (string, error) {
|
||||
body, err := os.ReadFile(fileRange.Filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(fileRange.SliceBytes(body)), nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -38,17 +167,14 @@ func (s *server) Parse(sess *provisionersdk.Session, _ *proto.ParseRequest, _ <-
|
||||
})
|
||||
|
||||
var templateVariables []*proto.TemplateVariable
|
||||
|
||||
for _, v := range variables {
|
||||
mv, err := convertTerraformVariable(v)
|
||||
if err != nil {
|
||||
return provisionersdk.ParseErrorf("can't convert the Terraform variable to a managed one: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
templateVariables = append(templateVariables, mv)
|
||||
}
|
||||
return &proto.ParseComplete{
|
||||
TemplateVariables: templateVariables,
|
||||
}
|
||||
return templateVariables, nil
|
||||
}
|
||||
|
||||
// Converts a Terraform variable to a template-wide variable, processed by Coder.
|
||||
|
||||
@@ -201,6 +201,177 @@ func TestParse(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "workspace-tags",
|
||||
Files: map[string]string{
|
||||
"parameters.tf": `data "coder_parameter" "os_selector" {
|
||||
name = "os_selector"
|
||||
display_name = "Operating System"
|
||||
mutable = false
|
||||
|
||||
default = "osx"
|
||||
|
||||
option {
|
||||
icon = "/icons/linux.png"
|
||||
name = "Linux"
|
||||
value = "linux"
|
||||
}
|
||||
option {
|
||||
icon = "/icons/osx.png"
|
||||
name = "OSX"
|
||||
value = "osx"
|
||||
}
|
||||
option {
|
||||
icon = "/icons/windows.png"
|
||||
name = "Windows"
|
||||
value = "windows"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "feature_cache_enabled" {
|
||||
name = "feature_cache_enabled"
|
||||
display_name = "Enable cache?"
|
||||
type = "bool"
|
||||
|
||||
default = false
|
||||
}
|
||||
|
||||
data "coder_parameter" "feature_debug_enabled" {
|
||||
name = "feature_debug_enabled"
|
||||
display_name = "Enable debug?"
|
||||
type = "bool"
|
||||
|
||||
default = true
|
||||
}`,
|
||||
"tags.tf": `data "coder_workspace_tags" "custom_workspace_tags" {
|
||||
tags = {
|
||||
"cluster" = "developers"
|
||||
"os" = data.coder_parameter.os_selector.value
|
||||
"debug" = "${data.coder_parameter.feature_debug_enabled.value}+12345"
|
||||
"cache" = data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
Response: &proto.ParseComplete{
|
||||
WorkspaceTags: map[string]string{
|
||||
"cluster": `"developers"`,
|
||||
"os": `data.coder_parameter.os_selector.value`,
|
||||
"debug": `"${data.coder_parameter.feature_debug_enabled.value}+12345"`,
|
||||
"cache": `data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "workspace-tags-in-a-single-file",
|
||||
Files: map[string]string{
|
||||
"main.tf": `
|
||||
|
||||
data "coder_parameter" "os_selector" {
|
||||
name = "os_selector"
|
||||
display_name = "Operating System"
|
||||
mutable = false
|
||||
|
||||
default = "osx"
|
||||
|
||||
option {
|
||||
icon = "/icons/linux.png"
|
||||
name = "Linux"
|
||||
value = "linux"
|
||||
}
|
||||
option {
|
||||
icon = "/icons/osx.png"
|
||||
name = "OSX"
|
||||
value = "osx"
|
||||
}
|
||||
option {
|
||||
icon = "/icons/windows.png"
|
||||
name = "Windows"
|
||||
value = "windows"
|
||||
}
|
||||
}
|
||||
|
||||
data "coder_parameter" "feature_cache_enabled" {
|
||||
name = "feature_cache_enabled"
|
||||
display_name = "Enable cache?"
|
||||
type = "bool"
|
||||
|
||||
default = false
|
||||
}
|
||||
|
||||
data "coder_parameter" "feature_debug_enabled" {
|
||||
name = "feature_debug_enabled"
|
||||
display_name = "Enable debug?"
|
||||
type = "bool"
|
||||
|
||||
default = true
|
||||
}
|
||||
|
||||
data "coder_workspace_tags" "custom_workspace_tags" {
|
||||
tags = {
|
||||
"cluster" = "developers"
|
||||
"os" = data.coder_parameter.os_selector.value
|
||||
"debug" = "${data.coder_parameter.feature_debug_enabled.value}+12345"
|
||||
"cache" = data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
Response: &proto.ParseComplete{
|
||||
WorkspaceTags: map[string]string{
|
||||
"cluster": `"developers"`,
|
||||
"os": `data.coder_parameter.os_selector.value`,
|
||||
"debug": `"${data.coder_parameter.feature_debug_enabled.value}+12345"`,
|
||||
"cache": `data.coder_parameter.feature_cache_enabled.value == "true" ? "nix-with-cache" : "no-cache"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "workspace-tags-duplicate-tag",
|
||||
Files: map[string]string{
|
||||
"main.tf": `
|
||||
|
||||
data "coder_workspace_tags" "custom_workspace_tags" {
|
||||
tags = {
|
||||
"cluster" = "developers"
|
||||
"debug" = "yes"
|
||||
"debug" = "no"
|
||||
"cache" = "no-cache"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
ErrorContains: `workspace tag "debug" is defined multiple times`,
|
||||
},
|
||||
{
|
||||
Name: "workspace-tags-wrong-tag-format",
|
||||
Files: map[string]string{
|
||||
"main.tf": `
|
||||
|
||||
data "coder_workspace_tags" "custom_workspace_tags" {
|
||||
tags {
|
||||
cluster = "developers"
|
||||
debug = "yes"
|
||||
cache = "no-cache"
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
ErrorContains: `"tags" attribute is required by coder_workspace_tags`,
|
||||
},
|
||||
{
|
||||
Name: "empty-main",
|
||||
Files: map[string]string{
|
||||
"main.tf": ``,
|
||||
},
|
||||
Response: &proto.ParseComplete{},
|
||||
},
|
||||
{
|
||||
Name: "non-tf-files",
|
||||
Files: map[string]string{
|
||||
"any-file.txt": "Foobar",
|
||||
},
|
||||
Response: &proto.ParseComplete{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
Reference in New Issue
Block a user