Skip to content

Commit e036857

Browse files
chore: allow pushing only inactive coderd_template versions (#167)
Per some customer feedback, this PR relaxes some of the constraints when creating template versions via the `coderd_template` resource. Previously, each update of the resource required that at least one of the versions in the `versions` list had the `active` attribute set to true. This constraint is now only required when: 1. Creating the resource, as every template must have an active version. 2. It's not clear what template version should be set as active. - This occurs when the currently active version is marked as inactive, but no changes are made to the contents of the version (and no version would be created). Examples of these cases can be seen in the tests. With this, a workflow that involves: - Marking an active version in the list as inactive - Pushing it a few times via the provider - And then finally marking it as active is now possible with just a single item in the list.
1 parent 19fbf75 commit e036857

File tree

5 files changed

+379
-43
lines changed

5 files changed

+379
-43
lines changed

docs/resources/template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ resource "coderd_template" "ubuntu-main" {
7777
- `description` (String) A description of the template.
7878
- `display_name` (String) The display name of the template. Defaults to the template name.
7979
- `failure_ttl_ms` (Number) (Enterprise) The max lifetime before Coder stops all resources for failed workspaces created from this template, in milliseconds.
80-
- `icon` (String) Relative path or external URL that specifes an icon to be displayed in the dashboard.
80+
- `icon` (String) Relative path or external URL that specifies an icon to be displayed in the dashboard.
8181
- `max_port_share_level` (String) (Enterprise) The maximum port share level for workspaces created from this template. Defaults to `owner` on an Enterprise deployment, or `public` otherwise.
8282
- `organization_id` (String) The ID of the organization. Defaults to the provider's default organization
8383
- `require_active_version` (Boolean) (Enterprise) Whether workspaces must be created from the active version of this template. Defaults to false.

integration/integration.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ func StartCoder(ctx context.Context, t *testing.T, name string, useLicense bool)
5151
ctr, err := cli.ContainerCreate(ctx, &container.Config{
5252
Image: coderImg + ":" + coderVersion,
5353
Env: []string{
54-
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container
55-
"CODER_ACCESS_URL=http://localhost:3000", // Set explicitly to avoid creating try.coder.app URLs.
56-
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
54+
"CODER_HTTP_ADDRESS=0.0.0.0:3000", // Listen on all interfaces inside the container
55+
"CODER_ACCESS_URL=http://localhost:3000", // Set explicitly to avoid creating try.coder.app URLs.
56+
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise.
57+
"CODER_DANGEROUS_DISABLE_RATE_LIMITS=true", // Avoid hitting file rate limit in tests.
5758
},
5859
Labels: map[string]string{},
5960
ExposedPorts: map[nat.Port]struct{}{nat.Port("3000/tcp"): {}},

integration/integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func TestIntegration(t *testing.T) {
166166
tfCmd.Stderr = &buf
167167
tt.preF(t, client)
168168
if err := tfCmd.Run(); !assert.NoError(t, err) {
169-
t.Logf("%s", buf.String())
169+
t.Log(buf.String())
170170
t.FailNow()
171171
}
172172
tt.assertF(t, client)

internal/provider/template_resource.go

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
286286
},
287287
},
288288
"icon": schema.StringAttribute{
289-
MarkdownDescription: "Relative path or external URL that specifes an icon to be displayed in the dashboard.",
289+
MarkdownDescription: "Relative path or external URL that specifies an icon to be displayed in the dashboard.",
290290
Optional: true,
291291
Computed: true,
292292
Default: stringdefault.StaticString(""),
@@ -404,7 +404,7 @@ func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaReques
404404
Required: true,
405405
Validators: []validator.List{
406406
listvalidator.SizeAtLeast(1),
407-
NewActiveVersionValidator(),
407+
NewVersionsValidator(),
408408
},
409409
NestedObject: schema.NestedAttributeObject{
410410
Attributes: map[string]schema.Attribute{
@@ -867,24 +867,24 @@ func (r *TemplateResource) ConfigValidators(context.Context) []resource.ConfigVa
867867
return []resource.ConfigValidator{}
868868
}
869869

870-
type activeVersionValidator struct{}
870+
type versionsValidator struct{}
871871

872-
func NewActiveVersionValidator() validator.List {
873-
return &activeVersionValidator{}
872+
func NewVersionsValidator() validator.List {
873+
return &versionsValidator{}
874874
}
875875

876876
// Description implements validator.List.
877-
func (a *activeVersionValidator) Description(ctx context.Context) string {
877+
func (a *versionsValidator) Description(ctx context.Context) string {
878878
return a.MarkdownDescription(ctx)
879879
}
880880

881881
// MarkdownDescription implements validator.List.
882-
func (a *activeVersionValidator) MarkdownDescription(context.Context) string {
883-
return "Validate that exactly one template version has active set to true."
882+
func (a *versionsValidator) MarkdownDescription(context.Context) string {
883+
return "Validate that template version names are unique and that at most one version is active."
884884
}
885885

886886
// ValidateList implements validator.List.
887-
func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
887+
func (a *versionsValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
888888
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
889889
return
890890
}
@@ -908,13 +908,13 @@ func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator
908908
uniqueNames[version.Name.ValueString()] = struct{}{}
909909
}
910910

911-
// Check if only one item in Version has active set to true
911+
// Ensure at most one version is active
912912
active := false
913913
for _, version := range data {
914-
// `active` is required, so if it's null or unknown, this is Terraform
914+
// `active` defaults to false, so if it's null or unknown, this is Terraform
915915
// requesting an early validation.
916916
if version.Active.IsNull() || version.Active.IsUnknown() {
917-
return
917+
continue
918918
}
919919
if version.Active.ValueBool() {
920920
if active {
@@ -924,12 +924,9 @@ func (a *activeVersionValidator) ValidateList(ctx context.Context, req validator
924924
active = true
925925
}
926926
}
927-
if !active {
928-
resp.Diagnostics.AddError("Client Error", "At least one template version must be active.")
929-
}
930927
}
931928

932-
var _ validator.List = &activeVersionValidator{}
929+
var _ validator.List = &versionsValidator{}
933930

934931
type versionsPlanModifier struct{}
935932

@@ -956,6 +953,12 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
956953
return
957954
}
958955

956+
hasActiveVersion, diag := hasOneActiveVersion(configVersions)
957+
if diag.HasError() {
958+
resp.Diagnostics.Append(diag...)
959+
return
960+
}
961+
959962
for i := range planVersions {
960963
hash, err := computeDirectoryHash(planVersions[i].Directory.ValueString())
961964
if err != nil {
@@ -974,6 +977,13 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
974977
// If this is the first read, init the private state value
975978
if lvBytes == nil {
976979
lv = make(LastVersionsByHash)
980+
// If there's no prior private state, this might be resource creation,
981+
// in which case one version must be active.
982+
if !hasActiveVersion {
983+
resp.Diagnostics.AddError("Client Error", "At least one template version must be active when creating a"+
984+
" `coderd_template` resource.\n(Subsequent resource updates can be made without an active template in the list).")
985+
return
986+
}
977987
} else {
978988
err := json.Unmarshal(lvBytes, &lv)
979989
if err != nil {
@@ -982,9 +992,34 @@ func (d *versionsPlanModifier) PlanModifyList(ctx context.Context, req planmodif
982992
}
983993
}
984994

985-
planVersions.reconcileVersionIDs(lv, configVersions)
995+
diag = planVersions.reconcileVersionIDs(lv, configVersions, hasActiveVersion)
996+
if diag.HasError() {
997+
resp.Diagnostics.Append(diag...)
998+
return
999+
}
9861000

987-
resp.PlanValue, resp.Diagnostics = types.ListValueFrom(ctx, req.PlanValue.ElementType(ctx), planVersions)
1001+
resp.PlanValue, diag = types.ListValueFrom(ctx, req.PlanValue.ElementType(ctx), planVersions)
1002+
if diag.HasError() {
1003+
resp.Diagnostics.Append(diag...)
1004+
}
1005+
}
1006+
1007+
func hasOneActiveVersion(data Versions) (hasActiveVersion bool, diags diag.Diagnostics) {
1008+
active := false
1009+
for _, version := range data {
1010+
if version.Active.IsNull() || version.Active.IsUnknown() {
1011+
// If null or unknown, the value will be defaulted to false
1012+
continue
1013+
}
1014+
if version.Active.ValueBool() {
1015+
if active {
1016+
diags.AddError("Client Error", "Only one template version can be active at a time.")
1017+
return
1018+
}
1019+
active = true
1020+
}
1021+
}
1022+
return active, diags
9881023
}
9891024

9901025
func NewVersionsPlanModifier() planmodifier.List {
@@ -1309,6 +1344,7 @@ type PreviousTemplateVersion struct {
13091344
ID uuid.UUID `json:"id"`
13101345
Name string `json:"name"`
13111346
TFVars map[string]string `json:"tf_vars"`
1347+
Active bool `json:"active"`
13121348
}
13131349

13141350
type privateState interface {
@@ -1331,13 +1367,15 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d
13311367
ID: version.ID.ValueUUID(),
13321368
Name: version.Name.ValueString(),
13331369
TFVars: tfVars,
1370+
Active: version.Active.ValueBool(),
13341371
})
13351372
} else {
13361373
lv[version.DirectoryHash.ValueString()] = []PreviousTemplateVersion{
13371374
{
13381375
ID: version.ID.ValueUUID(),
13391376
Name: version.Name.ValueString(),
13401377
TFVars: tfVars,
1378+
Active: version.Active.ValueBool(),
13411379
},
13421380
}
13431381
}
@@ -1350,7 +1388,7 @@ func (v Versions) setPrivateState(ctx context.Context, ps privateState) (diags d
13501388
return ps.SetKey(ctx, LastVersionsKey, lvBytes)
13511389
}
13521390

1353-
func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVersions Versions) {
1391+
func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVersions Versions, hasOneActiveVersion bool) (diag diag.Diagnostics) {
13541392
// We remove versions that we've matched from `lv`, so make a copy for
13551393
// resolving tfvar changes at the end.
13561394
fullLv := make(LastVersionsByHash)
@@ -1420,6 +1458,39 @@ func (planVersions Versions) reconcileVersionIDs(lv LastVersionsByHash, configVe
14201458
}
14211459
}
14221460
}
1461+
1462+
// If a version was deactivated, and no active version was set, we need to
1463+
// return an error to avoid a post-apply plan being non-empty.
1464+
if !hasOneActiveVersion {
1465+
for i := range planVersions {
1466+
if !planVersions[i].ID.IsUnknown() {
1467+
prevs, ok := fullLv[planVersions[i].DirectoryHash.ValueString()]
1468+
if !ok {
1469+
continue
1470+
}
1471+
if versionDeactivated(prevs, &planVersions[i]) {
1472+
diag.AddError("Client Error", "Plan could not determine which version should be active.\n"+
1473+
"Either specify an active version or modify the contents of the previously active version before marking it as inactive.")
1474+
return diag
1475+
}
1476+
}
1477+
}
1478+
}
1479+
return diag
1480+
}
1481+
1482+
func versionDeactivated(prevs []PreviousTemplateVersion, planned *TemplateVersion) bool {
1483+
for _, prev := range prevs {
1484+
if prev.ID == planned.ID.ValueUUID() {
1485+
if prev.Active &&
1486+
!planned.Active.IsNull() &&
1487+
!planned.Active.IsUnknown() &&
1488+
!planned.Active.ValueBool() {
1489+
return true
1490+
}
1491+
}
1492+
}
1493+
return false
14231494
}
14241495

14251496
func tfVariablesChanged(prevs []PreviousTemplateVersion, planned *TemplateVersion) bool {

0 commit comments

Comments
 (0)