diff --git a/Makefile b/Makefile index a07d4f9..d63dfaa 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ GOBIN := $(shell go env GOPATH)/bin endif OSARCH=$(shell uname -m) GOLANGCI_LINT_PATH=$(GOBIN)/golangci-lint +TFPLUGINDOCS_PATH=$(GOBIN)/tfplugindocs .PHONY: install-git-hooks install-git-hooks: @@ -15,8 +16,8 @@ install-git-hooks: .PHONY: install-tfplugindocs install-tfplugindocs: - @echo "Installing github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs..." - @go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.16.0 + @echo "Installing github.com/hashicorp/terraform-plugin-docs..." + @(test -f $(TFPLUGINDOCS_PATH) && echo "github.com/hashicorp/terraform-plugin-docs is already installed. Skipping...") || (wget -q -O $(TFPLUGINDOCS_PATH).zip https://github.com/hashicorp/terraform-plugin-docs/releases/download/v0.16.0/tfplugindocs_0.16.0_$(GOOS)_$(GOARCH).zip && unzip $(TFPLUGINDOCS_PATH).zip tfplugindocs -d $(GOBIN) && rm $(TFPLUGINDOCS_PATH).zip) .PHONY: install-golangci-lint install-golangci-lint: diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index 6fe8806..07a4274 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -33,8 +33,8 @@ data "woodpecker_user" "user" { ### Read-Only -- `active` (Boolean) Whether user is active in the system -- `admin` (Boolean) Whether user is an admin +- `active` (Boolean) whether user is active in the system +- `admin` (Boolean) whether user is an admin - `avatar` (String) the user's avatar URL - `email` (String) the user's email - `id` (Number) the user's id diff --git a/docs/resources/user.md b/docs/resources/user.md new file mode 100644 index 0000000..0ba4014 --- /dev/null +++ b/docs/resources/user.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "woodpecker_user Resource - terraform-provider-woodpecker" +subcategory: "" +description: |- + Provides a user resource. + This resource allows you to add/remove users. When applied, a new user will be created. When destroyed, that user will be removed. +--- + +# woodpecker_user (Resource) + +Provides a user resource. + + +This resource allows you to add/remove users. When applied, a new user will be created. When destroyed, that user will be removed. + +## Example Usage + +```terraform +# Create a user +resource "woodpecker_user" "test" { + login = "test" + email = "test@localhost" +} +``` + + +## Schema + +### Required + +- `login` (String) the name of the user + +### Optional + +- `admin` (Boolean) whether user is an admin +- `avatar` (String) the user's avatar URL +- `email` (String) the email of the user + +### Read-Only + +- `active` (Boolean) whether user is active in the system +- `id` (Number) the user's id diff --git a/examples/resources/woodpecker_user/resource.tf b/examples/resources/woodpecker_user/resource.tf new file mode 100644 index 0000000..72754b8 --- /dev/null +++ b/examples/resources/woodpecker_user/resource.tf @@ -0,0 +1,5 @@ +# Create a user +resource "woodpecker_user" "test" { + login = "test" + email = "test@localhost" +} diff --git a/internal/data_source_user.go b/internal/data_source_user.go index 58c4475..bee7861 100644 --- a/internal/data_source_user.go +++ b/internal/data_source_user.go @@ -6,7 +6,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/woodpecker-ci/woodpecker/woodpecker-go/woodpecker" ) @@ -15,36 +14,18 @@ type userDataSource struct { } var _ datasource.DataSource = (*userDataSource)(nil) +var _ datasource.DataSourceWithConfigure = (*userDataSource)(nil) func newUserDataSource() datasource.DataSource { return &userDataSource{} } -type userDataSourceModel struct { - ID types.Int64 `tfsdk:"id"` - Login types.String `tfsdk:"login"` - Email types.String `tfsdk:"email"` - Avatar types.String `tfsdk:"avatar"` - Active types.Bool `tfsdk:"active"` - Admin types.Bool `tfsdk:"admin"` -} - -func (m *userDataSourceModel) setValues(user *woodpecker.User) { - m.ID = types.Int64Value(user.ID) - m.Login = types.StringValue(user.Login) - m.Email = types.StringValue(user.Email) - m.Avatar = types.StringValue(user.Avatar) - m.Active = types.BoolValue(user.Active) - m.Admin = types.BoolValue(user.Admin) -} - func (d *userDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_user" } func (d *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - // This description is used by the documentation generator and the language server. MarkdownDescription: "Use this data source to retrieve information about a user.", Attributes: map[string]schema.Attribute{ @@ -66,17 +47,17 @@ func (d *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r }, "active": schema.BoolAttribute{ Computed: true, - Description: "Whether user is active in the system", + Description: "whether user is active in the system", }, "admin": schema.BoolAttribute{ Computed: true, - Description: "Whether user is an admin", + Description: "whether user is an admin", }, }, } } -func (d *userDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { +func (d *userDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return @@ -95,9 +76,8 @@ func (d *userDataSource) Configure(ctx context.Context, req datasource.Configure } func (d *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data userDataSourceModel + var data userModel - // Read Terraform configuration data into the model resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return @@ -117,6 +97,5 @@ func (d *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r data.setValues(user) - // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/internal/models.go b/internal/models.go new file mode 100644 index 0000000..416d0fd --- /dev/null +++ b/internal/models.go @@ -0,0 +1,35 @@ +package internal + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/woodpecker-ci/woodpecker/woodpecker-go/woodpecker" +) + +type userModel struct { + ID types.Int64 `tfsdk:"id"` + Login types.String `tfsdk:"login"` + Email types.String `tfsdk:"email"` + Avatar types.String `tfsdk:"avatar"` + Active types.Bool `tfsdk:"active"` + Admin types.Bool `tfsdk:"admin"` +} + +func (m *userModel) setValues(user *woodpecker.User) { + m.ID = types.Int64Value(user.ID) + m.Login = types.StringValue(user.Login) + m.Email = types.StringValue(user.Email) + m.Avatar = types.StringValue(user.Avatar) + m.Active = types.BoolValue(user.Active) + m.Admin = types.BoolValue(user.Admin) +} + +func (m *userModel) toWoodpeckerModel() *woodpecker.User { + return &woodpecker.User{ + ID: m.ID.ValueInt64(), + Login: m.Login.ValueString(), + Email: m.Email.ValueString(), + Avatar: m.Avatar.ValueString(), + Active: m.Active.ValueBool(), + Admin: m.Admin.ValueBool(), + } +} diff --git a/internal/provider.go b/internal/provider.go index 7d1b0cd..33e9052 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -15,7 +15,6 @@ import ( type woodpeckerProvider struct { version string - client woodpecker.Client } var _ provider.Provider = (*woodpeckerProvider)(nil) @@ -60,7 +59,9 @@ func (p *woodpeckerProvider) DataSources(_ context.Context) []func() datasource. } func (p *woodpeckerProvider) Resources(_ context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{ + newUserResource, + } } func (p *woodpeckerProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { @@ -69,10 +70,10 @@ func (p *woodpeckerProvider) Configure(ctx context.Context, req provider.Configu return } - p.client = newClient(ctx, cfg, resp) + client := newClient(ctx, cfg, resp) - resp.DataSourceData = p.client - resp.ResourceData = p.client + resp.DataSourceData = client + resp.ResourceData = client } type providerConfig struct { diff --git a/internal/resource_user.go b/internal/resource_user.go new file mode 100644 index 0000000..356f02b --- /dev/null +++ b/internal/resource_user.go @@ -0,0 +1,163 @@ +package internal + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/woodpecker-ci/woodpecker/woodpecker-go/woodpecker" +) + +type userResource struct { + client woodpecker.Client +} + +var _ resource.Resource = (*userResource)(nil) +var _ resource.ResourceWithConfigure = (*userResource)(nil) +var _ resource.ResourceWithImportState = (*userResource)(nil) + +func newUserResource() resource.Resource { + return &userResource{} +} + +func (r *userResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `Provides a user resource. + + +This resource allows you to add/remove users. When applied, a new user will be created. When destroyed, that user will be removed.`, + + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + Description: "the user's id", + }, + "login": schema.StringAttribute{ + Required: true, + Description: "the name of the user", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "email": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "the email of the user", + }, + "avatar": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "the user's avatar URL", + }, + "active": schema.BoolAttribute{ + Computed: true, + Description: "whether user is active in the system", + }, + "admin": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "whether user is an admin", + }, + }, + } +} + +func (r *userResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(woodpecker.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected woodpecker.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data userModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + user, err := r.client.UserPost(data.toWoodpeckerModel()) + if err != nil { + resp.Diagnostics.AddError("Couldn't create user", err.Error()) + return + } + + data.setValues(user) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data userModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + user, err := r.client.User(data.Login.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Couldn't get user", err.Error()) + } + + data.setValues(user) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data userModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + user, err := r.client.UserPatch(data.toWoodpeckerModel()) + if err != nil { + resp.Diagnostics.AddError("Couldn't update user", err.Error()) + return + } + + data.setValues(user) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data userModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.UserDel(data.Login.ValueString()); err != nil { + resp.Diagnostics.AddError("Couldn't delete user", err.Error()) + return + } +} + +func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("login"), req.ID)...) +} diff --git a/internal/resource_user_test.go b/internal/resource_user_test.go new file mode 100644 index 0000000..3e2f18c --- /dev/null +++ b/internal/resource_user_test.go @@ -0,0 +1,93 @@ +package internal_test + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" +) + +func TestResourceUser(t *testing.T) { + t.Parallel() + + login := uuid.NewString() + newLogin := uuid.NewString() + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +resource "woodpecker_user" "test_user" { + login = "%s" +} +`, login), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("woodpecker_user.test_user", "login", login), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "email", ""), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "avatar", ""), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "active", "false"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "admin", "false"), + ), + }, + { + Config: fmt.Sprintf(` +resource "woodpecker_user" "test_user" { + login = "%s" + email = "%s@localhost" + avatar = "http://localhost/%s" + admin = true +} +`, login, login, login), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("woodpecker_user.test_user", "login", login), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "email", login+"@localhost"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "avatar", "http://localhost/"+login), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "active", "false"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "admin", "true"), + ), + }, + { + Config: fmt.Sprintf(` +resource "woodpecker_user" "test_user" { + login = "%s" +} +`, login), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("woodpecker_user.test_user", "login", login), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "email", login+"@localhost"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "avatar", "http://localhost/"+login), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "active", "false"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "admin", "true"), + ), + }, + { + ResourceName: "woodpecker_user.test_user", + ImportState: true, + ImportStateId: login, + ImportStateVerify: true, + }, + { + Config: fmt.Sprintf(` +resource "woodpecker_user" "test_user" { + login = "%s" +} +`, newLogin), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("woodpecker_user.test_user", plancheck.ResourceActionReplace), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("woodpecker_user.test_user", "login", newLogin), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "email", ""), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "avatar", ""), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "active", "false"), + resource.TestCheckResourceAttr("woodpecker_user.test_user", "admin", "false"), + ), + }, + }, + }) +}