Skip to content

Commit d5188f1

Browse files
authored
Merge pull request #17 from CruGlobal/idempotent-user-resource
feat(external_user): Add external_user resource
2 parents aaac10a + 1de89c6 commit d5188f1

File tree

10 files changed

+602
-9
lines changed

10 files changed

+602
-9
lines changed

docs/data-sources/user.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ data "semaphoreui_user" "superman" {
4141

4242
### Read-Only
4343

44-
- `admin` (Boolean) Is the user an admin?.
45-
- `alert` (Boolean) Send alerts to the user's email?.
44+
- `admin` (Boolean) Indicates if the user is an admin.
45+
- `alert` (Boolean) Indicates if alerts should be sent to the user's email.
4646
- `created` (String) Creation date of the user.
47-
- `external` (Boolean) Is the user linked to an external identity provider?.
47+
- `external` (Boolean) Indicates if the user is linked to an external identity provider.
4848
- `name` (String) Display name.
4949
- `password` (String, Sensitive) This value is never returned by the API and will be an empty string.

docs/resources/external_user.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "semaphoreui_external_user Resource - semaphoreui"
4+
subcategory: ""
5+
description: |-
6+
The external user resource allows you to manage an external User in SemaphoreUI. This resource will not error if the user with the given username already exists and will not delete the user from SemaphoreUI when the resource is destroyed. This is useful when needing to manage the same user in multiple Terraform states.
7+
---
8+
9+
# semaphoreui_external_user (Resource)
10+
11+
The external user resource allows you to manage an external User in SemaphoreUI. This resource will not error if the user with the given username already exists and will not delete the user from SemaphoreUI when the resource is destroyed. This is useful when needing to manage the same user in multiple Terraform states.
12+
13+
## Example Usage
14+
15+
```terraform
16+
resource "semaphoreui_external_user" "example" {
17+
username = "login_name"
18+
name = "Full Name"
19+
20+
21+
admin = false
22+
alert = false
23+
}
24+
```
25+
26+
<!-- schema generated by tfplugindocs -->
27+
## Schema
28+
29+
### Required
30+
31+
- `email` (String) Email address.
32+
- `name` (String) Display name.
33+
- `username` (String) Username.
34+
35+
### Optional
36+
37+
- `admin` (Boolean) Indicates if the user is an admin. Value defaults to `false`.
38+
- `alert` (Boolean) Indicates if alerts should be sent to the user's email. Value defaults to `false`.
39+
40+
### Read-Only
41+
42+
- `created` (String) Creation date of the user.
43+
- `external` (Boolean) Indicates if the user is linked to an external identity provider. Value defaults to `true`.
44+
- `id` (Number) The ID of the user.
45+
46+
## Import
47+
48+
Import is supported using the following syntax:
49+
50+
```shell
51+
# Import ID is specified by the string "user/{user_id}".
52+
# - {user_id} is the ID of the user in SemaphoreUI.
53+
terraform import semaphoreui_external_user.example user/1
54+
```
55+
Or using `import {}` block in the configuration file:
56+
```hcl
57+
import {
58+
to = semaphoreui_external_user.example
59+
id = "user/1"
60+
}
61+
```

docs/resources/user.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ resource "semaphoreui_user" "example" {
3636

3737
### Optional
3838

39-
- `admin` (Boolean) Is the user an admin?. Value defaults to `false`.
40-
- `alert` (Boolean) Send alerts to the user's email?. Value defaults to `false`.
41-
- `external` (Boolean) (ForceNew) Is the user linked to an external identity provider?. Value defaults to `false`.
39+
- `admin` (Boolean) Indicates if the user is an admin. Value defaults to `false`.
40+
- `alert` (Boolean) Indicates if alerts should be sent to the user's email. Value defaults to `false`.
41+
- `external` (Boolean) (ForceNew) Indicates if the user is linked to an external identity provider. Value defaults to `false`.
4242
- `password` (String, Sensitive) Login Password. This value is never returned by the API and will be an empty string after import.
4343

4444
### Read-Only
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Import ID is specified by the string "user/{user_id}".
2+
# - {user_id} is the ID of the user in SemaphoreUI.
3+
terraform import semaphoreui_external_user.example user/1
4+
```
5+
Or using `import {}` block in the configuration file:
6+
```hcl
7+
import {
8+
to = semaphoreui_external_user.example
9+
id = "user/1"
10+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
resource "semaphoreui_external_user" "example" {
2+
username = "login_name"
3+
name = "Full Name"
4+
5+
6+
admin = false
7+
alert = false
8+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"github.com/hashicorp/terraform-plugin-framework/path"
7+
"github.com/hashicorp/terraform-plugin-framework/resource"
8+
"github.com/hashicorp/terraform-plugin-framework/types"
9+
apiclient "terraform-provider-semaphoreui/semaphoreui/client"
10+
"terraform-provider-semaphoreui/semaphoreui/client/user"
11+
"terraform-provider-semaphoreui/semaphoreui/models"
12+
)
13+
14+
// Ensure the implementation satisfies the expected interfaces.
15+
var (
16+
_ resource.Resource = &externalUserResource{}
17+
_ resource.ResourceWithConfigure = &externalUserResource{}
18+
_ resource.ResourceWithImportState = &externalUserResource{}
19+
)
20+
21+
func NewExternalUserResource() resource.Resource {
22+
return &externalUserResource{}
23+
}
24+
25+
type externalUserResource struct {
26+
client *apiclient.SemaphoreUI
27+
}
28+
29+
func (r *externalUserResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
30+
if req.ProviderData == nil {
31+
return
32+
}
33+
34+
client, ok := req.ProviderData.(*apiclient.SemaphoreUI)
35+
36+
if !ok {
37+
resp.Diagnostics.AddError(
38+
"Unexpected Resource Configure Type",
39+
"Expected *client.SemaphoreUI, got %T. Please report this issue to the provider developers.",
40+
)
41+
return
42+
}
43+
r.client = client
44+
}
45+
46+
func (r *externalUserResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
47+
resp.TypeName = req.ProviderTypeName + "_external_user"
48+
}
49+
50+
func (r *externalUserResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
51+
resp.Schema = ExternalUserSchema().GetResource(ctx)
52+
}
53+
54+
func convertResponseToExternalUserModel(user *models.User) ExternalUserModel {
55+
return ExternalUserModel{
56+
ID: types.Int64Value(user.ID),
57+
Username: types.StringValue(user.Username),
58+
Name: types.StringValue(user.Name),
59+
Email: types.StringValue(user.Email),
60+
Admin: types.BoolValue(user.Admin),
61+
Alert: types.BoolValue(user.Alert),
62+
External: types.BoolValue(user.External),
63+
Created: types.StringValue(user.Created),
64+
}
65+
}
66+
67+
func convertExternalUserModelToUserRequest(user ExternalUserModel) *models.UserRequest {
68+
return &models.UserRequest{
69+
Username: user.Username.ValueString(),
70+
Name: user.Name.ValueString(),
71+
Email: user.Email.ValueString(),
72+
Admin: user.Admin.ValueBool(),
73+
Alert: user.Alert.ValueBool(),
74+
External: user.External.ValueBool(),
75+
}
76+
}
77+
78+
func convertExternalUserModelToUserPutRequest(user ExternalUserModel) *models.UserPutRequest {
79+
return &models.UserPutRequest{
80+
Username: user.Username.ValueString(),
81+
Name: user.Name.ValueString(),
82+
Email: user.Email.ValueString(),
83+
Admin: user.Admin.ValueBool(),
84+
Alert: user.Alert.ValueBool(),
85+
}
86+
}
87+
88+
func (r *externalUserResource) GetExternalUserByUsername(username string) (*ExternalUserModel, error) {
89+
response, err := r.client.User.GetUsers(&user.GetUsersParams{}, nil)
90+
if err != nil {
91+
return nil, fmt.Errorf("could not get users: %s", err.Error())
92+
}
93+
for _, usr := range response.Payload {
94+
if usr.Username == username {
95+
model := convertResponseToExternalUserModel(usr)
96+
return &model, nil
97+
}
98+
}
99+
return nil, fmt.Errorf("user with username %s not found", username)
100+
}
101+
102+
func (r *externalUserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
103+
// Retrieve values from plan
104+
var plan ExternalUserModel
105+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
106+
if resp.Diagnostics.HasError() {
107+
return
108+
}
109+
110+
var id types.Int64
111+
112+
// Lookup user by username
113+
externalUser, err := r.GetExternalUserByUsername(plan.Username.ValueString())
114+
if err != nil {
115+
// If user not found, create new user
116+
if err.Error() == fmt.Sprintf("user with username %s not found", plan.Username.ValueString()) {
117+
response, err := r.client.User.PostUsers(&user.PostUsersParams{
118+
User: convertExternalUserModelToUserRequest(plan),
119+
}, nil)
120+
if err != nil {
121+
resp.Diagnostics.AddError(
122+
"Error Creating SemaphoreUI User",
123+
"Could not create user, unexpected error: "+err.Error(),
124+
)
125+
return
126+
}
127+
id = convertResponseToExternalUserModel(response.Payload).ID
128+
} else {
129+
resp.Diagnostics.AddError(
130+
"Error Creating SemaphoreUI User",
131+
"Could not create user, unexpected error: "+err.Error(),
132+
)
133+
return
134+
}
135+
} else {
136+
// If user found, update the user
137+
plan.ID = externalUser.ID
138+
_, err := r.client.User.PutUsersUserID(&user.PutUsersUserIDParams{
139+
UserID: plan.ID.ValueInt64(),
140+
User: convertExternalUserModelToUserPutRequest(plan),
141+
}, nil)
142+
if err != nil {
143+
resp.Diagnostics.AddError(
144+
"Error Creating Semaphore User",
145+
"Could not update user, unexpected error: "+err.Error(),
146+
)
147+
return
148+
}
149+
id = plan.ID
150+
}
151+
152+
response, err := r.client.User.GetUsersUserID(&user.GetUsersUserIDParams{UserID: id.ValueInt64()}, nil)
153+
if err != nil {
154+
resp.Diagnostics.AddError(
155+
"Error Creating Semaphore User",
156+
"Could not read user, unexpected error: "+err.Error(),
157+
)
158+
return
159+
}
160+
model := convertResponseToExternalUserModel(response.Payload)
161+
162+
resp.Diagnostics.Append(resp.State.Set(ctx, &model)...)
163+
if resp.Diagnostics.HasError() {
164+
return
165+
}
166+
}
167+
168+
// Read refreshes the Terraform state with the latest data.
169+
func (r *externalUserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
170+
// Get current state
171+
var state ExternalUserModel
172+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
173+
if resp.Diagnostics.HasError() {
174+
return
175+
}
176+
177+
// Get refreshed value from API
178+
response, err := r.client.User.GetUsersUserID(&user.GetUsersUserIDParams{UserID: state.ID.ValueInt64()}, nil)
179+
if err != nil {
180+
resp.Diagnostics.AddError(
181+
"Error Reading Semaphore User",
182+
"Could not read user, unexpected error: "+err.Error(),
183+
)
184+
return
185+
}
186+
187+
// Overwrite with refreshed state
188+
state = convertResponseToExternalUserModel(response.Payload)
189+
190+
// Set refreshed state
191+
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
192+
if resp.Diagnostics.HasError() {
193+
return
194+
}
195+
}
196+
197+
// Update updates the resource and sets the updated Terraform state on success.
198+
func (r *externalUserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
199+
// Retrieve values from plan
200+
var plan ExternalUserModel
201+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
202+
if resp.Diagnostics.HasError() {
203+
return
204+
}
205+
206+
// Generate API request body from plan
207+
var payload = convertExternalUserModelToUserPutRequest(plan)
208+
209+
// Update existing resource
210+
_, err := r.client.User.PutUsersUserID(&user.PutUsersUserIDParams{UserID: plan.ID.ValueInt64(), User: payload}, nil)
211+
if err != nil {
212+
resp.Diagnostics.AddError(
213+
"Error Updating Semaphore User",
214+
"Could not update user, unexpected error: "+err.Error(),
215+
)
216+
return
217+
}
218+
219+
// Fetch updated values as PutUsersUserIDParams does not return updated user
220+
response, err := r.client.User.GetUsersUserID(&user.GetUsersUserIDParams{UserID: plan.ID.ValueInt64()}, nil)
221+
if err != nil {
222+
resp.Diagnostics.AddError(
223+
"Error Reading Semaphore User",
224+
"Could not read user, unexpected error: "+err.Error(),
225+
)
226+
return
227+
}
228+
229+
// Update resource state with updated user
230+
plan = convertResponseToExternalUserModel(response.Payload)
231+
232+
resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
233+
if resp.Diagnostics.HasError() {
234+
return
235+
}
236+
}
237+
238+
func (r *externalUserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
239+
// Retrieve values from state
240+
var state ExternalUserModel
241+
diags := req.State.Get(ctx, &state)
242+
resp.Diagnostics.Append(diags...)
243+
if resp.Diagnostics.HasError() {
244+
return
245+
}
246+
// NOOP: External users are not deleted, just removed from state
247+
}
248+
249+
func (r *externalUserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
250+
fields, err := parseImportFields(req.ID, []string{"user"})
251+
if err != nil {
252+
resp.Diagnostics.AddError(
253+
"Invalid External User Import ID",
254+
"Could not parse import ID: "+err.Error(),
255+
)
256+
return
257+
}
258+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), fields["user"])...)
259+
}

0 commit comments

Comments
 (0)