Skip to content

feat: use preview to compute workspace tags from terraform #18720

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 1 commit into from
Jul 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions archive/fs/zip.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions coderd/coderdtest/dynamicparameters.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type DynamicParameterTemplateParams struct {

// TemplateID is used to update an existing template instead of creating a new one.
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) {
Expand All @@ -47,6 +49,9 @@ func DynamicParameterTemplate(t *testing.T, client *codersdk.Client, org uuid.UU
if args.TemplateID != uuid.Nil {
request.TemplateID = args.TemplateID
}
if args.Version != nil {
args.Version(request)
}
})
AwaitTemplateVersionJobCompleted(t, client, version.ID)

Expand Down
6 changes: 3 additions & 3 deletions coderd/dynamicparameters/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import (
"github.com/coder/coder/v2/codersdk"
)

func ParameterValidationError(diags hcl.Diagnostics) *DiagnosticError {
func parameterValidationError(diags hcl.Diagnostics) *DiagnosticError {
return &DiagnosticError{
Message: "Unable to validate parameters",
Diagnostics: diags,
KeyedDiagnostics: make(map[string]hcl.Diagnostics),
}
}

func TagValidationError(diags hcl.Diagnostics) *DiagnosticError {
func tagValidationError(diags hcl.Diagnostics) *DiagnosticError {
return &DiagnosticError{
Message: "Failed to parse workspace tags",
Message: "Unable to parse workspace tags",
Diagnostics: diags,
KeyedDiagnostics: make(map[string]hcl.Diagnostics),
}
Expand Down
67 changes: 38 additions & 29 deletions coderd/dynamicparameters/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,28 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
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 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
Expand All @@ -252,37 +273,37 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
// 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
// read the user though.
mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: r.data.templateVersion.OrganizationID,
mem, err := database.ExpectOne(db.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: org,
UserID: ownerID,
IncludeSystem: true,
}))
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.
//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 {
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
// matches how the provisioner currently works. We should figure out
// 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 {
return xerrors.Errorf("user roles: %w", err)
return nil, xerrors.Errorf("user roles: %w", err)
}
roles, err := row.RoleNames()
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))
for _, it := range roles {
if it.OrganizationID != uuid.Nil && it.OrganizationID != r.data.templateVersion.OrganizationID {
if it.OrganizationID != uuid.Nil && it.OrganizationID != org {
continue
}
var orgID string
Expand All @@ -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
// unless the template leaks it.
// 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) {
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
// user, unless the template does it through the parameters. Regardless, we need
// the correct groups, and a user might not have read access.
// nolint:gocritic
groups, err := r.db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
OrganizationID: r.data.templateVersion.OrganizationID,
groups, err := db.GetGroups(dbauthz.AsProvisionerd(ctx), database.GetGroupsParams{
OrganizationID: org,
HasMemberID: ownerID,
})
if err != nil {
return xerrors.Errorf("groups: %w", err)
return nil, xerrors.Errorf("groups: %w", err)
}
groupNames := make([]string, 0, len(groups))
for _, it := range groups {
groupNames = append(groupNames, it.Group.Name)
}

r.currentOwner = &previewtypes.WorkspaceOwner{
return &previewtypes.WorkspaceOwner{
ID: user.ID.String(),
Name: user.Username,
FullName: user.Name,
Expand All @@ -328,17 +349,5 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui
RBACRoles: ownerRoles,
SSHPublicKey: key.PublicKey,
Groups: groupNames,
}
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
}, nil
}
6 changes: 3 additions & 3 deletions coderd/dynamicparameters/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func ResolveParameters(
// 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.

return nil, ParameterValidationError(diags)
return nil, parameterValidationError(diags)
}

// The user's input now needs to be validated against the parameters.
Expand Down Expand Up @@ -113,13 +113,13 @@ func ResolveParameters(
// are fatal. Additional validation for immutability has to be done manually.
output, diags = renderer.Render(ctx, ownerID, values.ValuesMap())
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
// around without a parameter.
parameterNames := make(map[string]struct{}, len(output.Parameters))
parameterError := ParameterValidationError(nil)
parameterError := parameterValidationError(nil)
for _, parameter := range output.Parameters {
parameterNames[parameter.Name] = struct{}{}

Expand Down
100 changes: 100 additions & 0 deletions coderd/dynamicparameters/tags.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading