mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: use preview to compute workspace tags from terraform (#18720)
If using dynamic parameters, workspace tags are extracted using `coder/preview`.
This commit is contained in:
19
archive/fs/zip.go
Normal file
19
archive/fs/zip.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package archivefs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/afero/zipfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromZipReader creates a read-only in-memory FS
|
||||||
|
func FromZipReader(r io.ReaderAt, size int64) (fs.FS, error) {
|
||||||
|
zr, err := zip.NewReader(r, size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return afero.NewIOFS(zipfs.New(zr)), nil
|
||||||
|
}
|
@ -25,6 +25,8 @@ type DynamicParameterTemplateParams struct {
|
|||||||
|
|
||||||
// TemplateID is used to update an existing template instead of creating a new one.
|
// TemplateID is used to update an existing template instead of creating a new one.
|
||||||
TemplateID uuid.UUID
|
TemplateID uuid.UUID
|
||||||
|
|
||||||
|
Version func(request *codersdk.CreateTemplateVersionRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) {
|
func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UUID, args DynamicParameterTemplateParams) (codersdk.Template, codersdk.TemplateVersion) {
|
||||||
@ -47,6 +49,9 @@ func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UU
|
|||||||
if args.TemplateID != uuid.Nil {
|
if args.TemplateID != uuid.Nil {
|
||||||
request.TemplateID = args.TemplateID
|
request.TemplateID = args.TemplateID
|
||||||
}
|
}
|
||||||
|
if args.Version != nil {
|
||||||
|
args.Version(request)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParameterValidationError(diags hcl.Diagnostics) *DiagnosticError {
|
func parameterValidationError(diags hcl.Diagnostics) *DiagnosticError {
|
||||||
return &DiagnosticError{
|
return &DiagnosticError{
|
||||||
Message: "Unable to validate parameters",
|
Message: "Unable to validate parameters",
|
||||||
Diagnostics: diags,
|
Diagnostics: diags,
|
||||||
@ -18,9 +18,9 @@ func ParameterValidationError(diags hcl.Diagnostics) *DiagnosticError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TagValidationError(diags hcl.Diagnostics) *DiagnosticError {
|
func tagValidationError(diags hcl.Diagnostics) *DiagnosticError {
|
||||||
return &DiagnosticError{
|
return &DiagnosticError{
|
||||||
Message: "Failed to parse workspace tags",
|
Message: "Unable to parse workspace tags",
|
||||||
Diagnostics: diags,
|
Diagnostics: diags,
|
||||||
KeyedDiagnostics: make(map[string]hcl.Diagnostics),
|
KeyedDiagnostics: make(map[string]hcl.Diagnostics),
|
||||||
}
|
}
|
||||||
|
@ -243,7 +243,28 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
|
|||||||
return nil // already fetched
|
return nil // already fetched
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := r.db.GetUserByID(ctx, ownerID)
|
owner, err := WorkspaceOwner(ctx, r.db, r.data.templateVersion.OrganizationID, ownerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.currentOwner = owner
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dynamicRenderer) Close() {
|
||||||
|
r.once.Do(r.close)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvisionerVersionSupportsDynamicParameters(version string) bool {
|
||||||
|
major, minor, err := apiversion.Parse(version)
|
||||||
|
// If the api version is not valid or less than 1.6, we need to use the static parameters
|
||||||
|
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
|
||||||
|
return !useStaticParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func WorkspaceOwner(ctx context.Context, db database.Store, org uuid.UUID, ownerID uuid.UUID) (*previewtypes.WorkspaceOwner, error) {
|
||||||
|
user, err := db.GetUserByID(ctx, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the user failed to read, we also try to read the user from their
|
// If the user failed to read, we also try to read the user from their
|
||||||
// organization member. You only need to be able to read the organization member
|
// organization member. You only need to be able to read the organization member
|
||||||
@ -252,37 +273,37 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
|
|||||||
// Only the terraform files can therefore leak more information than the
|
// Only the terraform files can therefore leak more information than the
|
||||||
// caller should have access to. All this info should be public assuming you can
|
// caller should have access to. All this info should be public assuming you can
|
||||||
// read the user though.
|
// read the user though.
|
||||||
mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
mem, err := database.ExpectOne(db.OrganizationMembers(ctx, database.OrganizationMembersParams{
|
||||||
OrganizationID: r.data.templateVersion.OrganizationID,
|
OrganizationID: org,
|
||||||
UserID: ownerID,
|
UserID: ownerID,
|
||||||
IncludeSystem: true,
|
IncludeSystem: true,
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("fetch user: %w", err)
|
return nil, xerrors.Errorf("fetch user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Org member fetched, so use the provisioner context to fetch the user.
|
// Org member fetched, so use the provisioner context to fetch the user.
|
||||||
//nolint:gocritic // Has the correct permissions, and matches the provisioning flow.
|
//nolint:gocritic // Has the correct permissions, and matches the provisioning flow.
|
||||||
user, err = r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
|
user, err = db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("fetch user: %w", err)
|
return nil, xerrors.Errorf("fetch user: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:gocritic // This is kind of the wrong query to use here, but it
|
// nolint:gocritic // This is kind of the wrong query to use here, but it
|
||||||
// matches how the provisioner currently works. We should figure out
|
// matches how the provisioner currently works. We should figure out
|
||||||
// something that needs less escalation but has the correct behavior.
|
// something that needs less escalation but has the correct behavior.
|
||||||
row, err := r.db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID)
|
row, err := db.GetAuthorizationUserRoles(dbauthz.AsProvisionerd(ctx), ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("user roles: %w", err)
|
return nil, xerrors.Errorf("user roles: %w", err)
|
||||||
}
|
}
|
||||||
roles, err := row.RoleNames()
|
roles, err := row.RoleNames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("expand roles: %w", err)
|
return nil, xerrors.Errorf("expand roles: %w", err)
|
||||||
}
|
}
|
||||||
ownerRoles := make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
|
ownerRoles := make([]previewtypes.WorkspaceOwnerRBACRole, 0, len(roles))
|
||||||
for _, it := range roles {
|
for _, it := range roles {
|
||||||
if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID {
|
if it.OrganizationID != uuid.Nil && it.OrganizationID != org {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var orgID string
|
var orgID string
|
||||||
@ -298,28 +319,28 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
|
|||||||
// The correct public key has to be sent. This will not be leaked
|
// The correct public key has to be sent. This will not be leaked
|
||||||
// unless the template leaks it.
|
// unless the template leaks it.
|
||||||
// nolint:gocritic
|
// nolint:gocritic
|
||||||
key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID)
|
key, err := db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID)
|
||||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||||
return xerrors.Errorf("ssh key: %w", err)
|
return nil, xerrors.Errorf("ssh key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The groups need to be sent to preview. These groups are not exposed to the
|
// The groups need to be sent to preview. These groups are not exposed to the
|
||||||
// user, unless the template does it through the parameters. Regardless, we need
|
// user, unless the template does it through the parameters. Regardless, we need
|
||||||
// the correct groups, and a user might not have read access.
|
// the correct groups, and a user might not have read access.
|
||||||
// nolint:gocritic
|
// nolint:gocritic
|
||||||
groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
|
groups, err := db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
|
||||||
OrganizationID: r.data.templateVersion.OrganizationID,
|
OrganizationID: org,
|
||||||
HasMemberID: ownerID,
|
HasMemberID: ownerID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("groups: %w", err)
|
return nil, xerrors.Errorf("groups: %w", err)
|
||||||
}
|
}
|
||||||
groupNames := make([]string, 0, len(groups))
|
groupNames := make([]string, 0, len(groups))
|
||||||
for _, it := range groups {
|
for _, it := range groups {
|
||||||
groupNames = append(groupNames, it.Group.Name)
|
groupNames = append(groupNames, it.Group.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.currentOwner = &previewtypes.WorkspaceOwner{
|
return &previewtypes.WorkspaceOwner{
|
||||||
ID: user.ID.String(),
|
ID: user.ID.String(),
|
||||||
Name: user.Username,
|
Name: user.Username,
|
||||||
FullName: user.Name,
|
FullName: user.Name,
|
||||||
@ -328,17 +349,5 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
|
|||||||
RBACRoles: ownerRoles,
|
RBACRoles: ownerRoles,
|
||||||
SSHPublicKey: key.PublicKey,
|
SSHPublicKey: key.PublicKey,
|
||||||
Groups: groupNames,
|
Groups: groupNames,
|
||||||
}
|
}, nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *dynamicRenderer) Close() {
|
|
||||||
r.once.Do(r.close)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProvisionerVersionSupportsDynamicParameters(version string) bool {
|
|
||||||
major, minor, err := apiversion.Parse(version)
|
|
||||||
// If the api version is not valid or less than 1.6, we need to use the static parameters
|
|
||||||
useStaticParams := err != nil || major < 1 || (major == 1 && minor < 6)
|
|
||||||
return !useStaticParams
|
|
||||||
}
|
}
|
||||||
|
@ -73,7 +73,7 @@ func ResolveParameters(
|
|||||||
// always be valid. If there is a case where this is not true, then this has to
|
// always be valid. If there is a case where this is not true, then this has to
|
||||||
// be changed to allow the build to continue with a different set of values.
|
// be changed to allow the build to continue with a different set of values.
|
||||||
|
|
||||||
return nil, ParameterValidationError(diags)
|
return nil, parameterValidationError(diags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user's input now needs to be validated against the parameters.
|
// The user's input now needs to be validated against the parameters.
|
||||||
@ -113,13 +113,13 @@ func ResolveParameters(
|
|||||||
// are fatal. Additional validation for immutability has to be done manually.
|
// are fatal. Additional validation for immutability has to be done manually.
|
||||||
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
|
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
return nil, ParameterValidationError(diags)
|
return nil, parameterValidationError(diags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parameterNames is going to be used to remove any excess values that were left
|
// parameterNames is going to be used to remove any excess values that were left
|
||||||
// around without a parameter.
|
// around without a parameter.
|
||||||
parameterNames := make(map[string]struct{}, len(output.Parameters))
|
parameterNames := make(map[string]struct{}, len(output.Parameters))
|
||||||
parameterError := ParameterValidationError(nil)
|
parameterError := parameterValidationError(nil)
|
||||||
for _, parameter := range output.Parameters {
|
for _, parameter := range output.Parameters {
|
||||||
parameterNames[parameter.Name] = struct{}{}
|
parameterNames[parameter.Name] = struct{}{}
|
||||||
|
|
||||||
|
100
coderd/dynamicparameters/tags.go
Normal file
100
coderd/dynamicparameters/tags.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package dynamicparameters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl/v2"
|
||||||
|
|
||||||
|
"github.com/coder/preview"
|
||||||
|
previewtypes "github.com/coder/preview/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckTags(output *preview.Output, diags hcl.Diagnostics) *DiagnosticError {
|
||||||
|
de := tagValidationError(diags)
|
||||||
|
failedTags := output.WorkspaceTags.UnusableTags()
|
||||||
|
if len(failedTags) == 0 && !de.HasError() {
|
||||||
|
return nil // No errors, all is good!
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range failedTags {
|
||||||
|
name := tag.KeyString()
|
||||||
|
if name == previewtypes.UnknownStringValue {
|
||||||
|
name = "unknown" // Best effort to get a name for the tag
|
||||||
|
}
|
||||||
|
de.Extend(name, failedTagDiagnostic(tag))
|
||||||
|
}
|
||||||
|
return de
|
||||||
|
}
|
||||||
|
|
||||||
|
// failedTagDiagnostic is a helper function that takes an invalid tag and
|
||||||
|
// returns an appropriate hcl diagnostic for it.
|
||||||
|
func failedTagDiagnostic(tag previewtypes.Tag) hcl.Diagnostics {
|
||||||
|
const (
|
||||||
|
key = "key"
|
||||||
|
value = "value"
|
||||||
|
)
|
||||||
|
|
||||||
|
diags := hcl.Diagnostics{}
|
||||||
|
|
||||||
|
// TODO: It would be really nice to pull out the variable references to help identify the source of
|
||||||
|
// the unknown or invalid tag.
|
||||||
|
unknownErr := "Tag %s is not known, it likely refers to a variable that is not set or has no default."
|
||||||
|
invalidErr := "Tag %s is not valid, it must be a non-null string value."
|
||||||
|
|
||||||
|
if !tag.Key.Value.IsWhollyKnown() {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: fmt.Sprintf(unknownErr, key),
|
||||||
|
})
|
||||||
|
} else if !tag.Key.Valid() {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: fmt.Sprintf(invalidErr, key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tag.Value.Value.IsWhollyKnown() {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: fmt.Sprintf(unknownErr, value),
|
||||||
|
})
|
||||||
|
} else if !tag.Value.Valid() {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: fmt.Sprintf(invalidErr, value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if diags.HasErrors() {
|
||||||
|
// Stop here if there are diags, as the diags manually created above are more
|
||||||
|
// informative than the original tag's diagnostics.
|
||||||
|
return diags
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, decorate the original tag's diagnostics
|
||||||
|
diagErr := "Tag %s: %s"
|
||||||
|
if tag.Key.ValueDiags.HasErrors() {
|
||||||
|
// add 'Tag key' prefix to each diagnostic
|
||||||
|
for _, d := range tag.Key.ValueDiags {
|
||||||
|
d.Summary = fmt.Sprintf(diagErr, key, d.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diags = diags.Extend(tag.Key.ValueDiags)
|
||||||
|
|
||||||
|
if tag.Value.ValueDiags.HasErrors() {
|
||||||
|
// add 'Tag value' prefix to each diagnostic
|
||||||
|
for _, d := range tag.Value.ValueDiags {
|
||||||
|
d.Summary = fmt.Sprintf(diagErr, value, d.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diags = diags.Extend(tag.Value.ValueDiags)
|
||||||
|
|
||||||
|
if !diags.HasErrors() {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Tag is invalid for some unknown reason. Please check the tag's value and key.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return diags
|
||||||
|
}
|
667
coderd/dynamicparameters/tags_internal_test.go
Normal file
667
coderd/dynamicparameters/tags_internal_test.go
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
package dynamicparameters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/afero/zipfs"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
archivefs "github.com/coder/coder/v2/archive/fs"
|
||||||
|
"github.com/coder/preview"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_DynamicWorkspaceTagDefaultsFromFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
unknownTag = "Tag value is not known"
|
||||||
|
invalidValueType = "Tag value is not valid"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
expectTags map[string]string
|
||||||
|
expectedFailedTags map[string]string
|
||||||
|
expectedError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single text file",
|
||||||
|
files: map[string]string{
|
||||||
|
"file.txt": `
|
||||||
|
hello world`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az" {
|
||||||
|
name = "az"
|
||||||
|
type = "string"
|
||||||
|
default = "a"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az" {
|
||||||
|
name = "az"
|
||||||
|
type = "string"
|
||||||
|
default = "a"
|
||||||
|
}
|
||||||
|
data "coder_workspace_tags" "tags" {}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
variable "unrelated" {
|
||||||
|
type = bool
|
||||||
|
}
|
||||||
|
data "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "main.tf with parameter that has default value from dynamic value",
|
||||||
|
files: map[string]string{
|
||||||
|
"main.tf": `
|
||||||
|
provider "foo" {}
|
||||||
|
resource "foo_bar" "baz" {}
|
||||||
|
variable "region" {
|
||||||
|
type = string
|
||||||
|
default = "us"
|
||||||
|
}
|
||||||
|
variable "az" {
|
||||||
|
type = string
|
||||||
|
default = "${""}${"a"}"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az" {
|
||||||
|
name = "az"
|
||||||
|
type = "string"
|
||||||
|
default = var.az
|
||||||
|
}
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "main.tf with parameter that has default value from another parameter",
|
||||||
|
files: map[string]string{
|
||||||
|
"main.tf": `
|
||||||
|
provider "foo" {}
|
||||||
|
resource "foo_bar" "baz" {}
|
||||||
|
variable "region" {
|
||||||
|
type = string
|
||||||
|
default = "us"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az" {
|
||||||
|
name = "az"
|
||||||
|
type = string
|
||||||
|
default = "${""}${"a"}"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az2" {
|
||||||
|
name = "az2"
|
||||||
|
type = "string"
|
||||||
|
default = data.coder_parameter.az.value
|
||||||
|
}
|
||||||
|
data "coder_workspace_tags" "tags" {
|
||||||
|
tags = {
|
||||||
|
"platform" = "kubernetes",
|
||||||
|
"cluster" = "${"devel"}${"opers"}"
|
||||||
|
"region" = var.region
|
||||||
|
"az" = data.coder_parameter.az2.value
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{
|
||||||
|
"platform": "kubernetes",
|
||||||
|
"cluster": "developers",
|
||||||
|
"region": "us",
|
||||||
|
"az": "a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{"cluster": "developers", "platform": "kubernetes", "region": "us"},
|
||||||
|
expectedFailedTags: map[string]string{
|
||||||
|
"az": "Tag value is not known, it likely refers to a variable that is not set or has no default.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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: map[string]string{
|
||||||
|
"platform": "kubernetes",
|
||||||
|
"cluster": "developers",
|
||||||
|
"region": "us",
|
||||||
|
"az": "a",
|
||||||
|
},
|
||||||
|
expectedFailedTags: map[string]string{
|
||||||
|
"hostname": unknownTag,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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: map[string]string{
|
||||||
|
"platform": "kubernetes",
|
||||||
|
"cluster": "developers",
|
||||||
|
"region": "us",
|
||||||
|
"az": "a",
|
||||||
|
"foobarbaz": "foobar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "main.tf with allowed functions in workspace tags",
|
||||||
|
files: map[string]string{
|
||||||
|
"main.tf": `
|
||||||
|
provider "foo" {}
|
||||||
|
resource "foo_bar" "baz" {
|
||||||
|
name = "foobar"
|
||||||
|
}
|
||||||
|
locals {
|
||||||
|
some_path = pathexpand("file.txt")
|
||||||
|
}
|
||||||
|
variable "region" {
|
||||||
|
type = string
|
||||||
|
default = "us"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
data "coder_parameter" "az" {
|
||||||
|
name = "az"
|
||||||
|
type = "string"
|
||||||
|
default = "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: map[string]string{"platform": "kubernetes", "cluster": "developers", "region": "placeholder", "az": "placeholder"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Trying to use '~' in a path expand is not allowed, as there is
|
||||||
|
// no concept of home directory in preview.
|
||||||
|
name: "main.tf with disallowed functions in workspace tags",
|
||||||
|
files: map[string]string{
|
||||||
|
"main.tf": `
|
||||||
|
provider "foo" {}
|
||||||
|
resource "foo_bar" "baz" {
|
||||||
|
name = "foobar"
|
||||||
|
}
|
||||||
|
locals {
|
||||||
|
some_path = pathexpand("file.txt")
|
||||||
|
}
|
||||||
|
variable "region" {
|
||||||
|
type = string
|
||||||
|
default = "region.us"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "unrelated" {
|
||||||
|
name = "unrelated"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
"some_path" = pathexpand("~/file.txt")
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{
|
||||||
|
"platform": "kubernetes",
|
||||||
|
"cluster": "developers",
|
||||||
|
"region": "us",
|
||||||
|
"az": "a",
|
||||||
|
},
|
||||||
|
expectedFailedTags: map[string]string{
|
||||||
|
"some_path": unknownTag,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "supported types",
|
||||||
|
files: map[string]string{
|
||||||
|
"main.tf": `
|
||||||
|
variable "stringvar" {
|
||||||
|
type = string
|
||||||
|
default = "a"
|
||||||
|
}
|
||||||
|
variable "numvar" {
|
||||||
|
type = number
|
||||||
|
default = 1
|
||||||
|
}
|
||||||
|
variable "boolvar" {
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
variable "listvar" {
|
||||||
|
type = list(string)
|
||||||
|
default = ["a"]
|
||||||
|
}
|
||||||
|
variable "mapvar" {
|
||||||
|
type = map(string)
|
||||||
|
default = {"a": "b"}
|
||||||
|
}
|
||||||
|
data "coder_parameter" "stringparam" {
|
||||||
|
name = "stringparam"
|
||||||
|
type = "string"
|
||||||
|
default = "a"
|
||||||
|
}
|
||||||
|
data "coder_parameter" "numparam" {
|
||||||
|
name = "numparam"
|
||||||
|
type = "number"
|
||||||
|
default = 1
|
||||||
|
}
|
||||||
|
data "coder_parameter" "boolparam" {
|
||||||
|
name = "boolparam"
|
||||||
|
type = "bool"
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
data "coder_parameter" "listparam" {
|
||||||
|
name = "listparam"
|
||||||
|
type = "list(string)"
|
||||||
|
default = "[\"a\", \"b\"]"
|
||||||
|
}
|
||||||
|
data "coder_workspace_tags" "tags" {
|
||||||
|
tags = {
|
||||||
|
"stringvar" = var.stringvar
|
||||||
|
"numvar" = var.numvar
|
||||||
|
"boolvar" = var.boolvar
|
||||||
|
"listvar" = var.listvar
|
||||||
|
"mapvar" = var.mapvar
|
||||||
|
"stringparam" = data.coder_parameter.stringparam.value
|
||||||
|
"numparam" = data.coder_parameter.numparam.value
|
||||||
|
"boolparam" = data.coder_parameter.boolparam.value
|
||||||
|
"listparam" = data.coder_parameter.listparam.value
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{
|
||||||
|
"stringvar": "a",
|
||||||
|
"numvar": "1",
|
||||||
|
"boolvar": "true",
|
||||||
|
"stringparam": "a",
|
||||||
|
"numparam": "1",
|
||||||
|
"boolparam": "true",
|
||||||
|
"listparam": `["a", "b"]`, // OK because params are cast to strings
|
||||||
|
},
|
||||||
|
expectedFailedTags: map[string]string{
|
||||||
|
"listvar": invalidValueType,
|
||||||
|
"mapvar": invalidValueType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overlapping var name",
|
||||||
|
files: map[string]string{
|
||||||
|
`main.tf`: `
|
||||||
|
variable "a" {
|
||||||
|
type = string
|
||||||
|
default = "1"
|
||||||
|
}
|
||||||
|
variable "unused" {
|
||||||
|
type = map(string)
|
||||||
|
default = {"a" : "b"}
|
||||||
|
}
|
||||||
|
variable "ab" {
|
||||||
|
description = "This is a variable of type string"
|
||||||
|
type = string
|
||||||
|
default = "ab"
|
||||||
|
}
|
||||||
|
data "coder_workspace_tags" "tags" {
|
||||||
|
tags = {
|
||||||
|
"foo": "bar",
|
||||||
|
"a": var.a,
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
expectTags: map[string]string{"foo": "bar", "a": "1"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name+"/tar", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
tarData := testutil.CreateTar(t, tc.files)
|
||||||
|
|
||||||
|
output, diags := preview.Preview(ctx, preview.Input{}, archivefs.FromTarReader(bytes.NewBuffer(tarData)))
|
||||||
|
if tc.expectedError != "" {
|
||||||
|
require.True(t, diags.HasErrors())
|
||||||
|
require.Contains(t, diags.Error(), tc.expectedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.False(t, diags.HasErrors(), diags.Error())
|
||||||
|
|
||||||
|
tags := output.WorkspaceTags
|
||||||
|
tagMap := tags.Tags()
|
||||||
|
failedTags := tags.UnusableTags()
|
||||||
|
assert.Equal(t, tc.expectTags, tagMap, "expected tags to match, must always provide something")
|
||||||
|
for _, tag := range failedTags {
|
||||||
|
verr := failedTagDiagnostic(tag)
|
||||||
|
expectedErr, ok := tc.expectedFailedTags[tag.KeyString()]
|
||||||
|
require.Truef(t, ok, "assertion for failed tag required: %s, %s", tag.KeyString(), verr.Error())
|
||||||
|
assert.Contains(t, verr.Error(), expectedErr, tag.KeyString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run(tc.name+"/zip", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
zipData := testutil.CreateZip(t, tc.files)
|
||||||
|
|
||||||
|
// get the zip fs
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output, diags := preview.Preview(ctx, preview.Input{}, afero.NewIOFS(zipfs.New(r)))
|
||||||
|
if tc.expectedError != "" {
|
||||||
|
require.True(t, diags.HasErrors())
|
||||||
|
require.Contains(t, diags.Error(), tc.expectedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.False(t, diags.HasErrors(), diags.Error())
|
||||||
|
|
||||||
|
tags := output.WorkspaceTags
|
||||||
|
tagMap := tags.Tags()
|
||||||
|
failedTags := tags.UnusableTags()
|
||||||
|
assert.Equal(t, tc.expectTags, tagMap, "expected tags to match, must always provide something")
|
||||||
|
for _, tag := range failedTags {
|
||||||
|
verr := failedTagDiagnostic(tag)
|
||||||
|
expectedErr, ok := tc.expectedFailedTags[tag.KeyString()]
|
||||||
|
assert.Truef(t, ok, "assertion for failed tag required: %s, %s", tag.KeyString(), verr.Error())
|
||||||
|
assert.Contains(t, verr.Error(), expectedErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -70,6 +70,8 @@ func TestDynamicParametersOwnerSSHPublicKey(t *testing.T) {
|
|||||||
require.Equal(t, sshKey.PublicKey, preview.Parameters[0].Value.Value)
|
require.Equal(t, sshKey.PublicKey, preview.Parameters[0].Value.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDynamicParametersWithTerraformValues is for testing the websocket flow of
|
||||||
|
// dynamic parameters. No workspaces are created.
|
||||||
func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
func TestDynamicParametersWithTerraformValues(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package coderd
|
package coderd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@ -8,6 +9,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
stdslog "log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@ -18,6 +21,9 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
archivefs "github.com/coder/coder/v2/archive/fs"
|
||||||
|
"github.com/coder/coder/v2/coderd/dynamicparameters"
|
||||||
|
"github.com/coder/preview"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/audit"
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
@ -1464,8 +1470,9 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var dynamicTemplate bool
|
||||||
if req.TemplateID != uuid.Nil {
|
if req.TemplateID != uuid.Nil {
|
||||||
_, err := api.Database.GetTemplateByID(ctx, req.TemplateID)
|
tpl, err := api.Database.GetTemplateByID(ctx, req.TemplateID)
|
||||||
if httpapi.Is404Error(err) {
|
if httpapi.Is404Error(err) {
|
||||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||||
Message: "Template does not exist.",
|
Message: "Template does not exist.",
|
||||||
@ -1479,6 +1486,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
dynamicTemplate = !tpl.UseClassicParameterFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ExampleID != "" && req.FileID != uuid.Nil {
|
if req.ExampleID != "" && req.FileID != uuid.Nil {
|
||||||
@ -1574,45 +1582,18 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse template tags from the given file.
|
var parsedTags map[string]string
|
||||||
tempDir, err := os.MkdirTemp(api.Options.CacheDir, "tfparse-*")
|
var ok bool
|
||||||
if err != nil {
|
if dynamicTemplate {
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
parsedTags, ok = api.dynamicTemplateVersionTags(ctx, rw, organization.ID, apiKey.UserID, file)
|
||||||
Message: "Internal error checking workspace tags",
|
if !ok {
|
||||||
Detail: "create tempdir: " + err.Error(),
|
return
|
||||||
})
|
}
|
||||||
return
|
} else {
|
||||||
}
|
parsedTags, ok = api.classicTemplateVersionTags(ctx, rw, file)
|
||||||
defer func() {
|
if !ok {
|
||||||
if err := os.RemoveAll(tempDir); err != nil {
|
return
|
||||||
api.Logger.Error(ctx, "failed to remove temporary tfparse dir", slog.Error(err))
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
if err := tfparse.WriteArchive(file.Data, file.Mimetype, tempDir); err != nil {
|
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
||||||
Message: "Internal error checking workspace tags",
|
|
||||||
Detail: "extract archive to tempdir: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parser, diags := tfparse.New(tempDir, tfparse.WithLogger(api.Logger.Named("tfparse")))
|
|
||||||
if diags.HasErrors() {
|
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
||||||
Message: "Internal error checking workspace tags",
|
|
||||||
Detail: "parse module: " + diags.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedTags, err := parser.WorkspaceTagDefaults(ctx)
|
|
||||||
if err != nil {
|
|
||||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
||||||
Message: "Internal error checking workspace tags",
|
|
||||||
Detail: "evaluate default values of workspace tags: " + err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the "owner" tag is properly applied in addition to request tags and coder_workspace_tags.
|
// Ensure the "owner" tag is properly applied in addition to request tags and coder_workspace_tags.
|
||||||
@ -1781,6 +1762,105 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
|
|||||||
warnings))
|
warnings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) dynamicTemplateVersionTags(ctx context.Context, rw http.ResponseWriter, orgID uuid.UUID, owner uuid.UUID, file database.File) (map[string]string, bool) {
|
||||||
|
ownerData, err := dynamicparameters.WorkspaceOwner(ctx, api.Database, orgID, owner)
|
||||||
|
if err != nil {
|
||||||
|
if httpapi.Is404Error(err) {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: fmt.Sprintf("Owner not found, uuid=%s", owner.String()),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "fetch owner data: " + err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var files fs.FS
|
||||||
|
switch file.Mimetype {
|
||||||
|
case "application/x-tar":
|
||||||
|
files = archivefs.FromTarReader(bytes.NewBuffer(file.Data))
|
||||||
|
case "application/zip":
|
||||||
|
files, err = archivefs.FromZipReader(bytes.NewReader(file.Data), int64(len(file.Data)))
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "extract zip archive: " + err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Unsupported file type for dynamic template version tags",
|
||||||
|
Detail: fmt.Sprintf("Mimetype %q is not supported for dynamic template version tags", file.Mimetype),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
output, diags := preview.Preview(ctx, preview.Input{
|
||||||
|
PlanJSON: nil, // Template versions are before `terraform plan`
|
||||||
|
ParameterValues: nil, // No user-specified parameters
|
||||||
|
Owner: *ownerData,
|
||||||
|
Logger: stdslog.New(stdslog.DiscardHandler),
|
||||||
|
}, files)
|
||||||
|
tagErr := dynamicparameters.CheckTags(output, diags)
|
||||||
|
if tagErr != nil {
|
||||||
|
code, resp := tagErr.Response()
|
||||||
|
httpapi.Write(ctx, rw, code, resp)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.WorkspaceTags.Tags(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *API) classicTemplateVersionTags(ctx context.Context, rw http.ResponseWriter, file database.File) (map[string]string, bool) {
|
||||||
|
// Try to parse template tags from the given file.
|
||||||
|
tempDir, err := os.MkdirTemp(api.Options.CacheDir, "tfparse-*")
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "create tempdir: " + err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.RemoveAll(tempDir); err != nil {
|
||||||
|
api.Logger.Error(ctx, "failed to remove temporary tfparse dir", slog.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := tfparse.WriteArchive(file.Data, file.Mimetype, tempDir); err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "extract archive to tempdir: " + err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
parser, diags := tfparse.New(tempDir, tfparse.WithLogger(api.Logger.Named("tfparse")))
|
||||||
|
if diags.HasErrors() {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "parse module: " + diags.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedTags, err := parser.WorkspaceTagDefaults(ctx)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||||
|
Message: "Internal error checking workspace tags",
|
||||||
|
Detail: "evaluate default values of workspace tags: " + err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedTags, true
|
||||||
|
}
|
||||||
|
|
||||||
// templateVersionResources returns the workspace agent resources associated
|
// templateVersionResources returns the workspace agent resources associated
|
||||||
// with a template version. A template can specify more than one resource to be
|
// with a template version. A template can specify more than one resource to be
|
||||||
// provisioned, each resource can have an agent that dials back to coderd. The
|
// provisioned, each resource can have an agent that dials back to coderd. The
|
||||||
|
@ -83,6 +83,7 @@ type Builder struct {
|
|||||||
parameterValues *[]string
|
parameterValues *[]string
|
||||||
templateVersionPresetParameterValues *[]database.TemplateVersionPresetParameter
|
templateVersionPresetParameterValues *[]database.TemplateVersionPresetParameter
|
||||||
parameterRender dynamicparameters.Renderer
|
parameterRender dynamicparameters.Renderer
|
||||||
|
workspaceTags *map[string]string
|
||||||
|
|
||||||
prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage
|
prebuiltWorkspaceBuildStage sdkproto.PrebuiltWorkspaceBuildStage
|
||||||
verifyNoLegacyParametersOnce bool
|
verifyNoLegacyParametersOnce bool
|
||||||
@ -939,6 +940,76 @@ func (b *Builder) getLastBuildJob() (*database.ProvisionerJob, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *Builder) getProvisionerTags() (map[string]string, error) {
|
func (b *Builder) getProvisionerTags() (map[string]string, error) {
|
||||||
|
if b.workspaceTags != nil {
|
||||||
|
return *b.workspaceTags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags map[string]string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if b.usingDynamicParameters() {
|
||||||
|
tags, err = b.getDynamicProvisionerTags()
|
||||||
|
} else {
|
||||||
|
tags, err = b.getClassicProvisionerTags()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get provisioner tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.workspaceTags = &tags
|
||||||
|
return *b.workspaceTags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) getDynamicProvisionerTags() (map[string]string, error) {
|
||||||
|
// Step 1: Mutate template manually set version tags
|
||||||
|
templateVersionJob, err := b.getTemplateVersionJob()
|
||||||
|
if err != nil {
|
||||||
|
return nil, BuildError{http.StatusInternalServerError, "failed to fetch template version job", err}
|
||||||
|
}
|
||||||
|
annotationTags := provisionersdk.MutateTags(b.workspace.OwnerID, templateVersionJob.Tags)
|
||||||
|
|
||||||
|
tags := map[string]string{}
|
||||||
|
for name, value := range annotationTags {
|
||||||
|
tags[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch tags from the template
|
||||||
|
render, err := b.getDynamicParameterRenderer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, BuildError{http.StatusInternalServerError, "failed to get dynamic parameter renderer", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
names, values, err := b.getParameters()
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("tags render: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vals := make(map[string]string, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
if i >= len(values) {
|
||||||
|
return nil, BuildError{
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("parameter names and values mismatch, %d names & %d values", len(names), len(values)),
|
||||||
|
xerrors.New("names and values mismatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vals[name] = values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
output, diags := render.Render(b.ctx, b.workspace.OwnerID, vals)
|
||||||
|
tagErr := dynamicparameters.CheckTags(output, diags)
|
||||||
|
if tagErr != nil {
|
||||||
|
return nil, tagErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range output.WorkspaceTags.Tags() {
|
||||||
|
tags[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) getClassicProvisionerTags() (map[string]string, error) {
|
||||||
// Step 1: Mutate template version tags
|
// Step 1: Mutate template version tags
|
||||||
templateVersionJob, err := b.getTemplateVersionJob()
|
templateVersionJob, err := b.getTemplateVersionJob()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -25,7 +25,9 @@ func TestDynamicParameterBuild(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||||
Options: &coderdtest.Options{IncludeProvisionerDaemon: true},
|
Options: &coderdtest.Options{
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
},
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
codersdk.FeatureTemplateRBAC: 1,
|
codersdk.FeatureTemplateRBAC: 1,
|
||||||
@ -355,6 +357,92 @@ func TestDynamicParameterBuild(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDynamicWorkspaceTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner, _, _, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||||
|
Options: &coderdtest.Options{
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
},
|
||||||
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureTemplateRBAC: 1,
|
||||||
|
codersdk.FeatureExternalProvisionerDaemons: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
orgID := first.OrganizationID
|
||||||
|
|
||||||
|
templateAdmin, _ := coderdtest.CreateAnotherUser(t, owner, orgID, rbac.ScopedRoleOrgTemplateAdmin(orgID))
|
||||||
|
// create the template first, mark it as dynamic, then create the second version with the workspace tags.
|
||||||
|
// This ensures the template import uses the dynamic tags flow. The second step will happen in a test below.
|
||||||
|
workspaceTags, _ := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
|
||||||
|
MainTF: ``,
|
||||||
|
})
|
||||||
|
|
||||||
|
expectedTags := map[string]string{
|
||||||
|
"function": "param is foo",
|
||||||
|
"stringvar": "bar",
|
||||||
|
"numvar": "42",
|
||||||
|
"boolvar": "true",
|
||||||
|
"stringparam": "foo",
|
||||||
|
"numparam": "7",
|
||||||
|
"boolparam": "true",
|
||||||
|
"listparam": `["a","b"]`,
|
||||||
|
"static": "static value",
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new provisioner daemon is required to make the template version.
|
||||||
|
importProvisioner := coderdenttest.NewExternalProvisionerDaemon(t, owner, first.OrganizationID, expectedTags)
|
||||||
|
defer importProvisioner.Close()
|
||||||
|
|
||||||
|
// This tests the template import's workspace tags extraction.
|
||||||
|
workspaceTags, workspaceTagsVersion := coderdtest.DynamicParameterTemplate(t, templateAdmin, orgID, coderdtest.DynamicParameterTemplateParams{
|
||||||
|
MainTF: string(must(os.ReadFile("testdata/parameters/workspacetags/main.tf"))),
|
||||||
|
TemplateID: workspaceTags.ID,
|
||||||
|
Version: func(request *codersdk.CreateTemplateVersionRequest) {
|
||||||
|
request.ProvisionerTags = map[string]string{
|
||||||
|
"static": "static value",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
importProvisioner.Close() // No longer need this provisioner daemon, as the template import is done.
|
||||||
|
|
||||||
|
// Test the workspace create tag extraction.
|
||||||
|
expectedTags["function"] = "param is baz"
|
||||||
|
expectedTags["stringparam"] = "baz"
|
||||||
|
expectedTags["numparam"] = "8"
|
||||||
|
expectedTags["boolparam"] = "false"
|
||||||
|
workspaceProvisioner := coderdenttest.NewExternalProvisionerDaemon(t, owner, first.OrganizationID, expectedTags)
|
||||||
|
defer workspaceProvisioner.Close()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
wrk, err := templateAdmin.CreateUserWorkspace(ctx, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||||
|
TemplateVersionID: workspaceTagsVersion.ID,
|
||||||
|
Name: coderdtest.RandomUsername(t),
|
||||||
|
RichParameterValues: []codersdk.WorkspaceBuildParameter{
|
||||||
|
{Name: "stringparam", Value: "baz"},
|
||||||
|
{Name: "numparam", Value: "8"},
|
||||||
|
{Name: "boolparam", Value: "false"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
build, err := templateAdmin.WorkspaceBuild(ctx, wrk.LatestBuild.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
job, err := templateAdmin.OrganizationProvisionerJob(ctx, first.OrganizationID, build.Job.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// If the tags do no match, the await will fail.
|
||||||
|
// 'scope' and 'owner' tags are always included.
|
||||||
|
expectedTags["scope"] = "organization"
|
||||||
|
expectedTags["owner"] = ""
|
||||||
|
require.Equal(t, expectedTags, job.Tags)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, wrk.LatestBuild.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// TestDynamicParameterTemplate uses a template with some dynamic elements, and
|
// TestDynamicParameterTemplate uses a template with some dynamic elements, and
|
||||||
// tests the parameters, values, etc are all as expected.
|
// tests the parameters, values, etc are all as expected.
|
||||||
func TestDynamicParameterTemplate(t *testing.T) {
|
func TestDynamicParameterTemplate(t *testing.T) {
|
||||||
|
66
enterprise/coderd/testdata/parameters/workspacetags/main.tf
vendored
Normal file
66
enterprise/coderd/testdata/parameters/workspacetags/main.tf
vendored
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
coder = {
|
||||||
|
source = "coder/coder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
variable "stringvar" {
|
||||||
|
type = string
|
||||||
|
default = "bar"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "numvar" {
|
||||||
|
type = number
|
||||||
|
default = 42
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "boolvar" {
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "stringparam" {
|
||||||
|
name = "stringparam"
|
||||||
|
type = "string"
|
||||||
|
default = "foo"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "stringparamref" {
|
||||||
|
name = "stringparamref"
|
||||||
|
type = "string"
|
||||||
|
default = data.coder_parameter.stringparam.value
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "numparam" {
|
||||||
|
name = "numparam"
|
||||||
|
type = "number"
|
||||||
|
default = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "boolparam" {
|
||||||
|
name = "boolparam"
|
||||||
|
type = "bool"
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_parameter" "listparam" {
|
||||||
|
name = "listparam"
|
||||||
|
type = "list(string)"
|
||||||
|
default = jsonencode(["a", "b"])
|
||||||
|
}
|
||||||
|
|
||||||
|
data "coder_workspace_tags" "tags" {
|
||||||
|
tags = {
|
||||||
|
"function" = format("param is %s", data.coder_parameter.stringparamref.value)
|
||||||
|
"stringvar" = var.stringvar
|
||||||
|
"numvar" = var.numvar
|
||||||
|
"boolvar" = var.boolvar
|
||||||
|
"stringparam" = data.coder_parameter.stringparam.value
|
||||||
|
"numparam" = data.coder_parameter.numparam.value
|
||||||
|
"boolparam" = data.coder_parameter.boolparam.value
|
||||||
|
"listparam" = data.coder_parameter.listparam.value
|
||||||
|
}
|
||||||
|
}
|
2
go.mod
2
go.mod
@ -483,7 +483,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||||
github.com/coder/aisdk-go v0.0.9
|
github.com/coder/aisdk-go v0.0.9
|
||||||
github.com/coder/preview v1.0.2
|
github.com/coder/preview v1.0.3-0.20250701142654-c3d6e86b9393
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/mark3labs/mcp-go v0.32.0
|
github.com/mark3labs/mcp-go v0.32.0
|
||||||
)
|
)
|
||||||
|
4
go.sum
4
go.sum
@ -916,8 +916,8 @@ github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102 h1:ahTJlTRmTogsubgRVGO
|
|||||||
github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/coder/pq v1.10.5-0.20250630052411-a259f96b6102/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
|
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs=
|
||||||
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
|
github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc=
|
||||||
github.com/coder/preview v1.0.2 h1:ZFfox0PgXcIouB9iWGcZyOtdL0h2a4ju1iPw/dMqsg4=
|
github.com/coder/preview v1.0.3-0.20250701142654-c3d6e86b9393 h1:l+m2liikn8JoEv6C22QIV4qseolUfvNsyUNA6JJsD6Y=
|
||||||
github.com/coder/preview v1.0.2/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
|
github.com/coder/preview v1.0.3-0.20250701142654-c3d6e86b9393/go.mod h1:efDWGlO/PZPrvdt5QiDhMtTUTkPxejXo9c0wmYYLLjM=
|
||||||
github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE=
|
github.com/coder/quartz v0.2.1 h1:QgQ2Vc1+mvzewg2uD/nj8MJ9p9gE+QhGJm+Z+NGnrSE=
|
||||||
github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA=
|
github.com/coder/quartz v0.2.1/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA=
|
||||||
github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
|
github.com/coder/retry v1.5.1 h1:iWu8YnD8YqHs3XwqrqsjoBTAVqT9ml6z9ViJ2wlMiqc=
|
||||||
|
Reference in New Issue
Block a user