`.",
-
+ MarkdownDescription: "A Coder template.\n\nLogs from building template versions can be optionally streamed from the provisioner " +
+ "by setting the `TF_LOG` environment variable to `INFO` or higher.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "The ID of the template.",
@@ -247,13 +262,16 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
MarkdownDescription: "The name of the template.",
Required: true,
Validators: []validator.String{
- stringvalidator.LengthBetween(1, 32),
+ codersdkvalidator.Name(),
},
},
"display_name": schema.StringAttribute{
MarkdownDescription: "The display name of the template. Defaults to the template name.",
Optional: true,
Computed: true,
+ Validators: []validator.String{
+ codersdkvalidator.DisplayName(),
+ },
},
"description": schema.StringAttribute{
MarkdownDescription: "A description of the template.",
@@ -271,7 +289,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
},
},
"icon": schema.StringAttribute{
- MarkdownDescription: "Relative path or external URL that specifes an icon to be displayed in the dashboard.",
+ MarkdownDescription: "Relative path or external URL that specifies an icon to be displayed in the dashboard.",
Optional: true,
Computed: true,
Default: stringdefault.StaticString(""),
@@ -334,7 +352,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
Default: booldefault.StaticBool(true),
},
"allow_user_auto_stop": schema.BoolAttribute{
- MarkdownDescription: "(Enterprise) Whether users can auto-start workspaces created from this template. Defaults to true.",
+ MarkdownDescription: "(Enterprise) Whether users can auto-stop workspaces created from this template. Defaults to true.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
@@ -363,12 +381,31 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
Computed: true,
Default: booldefault.StaticBool(false),
},
+ "max_port_share_level": schema.StringAttribute{
+ MarkdownDescription: "(Enterprise) The maximum port share level for workspaces created from this template. Defaults to `owner` on an Enterprise deployment, or `public` otherwise.",
+ Optional: true,
+ Computed: true,
+ Validators: []validator.String{
+ stringvalidator.OneOfCaseInsensitive(string(codersdk.WorkspaceAgentPortShareLevelAuthenticated), string(codersdk.WorkspaceAgentPortShareLevelOwner), string(codersdk.WorkspaceAgentPortShareLevelPublic)),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
"deprecation_message": schema.StringAttribute{
MarkdownDescription: "If set, the template will be marked as deprecated with the provided message and users will be blocked from creating new workspaces from it. Does nothing if set when the resource is created.",
Optional: true,
Computed: true,
Default: stringdefault.StaticString(""),
},
+ "use_classic_parameter_flow": schema.BoolAttribute{
+ MarkdownDescription: "If true, the classic parameter flow will be used when creating workspaces from this template. Defaults to false.",
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.Bool{
+ boolplanmodifier.UseStateForUnknown(),
+ },
+ },
"acl": schema.SingleNestedAttribute{
MarkdownDescription: "(Enterprise) Access control list for the template. If null, ACL policies will not be added, removed, or read by Terraform.",
Optional: true,
@@ -381,7 +418,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
Required: true,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
- NewActiveVersionValidator(),
+ NewVersionsValidator(),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
@@ -390,11 +427,11 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
Computed: true,
},
"name": schema.StringAttribute{
- MarkdownDescription: "The name of the template version. Automatically generated if not provided. If provided, the name *must* change each time the directory contents are updated.",
+ MarkdownDescription: "The name of the template version. Automatically generated if not provided. If provided, the name *must* change each time the directory contents, or the `tf_vars` attribute are updated.",
Optional: true,
Computed: true,
Validators: []validator.String{
- stringvalidator.LengthAtLeast(1),
+ codersdkvalidator.TemplateVersionName(),
},
},
"message": schema.StringAttribute{
@@ -489,9 +526,9 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
if idx > 0 {
newVersionRequest.TemplateID = &templateResp.ID
}
- versionResp, err := newVersion(ctx, client, newVersionRequest)
+ versionResp, err, logs := newVersion(ctx, client, newVersionRequest)
if err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
+ resp.Diagnostics.AddError("Provisioner Error", formatLogs(err, logs))
return
}
if idx == 0 {
@@ -525,7 +562,7 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
if resp.Diagnostics.HasError() {
return
}
- err = client.UpdateTemplateACL(ctx, templateResp.ID, convertACLToRequest(acl))
+ err = client.UpdateTemplateACL(ctx, templateResp.ID, convertACLToRequest(codersdk.TemplateACL{}, acl))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to create template ACL: %s", err))
return
@@ -546,6 +583,44 @@ func (r *TemplateResource) Create(ctx context.Context, req resource.CreateReques
data.ID = UUIDValue(templateResp.ID)
data.DisplayName = types.StringValue(templateResp.DisplayName)
+ // TODO: Remove this update call once this provider requires a Coder
+ // deployment running `v2.15.0` or later.
+ if data.MaxPortShareLevel.IsUnknown() {
+ data.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel))
+ } else if data.MaxPortShareLevel.ValueString() == string(templateResp.MaxPortShareLevel) {
+ tflog.Info(ctx, "max port share level set to default, not updating")
+ } else {
+ mpslReq := data.toUpdateRequest(ctx, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ mpslResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *mpslReq)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set max port share level via update: %s", err))
+ return
+ }
+ data.MaxPortShareLevel = types.StringValue(string(mpslResp.MaxPortShareLevel))
+ }
+
+ // TODO: Remove this update call (and the attribute) once the provider
+ // requires a Coder version where this flag has been removed.
+ if data.UseClassicParameterFlow.IsUnknown() {
+ data.UseClassicParameterFlow = types.BoolValue(templateResp.UseClassicParameterFlow)
+ } else if data.UseClassicParameterFlow.ValueBool() == templateResp.UseClassicParameterFlow {
+ tflog.Info(ctx, "use classic parameter flow set to default, not updating")
+ } else {
+ ucpfReq := data.toUpdateRequest(ctx, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ ucpfResp, err := client.UpdateTemplateMeta(ctx, data.ID.ValueUUID(), *ucpfReq)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to set use classic parameter flow via update: %s", err))
+ return
+ }
+ data.UseClassicParameterFlow = types.BoolValue(ucpfResp.UseClassicParameterFlow)
+ }
+
resp.Diagnostics.Append(data.Versions.setPrivateState(ctx, resp.Private)...)
if resp.Diagnostics.HasError() {
return
@@ -570,6 +645,11 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r
template, err := client.Template(ctx, templateID)
if err != nil {
+ if isNotFound(err) {
+ resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Template with ID %s not found. Marking as deleted.", templateID.String()))
+ resp.State.RemoveResource(ctx)
+ return
+ }
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err))
return
}
@@ -579,6 +659,8 @@ func (r *TemplateResource) Read(ctx context.Context, req resource.ReadRequest, r
resp.Diagnostics.Append(diag...)
return
}
+ data.MaxPortShareLevel = types.StringValue(string(template.MaxPortShareLevel))
+ data.UseClassicParameterFlow = types.BoolValue(template.UseClassicParameterFlow)
if !data.ACL.IsNull() {
tflog.Info(ctx, "reading template ACL")
@@ -657,7 +739,7 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
// This is required, as the API will reject no-diff updates.
if templateMetadataChanged {
tflog.Info(ctx, "change in template metadata detected, updating.")
- updateReq := newState.toUpdateRequest(ctx, resp)
+ updateReq := newState.toUpdateRequest(ctx, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
@@ -678,7 +760,13 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
if resp.Diagnostics.HasError() {
return
}
- err := client.UpdateTemplateACL(ctx, templateID, convertACLToRequest(acl))
+ curACL, err := client.TemplateACL(ctx, templateID)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template ACL: %s", err))
+ return
+ }
+
+ err = client.UpdateTemplateACL(ctx, templateID, convertACLToRequest(curACL, acl))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to update template ACL: %s", err))
return
@@ -689,13 +777,13 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
for idx := range newState.Versions {
if newState.Versions[idx].ID.IsUnknown() {
tflog.Info(ctx, "discovered a new or modified template version")
- uploadResp, err := newVersion(ctx, client, newVersionRequest{
+ uploadResp, err, logs := newVersion(ctx, client, newVersionRequest{
Version: &newState.Versions[idx],
OrganizationID: orgID,
TemplateID: &templateID,
})
if err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
+ resp.Diagnostics.AddError("Provisioner Error", formatLogs(err, logs))
return
}
versionResp, err := client.TemplateVersion(ctx, uploadResp.ID)
@@ -740,6 +828,14 @@ func (r *TemplateResource) Update(ctx context.Context, req resource.UpdateReques
}
}
}
+ // TODO(ethanndickson): Remove this once the provider requires a Coder
+ // deployment running `v2.15.0` or later.
+ templateResp, err := client.Template(ctx, templateID)
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template: %s", err))
+ return
+ }
+ newState.MaxPortShareLevel = types.StringValue(string(templateResp.MaxPortShareLevel))
resp.Diagnostics.Append(newState.Versions.setPrivateState(ctx, resp.Private)...)
if resp.Diagnostics.HasError() {
@@ -802,60 +898,66 @@ func (r *TemplateResource) ConfigValidators(context.Context) []resource.ConfigVa
return []resource.ConfigValidator{}
}
-type activeVersionValidator struct{}
+type versionsValidator struct{}
-func NewActiveVersionValidator() validator.List {
- return &activeVersionValidator{}
+func NewVersionsValidator() validator.List {
+ return &versionsValidator{}
}
// Description implements validator.List.
-func (a *activeVersionValidator) Description(ctx context.Context) string {
+func (a *versionsValidator) Description(ctx context.Context) string {
return a.MarkdownDescription(ctx)
}
// MarkdownDescription implements validator.List.
-func (a *activeVersionValidator) MarkdownDescription(context.Context) string {
- return "Validate that exactly one template version has active set to true."
+func (a *versionsValidator) MarkdownDescription(context.Context) string {
+ return "Validate that template version names are unique and that at most one version is active."
}
// ValidateList implements validator.List.
-func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
+func (a *versionsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+
var data []TemplateVersion
resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &data, false)...)
if resp.Diagnostics.HasError() {
return
}
- // Check if only one item in Version has active set to true
- active := false
- for _, version := range data {
- if version.Active.ValueBool() {
- if active {
- resp.Diagnostics.AddError("Client Error", "Only one template version can be active at a time.")
- return
- }
- active = true
- }
- }
- if !active {
- resp.Diagnostics.AddError("Client Error", "At least one template version must be active.")
- }
-
// Check all versions have unique names
uniqueNames := make(map[string]struct{})
for _, version := range data {
- if version.Name.IsNull() {
+ if version.Name.IsNull() || version.Name.IsUnknown() {
continue
}
if _, ok := uniqueNames[version.Name.ValueString()]; ok {
- resp.Diagnostics.AddError("Client Error", "Template version names must be unique.")
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Template version names must be unique. `%s` appears twice.", version.Name.ValueString()))
return
}
uniqueNames[version.Name.ValueString()] = struct{}{}
}
+
+ // Ensure at most one version is active
+ active := false
+ for _, version := range data {
+ // `active` defaults to false, so if it's null or unknown, this is Terraform
+ // requesting an early validation.
+ if version.Active.IsNull() || version.Active.IsUnknown() {
+ continue
+ }
+ if version.Active.ValueBool() {
+ if active {
+ resp.Diagnostics.AddError("Client Error", "Only one template version can be active at a time.")
+ return
+ }
+ active = true
+ }
+ }
}
-var _ validator.List = &activeVersionValidator{}
+var _ validator.List = &versionsValidator{}
type versionsPlanModifier struct{}
@@ -882,6 +984,12 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
return
}
+ hasActiveVersion, diag := hasOneActiveVersion(configVersions)
+ if diag.HasError() {
+ resp.Diagnostics.Append(diag...)
+ return
+ }
+
for i := range planVersions {
hash, err := computeDirectoryHash(planVersions[i].Directory.ValueString())
if err != nil {
@@ -900,6 +1008,13 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
// If this is the first read, init the private state value
if lvBytes == nil {
lv = make(LastVersionsByHash)
+ // If there's no prior private state, this might be resource creation,
+ // in which case one version must be active.
+ if !hasActiveVersion {
+ resp.Diagnostics.AddError("Client Error", "At least one template version must be active when creating a"+
+ " `coderd_template` resource.\n(Subsequent resource updates can be made without an active template in the list).")
+ return
+ }
} else {
err := json.Unmarshal(lvBytes, &lv)
if err != nil {
@@ -908,9 +1023,34 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
}
}
- planVersions.reconcileVersionIDs(lv, configVersions)
+ diag = planVersions.reconcileVersionIDs(lv, configVersions, hasActiveVersion)
+ if diag.HasError() {
+ resp.Diagnostics.Append(diag...)
+ return
+ }
- resp.PlanValue, resp.Diagnostics = types.ListValueFrom(ctx, req.PlanValue.ElementType(ctx), planVersions)
+ resp.PlanValue, diag = types.ListValueFrom(ctx, req.PlanValue.ElementType(ctx), planVersions)
+ if diag.HasError() {
+ resp.Diagnostics.Append(diag...)
+ }
+}
+
+func hasOneActiveVersion(data Versions) (hasActiveVersion bool, diags diag.Diagnostics) {
+ active := false
+ for _, version := range data {
+ if version.Active.IsNull() || version.Active.IsUnknown() {
+ // If null or unknown, the value will be defaulted to false
+ continue
+ }
+ if version.Active.ValueBool() {
+ if active {
+ diags.AddError("Client Error", "Only one template version can be active at a time.")
+ return
+ }
+ active = true
+ }
+ }
+ return active, diags
}
func NewVersionsPlanModifier() planmodifier.List {
@@ -938,13 +1078,14 @@ func uploadDirectory(ctx context.Context, client *codersdk.Client, logger slog.L
return &resp, nil
}
-func waitForJob(ctx context.Context, client *codersdk.Client, version *codersdk.TemplateVersion) error {
+func waitForJob(ctx context.Context, client *codersdk.Client, version *codersdk.TemplateVersion) ([]codersdk.ProvisionerJobLog, error) {
const maxRetries = 3
+ var jobLogs []codersdk.ProvisionerJobLog
for retries := 0; retries < maxRetries; retries++ {
logs, closer, err := client.TemplateVersionLogsAfter(ctx, version.ID, 0)
defer closer.Close()
if err != nil {
- return fmt.Errorf("begin streaming logs: %w", err)
+ return jobLogs, fmt.Errorf("begin streaming logs: %w", err)
}
for {
logs, ok := <-logs
@@ -958,21 +1099,24 @@ func waitForJob(ctx context.Context, client *codersdk.Client, version *codersdk.
"level": logs.Level,
"created_at": logs.CreatedAt,
})
+ if logs.Output != "" {
+ jobLogs = append(jobLogs, logs)
+ }
}
latestResp, err := client.TemplateVersion(ctx, version.ID)
if err != nil {
- return err
+ return jobLogs, err
}
if latestResp.Job.Status.Active() {
tflog.Warn(ctx, fmt.Sprintf("provisioner job still active, continuing to wait...: %s", latestResp.Job.Status))
continue
}
if latestResp.Job.Status != codersdk.ProvisionerJobSucceeded {
- return fmt.Errorf("provisioner job did not succeed: %s (%s)", latestResp.Job.Status, latestResp.Job.Error)
+ return jobLogs, fmt.Errorf("provisioner job did not succeed: %s (%s)", latestResp.Job.Status, latestResp.Job.Error)
}
- return nil
+ return jobLogs, nil
}
- return fmt.Errorf("provisioner job did not complete after %d retries", maxRetries)
+ return jobLogs, fmt.Errorf("provisioner job did not complete after %d retries", maxRetries)
}
type newVersionRequest struct {
@@ -981,22 +1125,23 @@ type newVersionRequest struct {
TemplateID *uuid.UUID
}
-func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequest) (*codersdk.TemplateVersion, error) {
+func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequest) (*codersdk.TemplateVersion, error, []codersdk.ProvisionerJobLog) {
+ var logs []codersdk.ProvisionerJobLog
directory := req.Version.Directory.ValueString()
tflog.Info(ctx, "uploading directory")
uploadResp, err := uploadDirectory(ctx, client, slog.Make(newTFLogSink(ctx)), directory)
if err != nil {
- return nil, fmt.Errorf("failed to upload directory: %s", err)
+ return nil, fmt.Errorf("failed to upload directory: %s", err), logs
}
tflog.Info(ctx, "successfully uploaded directory")
tflog.Info(ctx, "discovering and parsing vars files")
varFiles, err := codersdk.DiscoverVarsFiles(directory)
if err != nil {
- return nil, fmt.Errorf("failed to discover vars files: %s", err)
+ return nil, fmt.Errorf("failed to discover vars files: %s", err), logs
}
vars, err := codersdk.ParseUserVariableValues(varFiles, "", []string{})
if err != nil {
- return nil, fmt.Errorf("failed to parse user variable values: %s", err)
+ return nil, fmt.Errorf("failed to parse user variable values: %s", err), logs
}
tflog.Info(ctx, "discovered and parsed vars files", map[string]any{
"vars": vars,
@@ -1007,6 +1152,10 @@ func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequ
Value: variable.Value.ValueString(),
})
}
+ provTags := make(map[string]string, len(req.Version.ProvisionerTags))
+ for _, provisionerTag := range req.Version.ProvisionerTags {
+ provTags[provisionerTag.Name.ValueString()] = provisionerTag.Value.ValueString()
+ }
tmplVerReq := codersdk.CreateTemplateVersionRequest{
Name: req.Version.Name.ValueString(),
Message: req.Version.Message.ValueString(),
@@ -1014,6 +1163,7 @@ func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequ
Provisioner: codersdk.ProvisionerTypeTerraform,
FileID: uploadResp.ID,
UserVariableValues: vars,
+ ProvisionerTags: provTags,
}
if req.TemplateID != nil {
tmplVerReq.TemplateID = *req.TemplateID
@@ -1021,15 +1171,15 @@ func newVersion(ctx context.Context, client *codersdk.Client, req newVersionRequ
tflog.Info(ctx, "creating template version")
versionResp, err := client.CreateTemplateVersion(ctx, req.OrganizationID, tmplVerReq)
if err != nil {
- return nil, fmt.Errorf("failed to create template version: %s", err)
+ return nil, fmt.Errorf("failed to create template version: %s", err), logs
}
tflog.Info(ctx, "waiting for template version import job.")
- err = waitForJob(ctx, client, &versionResp)
+ logs, err = waitForJob(ctx, client, &versionResp)
if err != nil {
- return nil, fmt.Errorf("failed to wait for job: %s", err)
+ return nil, fmt.Errorf("failed to wait for job: %s", err), logs
}
tflog.Info(ctx, "successfully created template version")
- return &versionResp, nil
+ return &versionResp, nil, logs
}
func markActive(ctx context.Context, client *codersdk.Client, templateID uuid.UUID, versionID uuid.UUID) error {
@@ -1041,21 +1191,33 @@ func markActive(ctx context.Context, client *codersdk.Client, templateID uuid.UU
ID: versionID,
})
if err != nil {
- return fmt.Errorf("Failed to update active template version: %s", err)
+ return fmt.Errorf("failed to update active template version: %s", err)
}
tflog.Info(ctx, "marked template version as active")
return nil
}
-func convertACLToRequest(permissions ACL) codersdk.UpdateTemplateACL {
+func convertACLToRequest(curACL codersdk.TemplateACL, newACL ACL) codersdk.UpdateTemplateACL {
var userPerms = make(map[string]codersdk.TemplateRole)
- for _, perm := range permissions.UserPermissions {
+ for _, perm := range newACL.UserPermissions {
userPerms[perm.ID.ValueString()] = codersdk.TemplateRole(perm.Role.ValueString())
}
var groupPerms = make(map[string]codersdk.TemplateRole)
- for _, perm := range permissions.GroupPermissions {
+ for _, perm := range newACL.GroupPermissions {
groupPerms[perm.ID.ValueString()] = codersdk.TemplateRole(perm.Role.ValueString())
}
+ // For each user or group to remove, we need to set their role to empty
+ // string.
+ for _, perm := range curACL.Users {
+ if _, ok := userPerms[perm.ID.String()]; !ok {
+ userPerms[perm.ID.String()] = ""
+ }
+ }
+ for _, perm := range curACL.Groups {
+ if _, ok := groupPerms[perm.ID.String()]; !ok {
+ groupPerms[perm.ID.String()] = ""
+ }
+ }
return codersdk.UpdateTemplateACL{
UserPerms: userPerms,
GroupPerms: groupPerms,
@@ -1112,25 +1274,27 @@ func (r *TemplateResourceModel) readResponse(ctx context.Context, template *code
r.TimeTilDormantAutoDeleteMillis = types.Int64Value(template.TimeTilDormantAutoDeleteMillis)
r.RequireActiveVersion = types.BoolValue(template.RequireActiveVersion)
r.DeprecationMessage = types.StringValue(template.DeprecationMessage)
+ // TODO(ethanndickson): MaxPortShareLevel deliberately omitted, as it can't
+ // be set during a create request, and we call this during `Create`.
return nil
}
-func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resource.UpdateResponse) *codersdk.UpdateTemplateMeta {
+func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, diag *diag.Diagnostics) *codersdk.UpdateTemplateMeta {
var days []string
- resp.Diagnostics.Append(
+ diag.Append(
r.AutostartPermittedDaysOfWeek.ElementsAs(ctx, &days, false)...,
)
- if resp.Diagnostics.HasError() {
+ if diag.HasError() {
return nil
}
autoStart := &codersdk.TemplateAutostartRequirement{
DaysOfWeek: days,
}
var reqs AutostopRequirement
- resp.Diagnostics.Append(
+ diag.Append(
r.AutostopRequirement.As(ctx, &reqs, basetypes.ObjectAsOptions{})...,
)
- if resp.Diagnostics.HasError() {
+ if diag.HasError() {
return nil
}
autoStop := &codersdk.TemplateAutostopRequirement{
@@ -1154,6 +1318,8 @@ func (r *TemplateResourceModel) toUpdateRequest(ctx context.Context, resp *resou
TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64(),
RequireActiveVersion: r.RequireActiveVersion.ValueBool(),
DeprecationMessage: r.DeprecationMessage.ValueStringPointer(),
+ MaxPortShareLevel: ptr.Ref(codersdk.WorkspaceAgentPortShareLevel(r.MaxPortShareLevel.ValueString())),
+ UseClassicParameterFlow: ptr.Ref(r.UseClassicParameterFlow.ValueBool()),
// If we're managing ACL, we want to delete the everyone group
DisableEveryoneGroupAccess: !r.ACL.IsNull(),
}
@@ -1198,6 +1364,7 @@ func (r *TemplateResourceModel) toCreateRequest(ctx context.Context, resp *resou
TimeTilDormantMillis: r.TimeTilDormantMillis.ValueInt64Pointer(),
TimeTilDormantAutoDeleteMillis: r.TimeTilDormantAutoDeleteMillis.ValueInt64Pointer(),
RequireActiveVersion: r.RequireActiveVersion.ValueBool(),
+ UseClassicParameterFlow: r.UseClassicParameterFlow.ValueBoolPointer(),
DisableEveryoneGroupAccess: !r.ACL.IsNull(),
}
}
@@ -1207,8 +1374,10 @@ type LastVersionsByHash = map[string][]PreviousTemplateVersion
var LastVersionsKey = "last_versions"
type PreviousTemplateVersion struct {
- ID uuid.UUID `json:"id"`
- Name string `json:"name"`
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ TFVars map[string]string `json:"tf_vars"`
+ Active bool `json:"active"`
}
type privateState interface {
@@ -1220,18 +1389,26 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d
lv := make(LastVersionsByHash)
for _, version := range v {
vbh, ok := lv[version.DirectoryHash.ValueString()]
+ tfVars := make(map[string]string, len(version.TerraformVariables))
+ for _, tfVar := range version.TerraformVariables {
+ tfVars[tfVar.Name.ValueString()] = tfVar.Value.ValueString()
+ }
// Store the IDs and names of all versions with the same directory hash,
// in the order they appear
if ok {
lv[version.DirectoryHash.ValueString()] = append(vbh, PreviousTemplateVersion{
- ID: version.ID.ValueUUID(),
- Name: version.Name.ValueString(),
+ ID: version.ID.ValueUUID(),
+ Name: version.Name.ValueString(),
+ TFVars: tfVars,
+ Active: version.Active.ValueBool(),
})
} else {
lv[version.DirectoryHash.ValueString()] = []PreviousTemplateVersion{
{
- ID: version.ID.ValueUUID(),
- Name: version.Name.ValueString(),
+ ID: version.ID.ValueUUID(),
+ Name: version.Name.ValueString(),
+ TFVars: tfVars,
+ Active: version.Active.ValueBool(),
},
}
}
@@ -1244,7 +1421,14 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d
return ps.SetKey(ctx, LastVersionsKey, lvBytes)
}
-func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVersions Versions) {
+func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVersions Versions, hasOneActiveVersion bool) (diag diag.Diagnostics) {
+ // We remove versions that we've matched from `lv`, so make a copy for
+ // resolving tfvar changes at the end.
+ fullLv := make(LastVersionsByHash)
+ for k, v := range lv {
+ fullLv[k] = slices.Clone(v)
+ }
+
for i := range planVersions {
prevList, ok := lv[planVersions[i].DirectoryHash.ValueString()]
// If not in state, mark as known after apply since we'll create a new version.
@@ -1284,4 +1468,92 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe
lv[planVersions[i].DirectoryHash.ValueString()] = prevList[1:]
}
}
+
+ // If only the Terraform variables have changed,
+ // we need to create a new version with the new variables.
+ for i := range planVersions {
+ if !planVersions[i].ID.IsUnknown() {
+ prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()]
+ if !ok {
+ continue
+ }
+ if tfVariablesChanged(prevs, &planVersions[i]) {
+ planVersions[i].ID = NewUUIDUnknown()
+ // We could always set the name to unknown here, to generate a
+ // random one (this is what the Web UI currently does when
+ // only updating tfvars).
+ // However, I think it'd be weird if the provider just started
+ // ignoring the name you set in the config, we'll instead
+ // require that users update the name if they update the tfvars.
+ if configVersions[i].Name.IsNull() {
+ planVersions[i].Name = types.StringUnknown()
+ }
+ }
+ }
+ }
+
+ // If a version was deactivated, and no active version was set, we need to
+ // return an error to avoid a post-apply plan being non-empty.
+ if !hasOneActiveVersion {
+ for i := range planVersions {
+ if !planVersions[i].ID.IsUnknown() {
+ prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()]
+ if !ok {
+ continue
+ }
+ if versionDeactivated(prevs, &planVersions[i]) {
+ diag.AddError("Client Error", "Plan could not determine which version should be active.\n"+
+ "Either specify an active version or modify the contents of the previously active version before marking it as inactive.")
+ return diag
+ }
+ }
+ }
+ }
+ return diag
+}
+
+func versionDeactivated(prevs []PreviousTemplateVersion, planned *TemplateVersion) bool {
+ for _, prev := range prevs {
+ if prev.ID == planned.ID.ValueUUID() {
+ if prev.Active &&
+ !planned.Active.IsNull() &&
+ !planned.Active.IsUnknown() &&
+ !planned.Active.ValueBool() {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func tfVariablesChanged(prevs []PreviousTemplateVersion, planned *TemplateVersion) bool {
+ for _, prev := range prevs {
+ if prev.ID == planned.ID.ValueUUID() {
+ // If the previous version has no TFVars, then it was created using
+ // an older provider version.
+ if prev.TFVars == nil {
+ return true
+ }
+ for _, tfVar := range planned.TerraformVariables {
+ if prev.TFVars[tfVar.Name.ValueString()] != tfVar.Value.ValueString() {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ return true
+
+}
+
+func formatLogs(err error, logs []codersdk.ProvisionerJobLog) string {
+ var b strings.Builder
+ b.WriteString(err.Error() + "\n")
+ for _, log := range logs {
+ if !log.CreatedAt.IsZero() {
+ b.WriteString(log.CreatedAt.Local().Format("2006-01-02 15:04:05.000Z07:00") + " ")
+ }
+ b.WriteString(log.Output + "\n")
+ }
+ return b.String()
}
diff --git a/internal/provider/template_resource_test.go b/internal/provider/template_resource_test.go
index b8b7f19..b21e1c0 100644
--- a/internal/provider/template_resource_test.go
+++ b/internal/provider/template_resource_test.go
@@ -17,15 +17,17 @@ import (
cp "github.com/otiai10/copy"
"github.com/stretchr/testify/require"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/terraform-provider-coderd/integration"
)
func TestAccTemplateResource(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
+ ctx := t.Context()
client := integration.StartCoder(ctx, t, "template_acc", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
@@ -42,12 +44,12 @@ func TestAccTemplateResource(t *testing.T) {
cfg1 := testAccTemplateResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Name: PtrTo("example-template"),
+ Name: ptr.Ref("example-template"),
Versions: []testAccTemplateVersionConfig{
{
// Auto-generated version name
Directory: &exTemplateOne,
- Active: PtrTo(true),
+ Active: ptr.Ref(true),
},
},
ACL: testAccTemplateACLConfig{
@@ -57,28 +59,28 @@ func TestAccTemplateResource(t *testing.T) {
cfg2 := cfg1
cfg2.Versions = slices.Clone(cfg2.Versions)
- cfg2.Name = PtrTo("example-template-new")
+ cfg2.Name = ptr.Ref("example-template-new")
cfg2.Versions[0].Directory = &exTemplateTwo
- cfg2.Versions[0].Name = PtrTo("new")
+ cfg2.Versions[0].Name = ptr.Ref("new")
cfg3 := cfg2
cfg3.Versions = slices.Clone(cfg3.Versions)
cfg3.Versions = append(cfg3.Versions, testAccTemplateVersionConfig{
- Name: PtrTo("legacy-template"),
+ Name: ptr.Ref("legacy-template"),
Directory: &exTemplateOne,
- Active: PtrTo(false),
+ Active: ptr.Ref(false),
TerraformVariables: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo("name"),
- Value: PtrTo("world"),
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world"),
},
},
})
cfg4 := cfg3
cfg4.Versions = slices.Clone(cfg4.Versions)
- cfg4.Versions[0].Active = PtrTo(false)
- cfg4.Versions[1].Active = PtrTo(true)
+ cfg4.Versions[0].Active = ptr.Ref(false)
+ cfg4.Versions[1].Active = ptr.Ref(true)
cfg5 := cfg4
cfg5.Versions = slices.Clone(cfg5.Versions)
@@ -113,6 +115,8 @@ func TestAccTemplateResource(t *testing.T) {
resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_ms", "0"),
resource.TestCheckResourceAttr("coderd_template.test", "time_til_dormant_autodelete_ms", "0"),
resource.TestCheckResourceAttr("coderd_template.test", "require_active_version", "false"),
+ resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
+ resource.TestCheckResourceAttr("coderd_template.test", "use_classic_parameter_flow", "false"),
resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
"name": regexp.MustCompile(".+"),
"id": regexp.MustCompile(".+"),
@@ -239,28 +243,29 @@ func TestAccTemplateResource(t *testing.T) {
cfg1 := testAccTemplateResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Name: PtrTo("example-template2"),
+ Name: ptr.Ref("example-template2"),
Versions: []testAccTemplateVersionConfig{
{
// Auto-generated version name
- Directory: PtrTo("../../integration/template-test/example-template-2/"),
+ Directory: &exTemplateTwo,
TerraformVariables: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo("name"),
- Value: PtrTo("world"),
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world"),
},
},
- Active: PtrTo(true),
+ Active: ptr.Ref(true),
},
{
// Auto-generated version name
- Directory: PtrTo("../../integration/template-test/example-template-2/"),
+ Directory: &exTemplateTwo,
TerraformVariables: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo("name"),
- Value: PtrTo("world"),
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world"),
},
},
+ Active: ptr.Ref(false),
},
},
ACL: testAccTemplateACLConfig{
@@ -270,21 +275,30 @@ func TestAccTemplateResource(t *testing.T) {
cfg2 := cfg1
cfg2.Versions = slices.Clone(cfg2.Versions)
- cfg2.Versions[1].Name = PtrTo("new-name")
+ cfg2.Versions[1].Name = ptr.Ref("new-name")
cfg3 := cfg2
cfg3.Versions = slices.Clone(cfg3.Versions)
- cfg3.Versions[0].Name = PtrTo("new-name-one")
- cfg3.Versions[1].Name = PtrTo("new-name-two")
+ cfg3.Versions[0].Name = ptr.Ref("new-name-one")
+ cfg3.Versions[1].Name = ptr.Ref("new-name-two")
cfg3.Versions[0], cfg3.Versions[1] = cfg3.Versions[1], cfg3.Versions[0]
cfg4 := cfg1
cfg4.Versions = slices.Clone(cfg4.Versions)
- cfg4.Versions[0].Directory = PtrTo("../../integration/template-test/example-template/")
+ cfg4.Versions[0].Directory = &exTemplateOne
cfg5 := cfg4
cfg5.Versions = slices.Clone(cfg5.Versions)
- cfg5.Versions[1].Directory = PtrTo("../../integration/template-test/example-template/")
+ cfg5.Versions[1].Directory = &exTemplateOne
+
+ cfg6 := cfg5
+ cfg6.Versions = slices.Clone(cfg6.Versions)
+ cfg6.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{
+ {
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world2"),
+ },
+ }
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
@@ -343,177 +357,523 @@ func TestAccTemplateResource(t *testing.T) {
testAccCheckNumTemplateVersions(ctx, client, 4),
),
},
+ // Update the Terraform variables of the first version
+ {
+ Config: cfg6.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 5),
+ ),
+ },
+ },
+ })
+ })
+
+ t.Run("AutoGenNameUpdateTFVars", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template3"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateTwo,
+ TerraformVariables: []testAccTemplateKeyValueConfig{
+ {
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world"),
+ },
+ },
+ Active: ptr.Ref(true),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ null: true,
+ },
+ }
+
+ cfg2 := cfg1
+ cfg2.Versions = slices.Clone(cfg2.Versions)
+ cfg2.Versions[0].TerraformVariables = []testAccTemplateKeyValueConfig{
+ {
+ Key: ptr.Ref("name"),
+ Value: ptr.Ref("world2"),
+ },
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg1.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 1),
+ ),
+ },
+ {
+ Config: cfg2.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 2),
+ ),
+ },
+ },
+ })
+ })
+
+ t.Run("CreateWithNoActiveVersionErrors", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(false),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ null: true,
+ },
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg1.String(t),
+ ExpectError: regexp.MustCompile("At least one template version must be active when creating"),
+ },
+ },
+ })
+ })
+
+ t.Run("AmbiguousActiveVersionResolvedByModifying", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ null: true,
+ },
+ }
+
+ cfg2 := cfg1
+ cfg2.Versions = slices.Clone(cfg2.Versions)
+ cfg2.Versions[0].Active = ptr.Ref(false)
+
+ cfg3 := cfg2
+ cfg3.Versions = slices.Clone(cfg3.Versions)
+ cfg3.Versions[0].Directory = &exTemplateTwo
+
+ cfg2b := cfg1
+ cfg2b.Versions = slices.Clone(cfg2b.Versions)
+ cfg2b.Versions = append(cfg2b.Versions, testAccTemplateVersionConfig{
+ Directory: &exTemplateTwo,
+ Active: ptr.Ref(false),
+ })
+
+ cfg3b := cfg2b
+ cfg3b.Versions = slices.Clone(cfg3b.Versions)
+ cfg3b.Versions[1].Active = ptr.Ref(true)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg1.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 1),
+ ),
+ },
+ // With an unmodified version deactivated, it's not clear what
+ // the active version should be.
+ {
+ Config: cfg2.String(t),
+ ExpectError: regexp.MustCompile("Plan could not determine which version should be active."),
+ },
+ // If we modify the version, a new version will be created on `coderd`,
+ // and the old version can remain active.
+ {
+ Config: cfg3.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 2),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
+ "active": regexp.MustCompile("false"),
+ }),
+ ),
+ },
+ },
+ })
+ })
+
+ t.Run("AmbiguousActiveVersionResolvedByCreatingNewVersion", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ null: true,
+ },
+ }
+
+ cfg2 := cfg1
+ cfg2.Versions = slices.Clone(cfg2.Versions)
+ cfg2.Versions[0].Active = ptr.Ref(false)
+ cfg2.Versions = append(cfg2.Versions, testAccTemplateVersionConfig{
+ Directory: &exTemplateTwo,
+ Active: ptr.Ref(false),
+ })
+
+ cfg3 := cfg2
+ cfg3.Versions = slices.Clone(cfg3.Versions)
+ cfg3.Versions[1].Active = ptr.Ref(true)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg1.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 1),
+ ),
+ },
+ // Adding a new version that's not active doesn't help
+ {
+ Config: cfg2.String(t),
+ ExpectError: regexp.MustCompile("Plan could not determine which version should be active."),
+ },
+ // Making that new version active will fix the issue
+ {
+ Config: cfg3.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 2),
+ ),
+ },
+ },
+ })
+ })
+
+ t.Run("PushNewInactiveVersion", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ null: true,
+ },
+ }
+
+ cfg2 := cfg1
+ cfg2.Versions = slices.Clone(cfg2.Versions)
+ cfg2.Versions[0].Active = ptr.Ref(false)
+ cfg2.Versions[0].Directory = &exTemplateTwo
+
+ cfg3 := cfg2
+ cfg3.Versions = slices.Clone(cfg3.Versions)
+ cfg3.Versions[0].Active = ptr.Ref(true)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ // Create one active version
+ {
+ Config: cfg1.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 1),
+ ),
+ },
+ // Modify an existing version, make it inactive
+ {
+ Config: cfg2.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 2),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
+ "active": regexp.MustCompile("false"),
+ }),
+ ),
+ },
+ // Make that modification active
+ {
+ Config: cfg3.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ testAccCheckNumTemplateVersions(ctx, client, 2),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "versions.*", map[string]*regexp.Regexp{
+ "active": regexp.MustCompile("true"),
+ }),
+ ),
+ },
},
})
})
}
func TestAccTemplateResourceEnterprise(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
- client := integration.StartCoder(ctx, t, "template_acc", true)
+ ctx := t.Context()
+ client := integration.StartCoder(ctx, t, "template_resource_acc", true)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
- cfg1 := testAccTemplateResourceConfig{
- URL: client.URL.String(),
- Token: client.SessionToken(),
- Name: PtrTo("example-template"),
- Versions: []testAccTemplateVersionConfig{
- {
- // Auto-generated version name
- Directory: PtrTo("../../integration/template-test/example-template"),
- Active: PtrTo(true),
- // TODO(ethanndickson): Remove this when we add in `*.tfvars` parsing
- TerraformVariables: []testAccTemplateKeyValueConfig{
+ group, err := client.CreateGroup(ctx, firstUser.OrganizationIDs[0], codersdk.CreateGroupRequest{
+ Name: "bosses",
+ QuotaAllowance: 200,
+ })
+ require.NoError(t, err)
+
+ exTemplateOne := t.TempDir()
+ err = cp.Copy("../../integration/template-test/example-template", exTemplateOne)
+ require.NoError(t, err)
+
+ t.Run("BasicUsage", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ // Auto-generated version name
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
+ },
+ },
+ ACL: testAccTemplateACLConfig{
+ GroupACL: []testAccTemplateKeyValueConfig{
+ {
+ Key: ptr.Ref(firstUser.OrganizationIDs[0].String()),
+ Value: ptr.Ref("use"),
+ },
+ {
+ Key: ptr.Ref(group.ID.String()),
+ Value: ptr.Ref("admin"),
+ },
+ },
+ UserACL: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo("name"),
- Value: PtrTo("world"),
+ Key: ptr.Ref(firstUser.ID.String()),
+ Value: ptr.Ref("admin"),
},
},
},
- },
- ACL: testAccTemplateACLConfig{
- GroupACL: []testAccTemplateKeyValueConfig{
+ }
+
+ cfg2 := cfg1
+ cfg2.ACL.GroupACL = slices.Clone(cfg2.ACL.GroupACL[1:])
+ cfg2.MaxPortShareLevel = ptr.Ref("owner")
+
+ cfg3 := cfg2
+ cfg3.ACL.null = true
+ cfg3.MaxPortShareLevel = ptr.Ref("public")
+
+ cfg4 := cfg3
+ cfg4.AllowUserAutostart = ptr.Ref(false)
+ cfg4.AutostopRequirement = testAccAutostopRequirementConfig{
+ DaysOfWeek: ptr.Ref([]string{"monday", "tuesday"}),
+ Weeks: ptr.Ref(int64(2)),
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
{
- Key: PtrTo(firstUser.OrganizationIDs[0].String()),
- Value: PtrTo("use"),
+ Config: cfg1.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
+ resource.TestCheckResourceAttr("coderd_template.test", "acl.groups.#", "2"),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{
+ "id": regexp.MustCompile(firstUser.OrganizationIDs[0].String()),
+ "role": regexp.MustCompile("^use$"),
+ }),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{
+ "id": regexp.MustCompile(group.ID.String()),
+ "role": regexp.MustCompile("^admin$"),
+ }),
+ resource.TestCheckResourceAttr("coderd_template.test", "acl.users.#", "1"),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{
+ "id": regexp.MustCompile(firstUser.ID.String()),
+ "role": regexp.MustCompile("^admin$"),
+ }),
+ ),
},
- },
- UserACL: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo(firstUser.ID.String()),
- Value: PtrTo("admin"),
+ Config: cfg2.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
+ resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{
+ "id": regexp.MustCompile(firstUser.ID.String()),
+ "role": regexp.MustCompile("^admin$"),
+ }),
+ ),
+ },
+ {
+ Config: cfg3.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "public"),
+ resource.TestCheckNoResourceAttr("coderd_template.test", "acl"),
+ func(s *terraform.State) error {
+ templates, err := client.Templates(ctx, codersdk.TemplateFilter{})
+ if err != nil {
+ return err
+ }
+ if len(templates) != 1 {
+ return fmt.Errorf("expected 1 template, got %d", len(templates))
+ }
+ acl, err := client.TemplateACL(ctx, templates[0].ID)
+ if err != nil {
+ return err
+ }
+ if len(acl.Groups) != 1 {
+ return fmt.Errorf("expected 1 group ACL, got %d", len(acl.Groups))
+ }
+ if acl.Groups[0].Role != "admin" && acl.Groups[0].ID != group.ID {
+ return fmt.Errorf("expected group ACL to be 'use' for %s, got %s", firstUser.OrganizationIDs[0].String(), acl.Groups[0].Role)
+ }
+ if len(acl.Users) != 1 {
+ return fmt.Errorf("expected 1 user ACL, got %d", len(acl.Users))
+ }
+ if acl.Users[0].Role != "admin" && acl.Users[0].ID != firstUser.ID {
+ return fmt.Errorf("expected user ACL to be 'admin' for %s, got %s", firstUser.ID.String(), acl.Users[0].Role)
+ }
+ return nil
+ },
+ ),
+ },
+ {
+ Config: cfg4.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "false"),
+ resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "2"),
+ resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "2"),
+ ),
},
},
- },
- }
-
- cfg2 := cfg1
- cfg2.ACL.null = true
-
- cfg3 := cfg2
- cfg3.AllowUserAutostart = PtrTo(false)
- cfg3.AutostopRequirement = testAccAutostopRequirementConfig{
- DaysOfWeek: PtrTo([]string{"monday", "tuesday"}),
- Weeks: PtrTo(int64(2)),
- }
+ })
+ })
- resource.Test(t, resource.TestCase{
- PreCheck: func() { testAccPreCheck(t) },
- IsUnitTest: true,
- ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
- Steps: []resource.TestStep{
- {
- Config: cfg1.String(t),
- Check: resource.ComposeAggregateTestCheckFunc(
- resource.TestCheckResourceAttr("coderd_template.test", "acl.groups.#", "1"),
- resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.groups.*", map[string]*regexp.Regexp{
- "id": regexp.MustCompile(".+"),
- "role": regexp.MustCompile("^use$"),
- }),
- resource.TestMatchTypeSetElemNestedAttrs("coderd_template.test", "acl.users.*", map[string]*regexp.Regexp{
- "id": regexp.MustCompile(".+"),
- "role": regexp.MustCompile("^admin$"),
- }),
- ),
- },
- {
- Config: cfg2.String(t),
- Check: resource.ComposeAggregateTestCheckFunc(
- resource.TestCheckNoResourceAttr("coderd_template.test", "acl"),
- func(s *terraform.State) error {
- templates, err := client.Templates(ctx, codersdk.TemplateFilter{})
- if err != nil {
- return err
- }
- if len(templates) != 1 {
- return fmt.Errorf("expected 1 template, got %d", len(templates))
- }
- acl, err := client.TemplateACL(ctx, templates[0].ID)
- if err != nil {
- return err
- }
- if len(acl.Groups) != 1 {
- return fmt.Errorf("expected 1 group ACL, got %d", len(acl.Groups))
- }
- if acl.Groups[0].Role != "use" && acl.Groups[0].ID != firstUser.OrganizationIDs[0] {
- return fmt.Errorf("expected group ACL to be 'use' for %s, got %s", firstUser.OrganizationIDs[0].String(), acl.Groups[0].Role)
- }
- if len(acl.Users) != 1 {
- return fmt.Errorf("expected 1 user ACL, got %d", len(acl.Users))
- }
- if acl.Users[0].Role != "admin" && acl.Users[0].ID != firstUser.ID {
- return fmt.Errorf("expected user ACL to be 'admin' for %s, got %s", firstUser.ID.String(), acl.Users[0].Role)
- }
- return nil
- },
- ),
+ // Verifies that when `max_port_share_level` is set to to the default value,
+ // an update request that would return HTTP Not Modified is not sent.
+ t.Run("DefaultMaxPortShareLevel", func(t *testing.T) {
+ cfg1 := testAccTemplateResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Name: ptr.Ref("example-template"),
+ Versions: []testAccTemplateVersionConfig{
+ {
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
+ },
},
- {
- Config: cfg3.String(t),
- Check: resource.ComposeAggregateTestCheckFunc(
- resource.TestCheckResourceAttr("coderd_template.test", "allow_user_auto_start", "false"),
- resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.days_of_week.#", "2"),
- resource.TestCheckResourceAttr("coderd_template.test", "auto_stop_requirement.weeks", "2"),
- ),
+ MaxPortShareLevel: ptr.Ref("owner"),
+ }
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg1.String(t),
+ Check: resource.TestCheckResourceAttr("coderd_template.test", "max_port_share_level", "owner"),
+ },
},
- },
+ })
})
}
func TestAccTemplateResourceAGPL(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
- client := integration.StartCoder(ctx, t, "template_acc", false)
+ ctx := t.Context()
+ client := integration.StartCoder(ctx, t, "template_resource_agpl_acc", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
+ exTemplateOne := t.TempDir()
+ err = cp.Copy("../../integration/template-test/example-template", exTemplateOne)
+ require.NoError(t, err)
+
cfg1 := testAccTemplateResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Name: PtrTo("example-template"),
+ Name: ptr.Ref("example-template"),
Versions: []testAccTemplateVersionConfig{
{
// Auto-generated version name
- Directory: PtrTo("../../integration/template-test/example-template/"),
- Active: PtrTo(true),
+ Directory: &exTemplateOne,
+ Active: ptr.Ref(true),
},
},
- AllowUserAutostart: PtrTo(false),
+ AllowUserAutostart: ptr.Ref(false),
}
cfg2 := cfg1
cfg2.AllowUserAutostart = nil
- cfg2.AutostopRequirement.DaysOfWeek = PtrTo([]string{"monday", "tuesday"})
+ cfg2.AutostopRequirement.DaysOfWeek = ptr.Ref([]string{"monday", "tuesday"})
cfg3 := cfg2
cfg3.AutostopRequirement.null = true
- cfg3.AutostartRequirement = PtrTo([]string{})
+ cfg3.AutostartRequirement = ptr.Ref([]string{})
cfg4 := cfg3
- cfg4.FailureTTL = PtrTo(int64(1))
+ cfg4.FailureTTL = ptr.Ref(int64(1))
cfg5 := cfg4
cfg5.FailureTTL = nil
cfg5.AutostartRequirement = nil
- cfg5.RequireActiveVersion = PtrTo(true)
+ cfg5.RequireActiveVersion = ptr.Ref(true)
cfg6 := cfg5
cfg6.RequireActiveVersion = nil
cfg6.ACL = testAccTemplateACLConfig{
GroupACL: []testAccTemplateKeyValueConfig{
{
- Key: PtrTo(firstUser.OrganizationIDs[0].String()),
- Value: PtrTo("use"),
+ Key: ptr.Ref(firstUser.OrganizationIDs[0].String()),
+ Value: ptr.Ref("use"),
},
},
}
+ cfg7 := cfg6
+ cfg7.ACL.null = true
+ cfg7.MaxPortShareLevel = ptr.Ref("owner")
+
for _, cfg := range []testAccTemplateResourceConfig{cfg1, cfg2, cfg3, cfg4} {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
@@ -541,6 +901,71 @@ func TestAccTemplateResourceAGPL(t *testing.T) {
Config: cfg6.String(t),
ExpectError: regexp.MustCompile("Your license is not entitled to use template access control"),
},
+ {
+ Config: cfg7.String(t),
+ ExpectError: regexp.MustCompile("Your license is not entitled to use port sharing control"),
+ },
+ },
+ })
+}
+
+func TestAccTemplateResourceVariables(t *testing.T) {
+ t.Parallel()
+ cfg := `
+provider coderd {
+ url = %q
+ token = %q
+}
+
+data "coderd_organization" "default" {
+ is_default = true
+}
+
+variable "PRIOR_GIT_COMMIT_SHA" {
+ default = "abcdef"
+}
+
+variable "CURRENT_GIT_COMMIT_SHA" {
+ default = "ghijkl"
+}
+
+variable "ACTIVE" {
+ default = true
+}
+
+resource "coderd_template" "sample" {
+ name = "example-template"
+ versions = [
+ {
+ name = "${var.PRIOR_GIT_COMMIT_SHA}"
+ directory = %q
+ active = var.ACTIVE
+ },
+ {
+ name = "${var.CURRENT_GIT_COMMIT_SHA}"
+ directory = %q
+ active = false
+ }
+ ]
+}`
+
+ ctx := t.Context()
+ client := integration.StartCoder(ctx, t, "template_resource_variables_acc", false)
+
+ exTemplateOne := t.TempDir()
+ err := cp.Copy("../../integration/template-test/example-template", exTemplateOne)
+ require.NoError(t, err)
+
+ cfg = fmt.Sprintf(cfg, client.URL.String(), client.SessionToken(), exTemplateOne, exTemplateOne)
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ IsUnitTest: true,
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg,
+ },
},
})
}
@@ -566,6 +991,8 @@ type testAccTemplateResourceConfig struct {
TimeTilDormantAutodelete *int64
RequireActiveVersion *bool
DeprecationMessage *string
+ MaxPortShareLevel *string
+ UseClassicParameterFlow *bool
Versions []testAccTemplateVersionConfig
ACL testAccTemplateACLConfig
@@ -672,6 +1099,8 @@ resource "coderd_template" "test" {
time_til_dormant_autodelete_ms = {{orNull .TimeTilDormantAutodelete}}
require_active_version = {{orNull .RequireActiveVersion}}
deprecation_message = {{orNull .DeprecationMessage}}
+ max_port_share_level = {{orNull .MaxPortShareLevel}}
+ use_classic_parameter_flow = {{orNull .UseClassicParameterFlow}}
acl = ` + c.ACL.String(t) + `
@@ -746,27 +1175,32 @@ func testAccCheckNumTemplateVersions(ctx context.Context, client *codersdk.Clien
}
func TestReconcileVersionIDs(t *testing.T) {
+ t.Parallel()
aUUID := uuid.New()
bUUID := uuid.New()
cases := []struct {
- Name string
- planVersions Versions
- configVersions Versions
- inputState LastVersionsByHash
- expectedVersions Versions
+ Name string
+ planVersions Versions
+ configVersions Versions
+ inputState LastVersionsByHash
+ expectedVersions Versions
+ cfgHasActiveVersion bool
+ expectError bool
}{
{
Name: "IdenticalDontRename",
planVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
},
configVersions: []TemplateVersion{
@@ -780,21 +1214,24 @@ func TestReconcileVersionIDs(t *testing.T) {
inputState: map[string][]PreviousTemplateVersion{
"aaa": {
{
- ID: aUUID,
- Name: "bar",
+ ID: aUUID,
+ Name: "bar",
+ TFVars: map[string]string{},
},
},
},
expectedVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: UUIDValue(aUUID),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{},
},
},
},
@@ -802,14 +1239,16 @@ func TestReconcileVersionIDs(t *testing.T) {
Name: "IdenticalRenameFirst",
planVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
},
configVersions: []TemplateVersion{
@@ -823,21 +1262,24 @@ func TestReconcileVersionIDs(t *testing.T) {
inputState: map[string][]PreviousTemplateVersion{
"aaa": {
{
- ID: aUUID,
- Name: "baz",
+ ID: aUUID,
+ Name: "baz",
+ TFVars: map[string]string{},
},
},
},
expectedVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: UUIDValue(aUUID),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
},
},
@@ -845,14 +1287,16 @@ func TestReconcileVersionIDs(t *testing.T) {
Name: "IdenticalHashesInState",
planVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
},
configVersions: []TemplateVersion{
@@ -866,25 +1310,29 @@ func TestReconcileVersionIDs(t *testing.T) {
inputState: map[string][]PreviousTemplateVersion{
"aaa": {
{
- ID: aUUID,
- Name: "qux",
+ ID: aUUID,
+ Name: "qux",
+ TFVars: map[string]string{},
},
{
- ID: bUUID,
- Name: "baz",
+ ID: bUUID,
+ Name: "baz",
+ TFVars: map[string]string{},
},
},
},
expectedVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: UUIDValue(aUUID),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringValue("bar"),
- DirectoryHash: types.StringValue("aaa"),
- ID: UUIDValue(bUUID),
+ Name: types.StringValue("bar"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(bUUID),
+ TerraformVariables: []Variable{},
},
},
},
@@ -892,14 +1340,16 @@ func TestReconcileVersionIDs(t *testing.T) {
Name: "UnknownUsesStateInOrder",
planVersions: []TemplateVersion{
{
- Name: types.StringValue("foo"),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
{
- Name: types.StringUnknown(),
- DirectoryHash: types.StringValue("aaa"),
- ID: NewUUIDUnknown(),
+ Name: types.StringUnknown(),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
},
},
configVersions: []TemplateVersion{
@@ -913,65 +1363,197 @@ func TestReconcileVersionIDs(t *testing.T) {
inputState: map[string][]PreviousTemplateVersion{
"aaa": {
{
- ID: aUUID,
- Name: "qux",
+ ID: aUUID,
+ Name: "qux",
+ TFVars: map[string]string{},
},
{
- ID: bUUID,
- Name: "baz",
+ ID: bUUID,
+ Name: "baz",
+ TFVars: map[string]string{},
+ },
+ },
+ },
+ expectedVersions: []TemplateVersion{
+ {
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{},
+ },
+ {
+ Name: types.StringValue("baz"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(bUUID),
+ TerraformVariables: []Variable{},
+ },
+ },
+ },
+ {
+ Name: "NewVersionNewRandomName",
+ planVersions: []TemplateVersion{
+ {
+ Name: types.StringValue("weird_draught12"),
+ DirectoryHash: types.StringValue("bbb"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{},
+ },
+ },
+ configVersions: []TemplateVersion{
+ {
+ Name: types.StringNull(),
+ },
+ },
+ inputState: map[string][]PreviousTemplateVersion{
+ "aaa": {
+ {
+ ID: aUUID,
+ Name: "weird_draught12",
+ TFVars: map[string]string{},
},
},
},
expectedVersions: []TemplateVersion{
+ {
+ Name: types.StringUnknown(),
+ DirectoryHash: types.StringValue("bbb"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
+ },
+ },
+ },
+ {
+ Name: "IdenticalNewVars",
+ planVersions: []TemplateVersion{
{
Name: types.StringValue("foo"),
DirectoryHash: types.StringValue("aaa"),
ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{
+ {
+ Name: types.StringValue("foo"),
+ Value: types.StringValue("bar"),
+ },
+ },
},
+ },
+ configVersions: []TemplateVersion{
{
- Name: types.StringValue("baz"),
+ Name: types.StringValue("foo"),
+ },
+ },
+ inputState: map[string][]PreviousTemplateVersion{
+ "aaa": {
+ {
+ ID: aUUID,
+ Name: "foo",
+ TFVars: map[string]string{
+ "foo": "foo",
+ },
+ },
+ },
+ },
+ expectedVersions: []TemplateVersion{
+ {
+ Name: types.StringValue("foo"),
DirectoryHash: types.StringValue("aaa"),
- ID: UUIDValue(bUUID),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{
+ {
+ Name: types.StringValue("foo"),
+ Value: types.StringValue("bar"),
+ },
+ },
},
},
},
{
- Name: "NewVersionNewRandomName",
+ Name: "IdenticalSameVars",
planVersions: []TemplateVersion{
{
- Name: types.StringValue("weird_draught12"),
- DirectoryHash: types.StringValue("bbb"),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{
+ {
+ Name: types.StringValue("foo"),
+ Value: types.StringValue("bar"),
+ },
+ },
},
},
configVersions: []TemplateVersion{
{
- Name: types.StringNull(),
+ Name: types.StringValue("foo"),
},
},
inputState: map[string][]PreviousTemplateVersion{
"aaa": {
{
ID: aUUID,
- Name: "weird_draught12",
+ Name: "foo",
+ TFVars: map[string]string{
+ "foo": "bar",
+ },
},
},
},
expectedVersions: []TemplateVersion{
{
- Name: types.StringUnknown(),
- DirectoryHash: types.StringValue("bbb"),
- ID: NewUUIDUnknown(),
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: UUIDValue(aUUID),
+ TerraformVariables: []Variable{
+ {
+ Name: types.StringValue("foo"),
+ Value: types.StringValue("bar"),
+ },
+ },
+ },
+ },
+ },
+ {
+ Name: "NoPossibleActiveVersion",
+ planVersions: []TemplateVersion{
+ {
+ Name: types.StringValue("foo"),
+ DirectoryHash: types.StringValue("aaa"),
+ ID: NewUUIDUnknown(),
+ TerraformVariables: []Variable{},
+ Active: types.BoolValue(false),
+ },
+ },
+ configVersions: []TemplateVersion{
+ {
+ Name: types.StringValue("foo"),
},
},
+ inputState: map[string][]PreviousTemplateVersion{
+ "aaa": {
+ {
+ ID: aUUID,
+ Name: "foo",
+ TFVars: map[string]string{},
+ Active: true,
+ },
+ },
+ },
+ cfgHasActiveVersion: false,
+ expectError: true,
},
}
for _, c := range cases {
c := c
t.Run(c.Name, func(t *testing.T) {
- c.planVersions.reconcileVersionIDs(c.inputState, c.configVersions)
- require.Equal(t, c.expectedVersions, c.planVersions)
+ t.Parallel()
+
+ diag := c.planVersions.reconcileVersionIDs(c.inputState, c.configVersions, c.cfgHasActiveVersion)
+ if c.expectError {
+ require.True(t, diag.HasError())
+ } else {
+ require.Equal(t, c.expectedVersions, c.planVersions)
+ }
})
}
diff --git a/internal/provider/user_data_source.go b/internal/provider/user_data_source.go
index 7c5846f..d221a1e 100644
--- a/internal/provider/user_data_source.go
+++ b/internal/provider/user_data_source.go
@@ -41,7 +41,6 @@ type UserDataSourceModel struct {
OrganizationIDs types.Set `tfsdk:"organization_ids"`
CreatedAt types.Int64 `tfsdk:"created_at"` // Unix timestamp
LastSeenAt types.Int64 `tfsdk:"last_seen_at"`
- ThemePreference types.String `tfsdk:"theme_preference"`
}
func (d *UserDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
@@ -72,12 +71,12 @@ func (d *UserDataSource) Schema(ctx context.Context, req datasource.SchemaReques
Computed: true,
},
"roles": schema.SetAttribute{
- MarkdownDescription: "Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.",
+ MarkdownDescription: "Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`.",
Computed: true,
ElementType: types.StringType,
},
"login_type": schema.StringAttribute{
- MarkdownDescription: "Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.",
+ MarkdownDescription: "Type of login for the user. Valid types are `none`, `password', `github`, and `oidc`.",
Computed: true,
},
"suspended": schema.BoolAttribute{
@@ -101,10 +100,6 @@ func (d *UserDataSource) Schema(ctx context.Context, req datasource.SchemaReques
MarkdownDescription: "Unix timestamp of when the user was last seen.",
Computed: true,
},
- "theme_preference": schema.StringAttribute{
- MarkdownDescription: "The user's preferred theme.",
- Computed: true,
- },
},
}
}
@@ -149,6 +144,11 @@ func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
}
user, err := client.User(ctx, ident)
if err != nil {
+ if isNotFound(err) {
+ resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("User with identifier %q not found. Marking as deleted.", ident))
+ resp.State.RemoveResource(ctx)
+ return
+ }
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
return
}
@@ -183,7 +183,6 @@ func (d *UserDataSource) Read(ctx context.Context, req datasource.ReadRequest, r
data.OrganizationIDs = types.SetValueMust(UUIDType, orgIDs)
data.CreatedAt = types.Int64Value(user.CreatedAt.Unix())
data.LastSeenAt = types.Int64Value(user.LastSeenAt.Unix())
- data.ThemePreference = types.StringValue(user.ThemePreference)
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
diff --git a/internal/provider/user_data_source_test.go b/internal/provider/user_data_source_test.go
index 2d69d13..2890381 100644
--- a/internal/provider/user_data_source_test.go
+++ b/internal/provider/user_data_source_test.go
@@ -1,13 +1,13 @@
package provider
import (
- "context"
"os"
"regexp"
"strings"
"testing"
"text/template"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/terraform-provider-coderd/integration"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
@@ -15,10 +15,11 @@ import (
)
func TestAccUserDataSource(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
+ ctx := t.Context()
client := integration.StartCoder(ctx, t, "user_data_acc", false)
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
@@ -53,9 +54,10 @@ func TestAccUserDataSource(t *testing.T) {
cfg := testAccUserDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Username: PtrTo(user.Username),
+ Username: ptr.Ref(user.Username),
}
resource.Test(t, resource.TestCase{
+ IsUnitTest: true,
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
@@ -71,9 +73,10 @@ func TestAccUserDataSource(t *testing.T) {
cfg := testAccUserDataSourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- ID: PtrTo(user.ID.String()),
+ ID: ptr.Ref(user.ID.String()),
}
resource.Test(t, resource.TestCase{
+ IsUnitTest: true,
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
// User by ID
@@ -91,6 +94,7 @@ func TestAccUserDataSource(t *testing.T) {
Token: client.SessionToken(),
}
resource.Test(t, resource.TestCase{
+ IsUnitTest: true,
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
// Neither ID nor Username
@@ -103,6 +107,24 @@ func TestAccUserDataSource(t *testing.T) {
})
})
+ t.Run("InvalidUUIDError", func(t *testing.T) {
+ cfg := testAccUserDataSourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ ID: ptr.Ref("invalid-uuid"),
+ }
+ resource.Test(t, resource.TestCase{
+ IsUnitTest: true,
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg.String(t),
+ ExpectError: regexp.MustCompile(`The provided value cannot be parsed as a UUID`),
+ },
+ },
+ })
+ })
}
type testAccUserDataSourceConfig struct {
diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go
index 4e8de49..db605c0 100644
--- a/internal/provider/user_resource.go
+++ b/internal/provider/user_resource.go
@@ -14,7 +14,6 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
- "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
@@ -22,6 +21,7 @@ import (
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/coder/coder/v2/codersdk"
+ "github.com/coder/terraform-provider-coderd/internal/codersdkvalidator"
)
// Ensure provider defined types fully satisfy framework interfaces.
@@ -56,8 +56,7 @@ func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataReques
func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
- MarkdownDescription: "A user on the Coder deployment.\n\n" +
- "When importing, the ID supplied can be either a user UUID or a username.",
+ MarkdownDescription: "A user on the Coder deployment.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
@@ -71,19 +70,24 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r
"username": schema.StringAttribute{
MarkdownDescription: "Username of the user.",
Required: true,
+ Validators: []validator.String{
+ codersdkvalidator.Name(),
+ },
},
"name": schema.StringAttribute{
MarkdownDescription: "Display name of the user. Defaults to username.",
Computed: true,
Optional: true,
+ Validators: []validator.String{
+ codersdkvalidator.UserRealName(),
+ },
},
"email": schema.StringAttribute{
MarkdownDescription: "Email address of the user.",
Required: true,
},
"roles": schema.SetAttribute{
- MarkdownDescription: "Roles assigned to the user. Valid roles are 'owner', 'template-admin', 'user-admin', and 'auditor'.",
- Computed: true,
+ MarkdownDescription: "Roles assigned to the user. Valid roles are `owner`, `template-admin`, `user-admin`, and `auditor`. If `null`, roles will not be managed by Terraform. This attribute must be null if the user is an OIDC user and role sync is configured",
Optional: true,
ElementType: types.StringType,
Validators: []validator.Set{
@@ -91,10 +95,9 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r
stringvalidator.OneOf("owner", "template-admin", "user-admin", "auditor"),
),
},
- Default: setdefault.StaticValue(types.SetValueMust(types.StringType, []attr.Value{})),
},
"login_type": schema.StringAttribute{
- MarkdownDescription: "Type of login for the user. Valid types are 'none', 'password', 'github', and 'oidc'.",
+ MarkdownDescription: "Type of login for the user. Valid types are `none`, `password`, `github`, and `oidc`.",
Computed: true,
Optional: true,
Validators: []validator.String{
@@ -106,7 +109,7 @@ func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, r
},
},
"password": schema.StringAttribute{
- MarkdownDescription: "Password for the user. Required when login_type is 'password'. Passwords are saved into the state as plain text and should only be used for testing purposes.",
+ MarkdownDescription: "Password for the user. Required when `login_type` is `password`. Passwords are saved into the state as plain text and should only be used for testing purposes.",
Optional: true,
Sensitive: true,
},
@@ -203,21 +206,26 @@ func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, r
tflog.Info(ctx, "successfully updated user profile")
data.Name = types.StringValue(user.Name)
- var roles []string
- resp.Diagnostics.Append(
- data.Roles.ElementsAs(ctx, &roles, false)...,
- )
- tflog.Info(ctx, "updating user roles", map[string]any{
- "new_roles": roles,
- })
- user, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
- Roles: roles,
- })
- if err != nil {
- resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user roles, got error: %s", err))
- return
+ if !data.Roles.IsNull() {
+ var roles []string
+ resp.Diagnostics.Append(
+ data.Roles.ElementsAs(ctx, &roles, false)...,
+ )
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "updating user roles", map[string]any{
+ "new_roles": roles,
+ })
+ user, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
+ Roles: roles,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update newly created user roles, got error: %s", err))
+ return
+ }
+ tflog.Info(ctx, "successfully updated user roles")
}
- tflog.Info(ctx, "successfully updated user roles")
if data.Suspended.ValueBool() {
_, err = client.UpdateUserStatus(ctx, data.ID.ValueString(), codersdk.UserStatus("suspended"))
@@ -242,9 +250,15 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp
client := r.data.Client
+ // Lookup by ID to handle imports
user, err := client.User(ctx, data.ID.ValueString())
if err != nil {
- resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user, got error: %s", err))
+ if isNotFound(err) {
+ resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("User with ID %q not found. Marking resource as deleted.", data.ID.ValueString()))
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user by ID, got error: %s", err))
return
}
if len(user.OrganizationIDs) < 1 {
@@ -255,14 +269,40 @@ func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp
data.Email = types.StringValue(user.Email)
data.Name = types.StringValue(user.Name)
data.Username = types.StringValue(user.Username)
- roles := make([]attr.Value, 0, len(user.Roles))
- for _, role := range user.Roles {
- roles = append(roles, types.StringValue(role.Name))
+ if !data.Roles.IsNull() {
+ roles := make([]attr.Value, 0, len(user.Roles))
+ for _, role := range user.Roles {
+ roles = append(roles, types.StringValue(role.Name))
+ }
+ data.Roles = types.SetValueMust(types.StringType, roles)
}
- data.Roles = types.SetValueMust(types.StringType, roles)
data.LoginType = types.StringValue(string(user.LoginType))
data.Suspended = types.BoolValue(user.Status == codersdk.UserStatusSuspended)
+ // The user-by-ID API returns deleted users if the authorized user has
+ // permission. It does not indicate whether the user is deleted or not.
+ // The user-by-username API will never return deleted users.
+ // So, we do another lookup by username.
+ userByName, err := client.User(ctx, data.Username.ValueString())
+ if err != nil {
+ if isNotFound(err) {
+ resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf(
+ "User with username %q not found. Marking resource as deleted.",
+ data.Username.ValueString()))
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to get current user by username, got error: %s", err))
+ return
+ }
+ if userByName.ID != data.ID.ValueUUID() {
+ resp.Diagnostics.AddWarning("Client Error", fmt.Sprintf(
+ "The username %q has been reassigned to a new user not managed by this Terraform resource. Marking resource as deleted.",
+ user.Username))
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
@@ -308,21 +348,26 @@ func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, r
data.Name = name
tflog.Info(ctx, "successfully updated user profile")
- var roles []string
- resp.Diagnostics.Append(
- data.Roles.ElementsAs(ctx, &roles, false)...,
- )
- tflog.Info(ctx, "updating user roles", map[string]any{
- "new_roles": roles,
- })
- _, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
- Roles: roles,
- })
- if err != nil {
- resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user roles, got error: %s", err))
- return
+ if !data.Roles.IsNull() {
+ var roles []string
+ resp.Diagnostics.Append(
+ data.Roles.ElementsAs(ctx, &roles, false)...,
+ )
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "updating user roles", map[string]any{
+ "new_roles": roles,
+ })
+ _, err = client.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
+ Roles: roles,
+ })
+ if err != nil {
+ resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update user roles, got error: %s", err))
+ return
+ }
+ tflog.Info(ctx, "successfully updated user roles")
}
- tflog.Info(ctx, "successfully updated user roles")
if data.LoginType.ValueString() == string(codersdk.LoginTypePassword) && !data.Password.IsNull() {
tflog.Info(ctx, "updating password")
diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go
index a7bb470..9e47df3 100644
--- a/internal/provider/user_resource_test.go
+++ b/internal/provider/user_resource_test.go
@@ -1,45 +1,50 @@
package provider
import (
- "context"
"os"
"strings"
"testing"
"text/template"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/terraform-provider-coderd/integration"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/stretchr/testify/require"
)
func TestAccUserResource(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
+ ctx := t.Context()
client := integration.StartCoder(ctx, t, "user_acc", false)
cfg1 := testAccUserResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Username: PtrTo("example"),
- Name: PtrTo("Example User"),
- Email: PtrTo("example@coder.com"),
- Roles: PtrTo([]string{"owner", "auditor"}),
- LoginType: PtrTo("password"),
- Password: PtrTo("SomeSecurePassword!"),
+ Username: ptr.Ref("example"),
+ Name: ptr.Ref("Example User"),
+ Email: ptr.Ref("example@coder.com"),
+ Roles: ptr.Ref([]string{"owner", "auditor"}),
+ LoginType: ptr.Ref("password"),
+ Password: ptr.Ref("SomeSecurePassword!"),
}
cfg2 := cfg1
- cfg2.Username = PtrTo("exampleNew")
+ cfg2.Username = ptr.Ref("exampleNew")
cfg3 := cfg2
- cfg3.Name = PtrTo("Example New")
+ cfg3.Name = ptr.Ref("Example New")
cfg4 := cfg3
- cfg4.LoginType = PtrTo("github")
+ cfg4.LoginType = ptr.Ref("github")
cfg4.Password = nil
+ cfg5 := cfg4
+ cfg5.Roles = nil
+
resource.Test(t, resource.TestCase{
IsUnitTest: true,
PreCheck: func() { testAccPreCheck(t) },
@@ -66,7 +71,7 @@ func TestAccUserResource(t *testing.T) {
ImportState: true,
ImportStateVerify: true,
// We can't pull the password from the API.
- ImportStateVerifyIgnore: []string{"password"},
+ ImportStateVerifyIgnore: []string{"password", "roles"},
},
// ImportState by username
{
@@ -75,7 +80,7 @@ func TestAccUserResource(t *testing.T) {
ImportStateVerify: true,
ImportStateId: "example",
// We can't pull the password from the API.
- ImportStateVerifyIgnore: []string{"password"},
+ ImportStateVerifyIgnore: []string{"password", "roles"},
},
// Update and Read testing
{
@@ -99,8 +104,55 @@ func TestAccUserResource(t *testing.T) {
resource.TestCheckResourceAttr("coderd_user.test", "login_type", "github"),
),
},
+ // Verify config drift via deletion is handled
+ {
+ Config: cfg4.String(t),
+ Check: func(*terraform.State) error {
+ user, err := client.User(ctx, "exampleNew")
+ if err != nil {
+ return err
+ }
+ return client.DeleteUser(ctx, user.ID)
+ },
+ // The Plan should be to create the entire resource
+ ExpectNonEmptyPlan: true,
+ },
+ // Unmanaged roles
+ {
+ Config: cfg5.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckNoResourceAttr("coderd_user.test", "roles"),
+ ),
+ },
},
})
+
+ t.Run("CreateUnmanagedRolesOk", func(t *testing.T) {
+ cfg := testAccUserResourceConfig{
+ URL: client.URL.String(),
+ Token: client.SessionToken(),
+ Username: ptr.Ref("unmanaged"),
+ Name: ptr.Ref("Unmanaged User"),
+ Email: ptr.Ref("unmanaged@coder.com"),
+ Roles: nil, // Start with unmanaged roles
+ LoginType: ptr.Ref("password"),
+ Password: ptr.Ref("SomeSecurePassword!"),
+ }
+
+ resource.Test(t, resource.TestCase{
+ IsUnitTest: true,
+ PreCheck: func() { testAccPreCheck(t) },
+ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ Config: cfg.String(t),
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckNoResourceAttr("coderd_user.test", "roles"),
+ ),
+ },
+ },
+ })
+ })
}
type testAccUserResourceConfig struct {
diff --git a/internal/provider/util.go b/internal/provider/util.go
index 03d899f..3f35a25 100644
--- a/internal/provider/util.go
+++ b/internal/provider/util.go
@@ -3,17 +3,17 @@ package provider
import (
"crypto/sha256"
"encoding/hex"
+ "errors"
"fmt"
+ "net/http"
"os"
"path/filepath"
+ "strings"
+ "github.com/coder/coder/v2/codersdk"
"github.com/google/uuid"
)
-func PtrTo[T any](v T) *T {
- return &v
-}
-
func PrintOrNull(v any) string {
if v == nil {
return "null"
@@ -84,13 +84,15 @@ func computeDirectoryHash(directory string) (string, error) {
return hex.EncodeToString(hash.Sum(nil)), nil
}
-// memberDiff returns the members to add and remove from the group, given the current members and the planned members.
-// plannedMembers is deliberately our custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a set.
-func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) {
- curSet := make(map[uuid.UUID]struct{}, len(curMembers))
+// memberDiff returns the members to add and remove from the group, given the
+// current members and the planned members. plannedMembers is deliberately our
+// custom type, as Terraform cannot automatically produce `[]uuid.UUID` from a
+// set.
+func memberDiff(currentMembers []uuid.UUID, plannedMembers []UUID) (add, remove []string) {
+ curSet := make(map[uuid.UUID]struct{}, len(currentMembers))
planSet := make(map[uuid.UUID]struct{}, len(plannedMembers))
- for _, userID := range curMembers {
+ for _, userID := range currentMembers {
curSet[userID] = struct{}{}
}
for _, plannedUserID := range plannedMembers {
@@ -99,10 +101,25 @@ func memberDiff(curMembers []uuid.UUID, plannedMembers []UUID) (add, remove []st
add = append(add, plannedUserID.ValueString())
}
}
- for _, curUserID := range curMembers {
+ for _, curUserID := range currentMembers {
if _, exists := planSet[curUserID]; !exists {
remove = append(remove, curUserID.String())
}
}
return add, remove
}
+
+func isNotFound(err error) bool {
+ var sdkErr *codersdk.Error
+ if !errors.As(err, &sdkErr) {
+ return false
+ }
+ if sdkErr.StatusCode() == http.StatusNotFound {
+ return true
+ }
+ // `httpmw/ExtractUserContext` returns a 400 w/ this message if the user is not found
+ if sdkErr.StatusCode() == http.StatusBadRequest && strings.Contains(sdkErr.Message, "must be an existing uuid or username") {
+ return true
+ }
+ return false
+}
diff --git a/internal/provider/uuid.go b/internal/provider/uuid.go
index 8cd8912..ac37b04 100644
--- a/internal/provider/uuid.go
+++ b/internal/provider/uuid.go
@@ -48,16 +48,16 @@ func (t uuidType) ValueFromString(ctx context.Context, in basetypes.StringValue)
return NewUUIDUnknown(), diags
}
- value, err := uuid.Parse(in.ValueString())
- if err != nil {
- // The framework doesn't want us to return validation errors here
- // for some reason. They get caught by `ValidateAttribute` instead,
- // and this function isn't called directly by our provider - UUIDValue
- // takes a valid UUID instead of a string.
- return NewUUIDUnknown(), diags
- }
-
- return UUIDValue(value), diags
+ // This function deliberately does not handle invalid UUIDs.
+ // Instead, `ValidateAttribute` will be called
+ // on the stored string during `validate` `plan` and `apply`,
+ // which will also create an error diagnostic.
+ // For that reason, storing the zero UUID is fine.
+ v, _ := uuid.Parse(in.ValueString())
+ return UUID{
+ StringValue: in,
+ value: v,
+ }, diags
}
// ValueFromTerraform implements basetypes.StringTypable.
diff --git a/internal/provider/uuid_internal_test.go b/internal/provider/uuid_internal_test.go
index 6283bb9..4b2fe05 100644
--- a/internal/provider/uuid_internal_test.go
+++ b/internal/provider/uuid_internal_test.go
@@ -1,12 +1,12 @@
package provider
import (
- "context"
"testing"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-go/tftypes"
"github.com/stretchr/testify/require"
)
@@ -37,9 +37,12 @@ func TestUUIDTypeValueFromTerraform(t *testing.T) {
expected: UUIDValue(ValidUUID),
},
{
- name: "invalid UUID",
- input: tftypes.NewValue(tftypes.String, "invalid"),
- expected: NewUUIDUnknown(),
+ name: "invalid UUID",
+ input: tftypes.NewValue(tftypes.String, "invalid"),
+ expected: UUID{
+ StringValue: basetypes.NewStringValue("invalid"),
+ value: uuid.Nil,
+ },
},
}
@@ -47,11 +50,10 @@ func TestUUIDTypeValueFromTerraform(t *testing.T) {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
- ctx := context.Background()
+ ctx := t.Context()
actual, err := uuidType.ValueFromTerraform(UUIDType, ctx, test.input)
require.NoError(t, err)
-
require.Equal(t, test.expected, actual)
})
}
@@ -82,10 +84,9 @@ func TestUUIDToStringValue(t *testing.T) {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
- ctx := context.Background()
+ ctx := t.Context()
s, _ := test.uuid.ToStringValue(ctx)
-
require.Equal(t, test.expected, s)
})
}
diff --git a/internal/provider/workspace_proxy_resource.go b/internal/provider/workspace_proxy_resource.go
index a95dc68..211c778 100644
--- a/internal/provider/workspace_proxy_resource.go
+++ b/internal/provider/workspace_proxy_resource.go
@@ -60,7 +60,7 @@ func (r *WorkspaceProxyResource) Schema(ctx context.Context, req resource.Schema
Computed: true,
},
"icon": schema.StringAttribute{
- MarkdownDescription: "Relative path or external URL that specifes an icon to be displayed in the dashboard.",
+ MarkdownDescription: "Relative path or external URL that specifies an icon to be displayed in the dashboard.",
Required: true,
},
"session_token": schema.StringAttribute{
@@ -142,6 +142,11 @@ func (r *WorkspaceProxyResource) Read(ctx context.Context, req resource.ReadRequ
client := r.data.Client
wsp, err := client.WorkspaceProxyByID(ctx, data.ID.ValueUUID())
if err != nil {
+ if isNotFound(err) {
+ resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Workspace proxy with ID %s not found. Marking as deleted.", data.ID.ValueString()))
+ resp.State.RemoveResource(ctx)
+ return
+ }
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to read workspace proxy: %v", err))
return
}
diff --git a/internal/provider/workspace_proxy_resource_test.go b/internal/provider/workspace_proxy_resource_test.go
index a2447ea..cecad70 100644
--- a/internal/provider/workspace_proxy_resource_test.go
+++ b/internal/provider/workspace_proxy_resource_test.go
@@ -1,36 +1,37 @@
package provider
import (
- "context"
"os"
"regexp"
"strings"
"testing"
"text/template"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/terraform-provider-coderd/integration"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/stretchr/testify/require"
)
func TestAccWorkspaceProxyResource(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
+ ctx := t.Context()
client := integration.StartCoder(ctx, t, "ws_proxy_acc", true)
cfg1 := testAccWorkspaceProxyResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Name: PtrTo("example"),
- DisplayName: PtrTo("Example WS Proxy"),
- Icon: PtrTo("/emojis/1f407.png"),
+ Name: ptr.Ref("example"),
+ DisplayName: ptr.Ref("Example WS Proxy"),
+ Icon: ptr.Ref("/emojis/1f407.png"),
}
cfg2 := cfg1
- cfg2.Name = PtrTo("example-new")
- cfg2.DisplayName = PtrTo("Example WS Proxy New")
+ cfg2.Name = ptr.Ref("example-new")
+ cfg2.DisplayName = ptr.Ref("Example WS Proxy New")
resource.Test(t, resource.TestCase{
IsUnitTest: true,
@@ -55,18 +56,19 @@ func TestAccWorkspaceProxyResource(t *testing.T) {
}
func TestAccWorkspaceProxyResourceAGPL(t *testing.T) {
+ t.Parallel()
if os.Getenv("TF_ACC") == "" {
t.Skip("Acceptance tests are disabled.")
}
- ctx := context.Background()
- client := integration.StartCoder(ctx, t, "ws_proxy_acc", false)
+ ctx := t.Context()
+ client := integration.StartCoder(ctx, t, "ws_proxy_acc_agpl", false)
cfg1 := testAccWorkspaceProxyResourceConfig{
URL: client.URL.String(),
Token: client.SessionToken(),
- Name: PtrTo("example"),
- DisplayName: PtrTo("Example WS Proxy"),
- Icon: PtrTo("/emojis/1f407.png"),
+ Name: ptr.Ref("example"),
+ DisplayName: ptr.Ref("Example WS Proxy"),
+ Icon: ptr.Ref("/emojis/1f407.png"),
}
resource.Test(t, resource.TestCase{
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy