Skip to content

Commit 7645de8

Browse files
chore: support importing resources by fully qualified names (#84)
Previously, user, group and template resources could only be imported via their UUIDs. The `coder/coder` frontend doesn't expose these UUIDs to the user, so we should provide an alternative, more user-friendly way to import resources. User resources: ID or username, since usernames must be unique, even across multiple orgs. Template resources: ID or `<organization-name>/<template-name>` Group resources: ID or `<organization-name/<group-name>`
1 parent 4d366f7 commit 7645de8

9 files changed

+106
-13
lines changed

docs/resources/group.md

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ subcategory: ""
55
description: |-
66
A group on the Coder deployment.
77
Creating groups requires an Enterprise license.
8+
When importing, the ID supplied can be either a group UUID retrieved via the API or <organization-name>/<group-name>.
89
---
910

1011
# coderd_group (Resource)
@@ -13,6 +14,8 @@ A group on the Coder deployment.
1314

1415
Creating groups requires an Enterprise license.
1516

17+
When importing, the ID supplied can be either a group UUID retrieved via the API or `<organization-name>/<group-name>`.
18+
1619
## Example Usage
1720

1821
```terraform

docs/resources/template.md

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ subcategory: ""
55
description: |-
66
A Coder template.
77
Logs from building template versions are streamed from the provisioner when the TF_LOG environment variable is INFO or higher.
8+
When importing, the ID supplied can be either a template UUID retrieved via the API or <organization-name>/<template-name>.
89
---
910

1011
# coderd_template (Resource)
@@ -13,6 +14,8 @@ A Coder template.
1314

1415
Logs from building template versions are streamed from the provisioner when the `TF_LOG` environment variable is `INFO` or higher.
1516

17+
When importing, the ID supplied can be either a template UUID retrieved via the API or `<organization-name>/<template-name>`.
18+
1619
## Example Usage
1720

1821
```terraform

docs/resources/user.md

+3
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ page_title: "coderd_user Resource - terraform-provider-coderd"
44
subcategory: ""
55
description: |-
66
A user on the Coder deployment.
7+
When importing, the ID supplied can be either a user UUID or a username.
78
---
89

910
# coderd_user (Resource)
1011

1112
A user on the Coder deployment.
1213

14+
When importing, the ID supplied can be either a user UUID or a username.
15+
1316
## Example Usage
1417

1518
```terraform

internal/provider/group_resource.go

+28-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provider
33
import (
44
"context"
55
"fmt"
6+
"strings"
67

78
"github.com/coder/coder/v2/codersdk"
89
"github.com/google/uuid"
@@ -60,7 +61,9 @@ func (r *GroupResource) Metadata(ctx context.Context, req resource.MetadataReque
6061

6162
func (r *GroupResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
6263
resp.Schema = schema.Schema{
63-
MarkdownDescription: "A group on the Coder deployment.\n\nCreating groups requires an Enterprise license.",
64+
MarkdownDescription: "A group on the Coder deployment.\n\n" +
65+
"Creating groups requires an Enterprise license.\n\n" +
66+
"When importing, the ID supplied can be either a group UUID retrieved via the API or `<organization-name>/<group-name>`.",
6467

6568
Attributes: map[string]schema.Attribute{
6669
"id": schema.StringAttribute{
@@ -324,10 +327,30 @@ func (r *GroupResource) Delete(ctx context.Context, req resource.DeleteRequest,
324327
}
325328

326329
func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
330+
var groupID uuid.UUID
327331
client := r.data.Client
328-
groupID, err := uuid.Parse(req.ID)
329-
if err != nil {
330-
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse import group ID as UUID, got error: %s", err))
332+
idParts := strings.Split(req.ID, "/")
333+
if len(idParts) == 1 {
334+
var err error
335+
groupID, err = uuid.Parse(req.ID)
336+
if err != nil {
337+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to parse import group ID as UUID, got error: %s", err))
338+
return
339+
}
340+
} else if len(idParts) == 2 {
341+
org, err := client.OrganizationByName(ctx, idParts[0])
342+
if err != nil {
343+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get organization with name %s: %s", idParts[0], err))
344+
return
345+
}
346+
group, err := client.GroupByOrgAndName(ctx, org.ID, idParts[1])
347+
if err != nil {
348+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get group with name %s: %s", idParts[1], err))
349+
return
350+
}
351+
groupID = group.ID
352+
} else {
353+
resp.Diagnostics.AddError("Client Error", "Invalid import ID format, expected a single UUID or `<organization-name>/<group-name>`")
331354
return
332355
}
333356
group, err := client.Group(ctx, groupID)
@@ -339,5 +362,5 @@ func (r *GroupResource) ImportState(ctx context.Context, req resource.ImportStat
339362
resp.Diagnostics.AddError("Client Error", "Cannot import groups created via OIDC")
340363
return
341364
}
342-
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
365+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), groupID.String())...)
343366
}

internal/provider/group_resource_test.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,21 @@ func TestAccGroupResource(t *testing.T) {
7878
resource.TestCheckResourceAttr("coderd_group.test", "members.0", user1.ID.String()),
7979
),
8080
},
81-
// Import
81+
// Import by ID
8282
{
83-
Config: cfg1.String(t),
8483
ResourceName: "coderd_group.test",
8584
ImportState: true,
8685
ImportStateVerify: true,
8786
ImportStateVerifyIgnore: []string{"members"},
8887
},
88+
// Import by org name and group name
89+
{
90+
ResourceName: "coderd_group.test",
91+
ImportState: true,
92+
ImportStateId: "default/example-group",
93+
ImportStateVerify: true,
94+
ImportStateVerifyIgnore: []string{"members"},
95+
},
8996
// Update and Read
9097
{
9198
Config: cfg2.String(t),

internal/provider/template_resource.go

+25-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"strings"
910

1011
"cdr.dev/slog"
1112
"github.com/coder/coder/v2/codersdk"
@@ -230,7 +231,8 @@ func (r *TemplateResource) Metadata(ctx context.Context, req resource.MetadataRe
230231
func (r *TemplateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
231232
resp.Schema = schema.Schema{
232233
MarkdownDescription: "A Coder template.\n\nLogs from building template versions are streamed from the provisioner " +
233-
"when the `TF_LOG` environment variable is `INFO` or higher.",
234+
"when the `TF_LOG` environment variable is `INFO` or higher.\n\n" +
235+
"When importing, the ID supplied can be either a template UUID retrieved via the API or `<organization-name>/<template-name>`.",
234236

235237
Attributes: map[string]schema.Attribute{
236238
"id": schema.StringAttribute{
@@ -771,7 +773,28 @@ func (r *TemplateResource) Delete(ctx context.Context, req resource.DeleteReques
771773
}
772774

773775
func (r *TemplateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
774-
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
776+
idParts := strings.Split(req.ID, "/")
777+
if len(idParts) == 1 {
778+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
779+
return
780+
} else if len(idParts) == 2 {
781+
client := r.data.Client
782+
org, err := client.OrganizationByName(ctx, idParts[0])
783+
if err != nil {
784+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get organization with name %s: %s", idParts[0], err))
785+
return
786+
}
787+
template, err := client.TemplateByName(ctx, org.ID, idParts[1])
788+
if err != nil {
789+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Failed to get template with name %s: %s", idParts[1], err))
790+
return
791+
}
792+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), template.ID.String())...)
793+
return
794+
} else {
795+
resp.Diagnostics.AddError("Client Error", "Invalid import ID format, expected a single UUID or `<organization-name>/<template-name>`")
796+
return
797+
}
775798
}
776799

777800
// ConfigValidators implements resource.ResourceWithConfigValidators.

internal/provider/template_resource_test.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestAccTemplateResource(t *testing.T) {
145145
},
146146
Check: testAccCheckNumTemplateVersions(ctx, client, 3),
147147
},
148-
// Import
148+
// Import by ID
149149
{
150150
Config: cfg1.String(t),
151151
ResourceName: "coderd_template.test",
@@ -155,6 +155,14 @@ func TestAccTemplateResource(t *testing.T) {
155155
// We can't import ACL as we can't currently differentiate between managed and unmanaged ACL
156156
ImportStateVerifyIgnore: []string{"versions", "acl"},
157157
},
158+
// Import by org name and template name
159+
{
160+
ResourceName: "coderd_template.test",
161+
ImportState: true,
162+
ImportStateVerify: true,
163+
ImportStateId: "default/example-template",
164+
ImportStateVerifyIgnore: []string{"versions", "acl"},
165+
},
158166
// Change existing version directory & name, update template metadata. Creates a fourth version.
159167
{
160168
Config: cfg2.String(t),

internal/provider/user_resource.go

+16-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/google/uuid"
89
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
910
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1011
"github.com/hashicorp/terraform-plugin-framework/attr"
@@ -55,7 +56,8 @@ func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataReques
5556

5657
func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
5758
resp.Schema = schema.Schema{
58-
MarkdownDescription: "A user on the Coder deployment.",
59+
MarkdownDescription: "A user on the Coder deployment.\n\n" +
60+
"When importing, the ID supplied can be either a user UUID or a username.",
5961

6062
Attributes: map[string]schema.Attribute{
6163
"id": schema.StringAttribute{
@@ -371,6 +373,18 @@ func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, r
371373
tflog.Info(ctx, "successfully deleted user")
372374
}
373375

376+
// Req.ID can be either a UUID or a username.
374377
func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
375-
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
378+
_, err := uuid.Parse(req.ID)
379+
if err == nil {
380+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
381+
return
382+
}
383+
client := r.data.Client
384+
user, err := client.User(ctx, req.ID)
385+
if err != nil {
386+
resp.Diagnostics.AddError("Client Error", "Invalid import ID format, expected a single UUID or a valid username")
387+
return
388+
}
389+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), user.ID.String())...)
376390
}

internal/provider/user_resource_test.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,23 @@ func TestAccUserResource(t *testing.T) {
6060
resource.TestCheckResourceAttr("coderd_user.test", "suspended", "false"),
6161
),
6262
},
63-
// ImportState testing
63+
// Import by ID
6464
{
6565
ResourceName: "coderd_user.test",
6666
ImportState: true,
6767
ImportStateVerify: true,
6868
// We can't pull the password from the API.
6969
ImportStateVerifyIgnore: []string{"password"},
7070
},
71+
// ImportState by username
72+
{
73+
ResourceName: "coderd_user.test",
74+
ImportState: true,
75+
ImportStateVerify: true,
76+
ImportStateId: "example",
77+
// We can't pull the password from the API.
78+
ImportStateVerifyIgnore: []string{"password"},
79+
},
7180
// Update and Read testing
7281
{
7382
Config: cfg2.String(t),

0 commit comments

Comments
 (0)