From 8c662af48f12d8cf651bd1b0fc5af05a9f94c1c8 Mon Sep 17 00:00:00 2001 From: Keisuke Nitta Date: Thu, 4 Jul 2024 14:17:44 +0900 Subject: [PATCH 1/3] feat: add RoleModel --- internal/mackerel/role.go | 133 +++++++++++++++++++++++++ internal/mackerel/role_test.go | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 internal/mackerel/role.go create mode 100644 internal/mackerel/role_test.go diff --git a/internal/mackerel/role.go b/internal/mackerel/role.go new file mode 100644 index 0000000..862669d --- /dev/null +++ b/internal/mackerel/role.go @@ -0,0 +1,133 @@ +package mackerel + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mackerelio/mackerel-client-go" +) + +type RoleModel struct { + ID types.String `tfsdk:"id"` + ServiceName types.String `tfsdk:"service"` + RoleName types.String `tfsdk:"name"` + Memo types.String `tfsdk:"memo"` +} + +func roleID(serviceName, roleName string) string { + return fmt.Sprintf("%s:%s", serviceName, roleName) +} + +func parseRoleID(id string) (serviceName, roleName string, err error) { + serviceName, roleName, ok := strings.Cut(id, ":") + if !ok { + return "", "", fmt.Errorf("The ID is expected to have `:` format, but got: '%s'.", id) + } + return serviceName, roleName, nil +} + +var roleNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]+$`) + +func RoleNameValidator() validator.String { + return stringvalidator.All( + stringvalidator.LengthBetween(2, 63), + stringvalidator.RegexMatches( + roleNameRegex, + "it can only contain letters, numbers, hyphens, and underscores and cannot start with a hyphen or underscore", + ), + ) +} + +func ReadRole(ctx context.Context, client *Client, serviceName, roleName string) (RoleModel, error) { + return readRoleInner(ctx, client, serviceName, roleName) +} + +type roleFinder interface { + FindRoles(string) ([]*mackerel.Role, error) +} + +func readRoleInner(_ context.Context, client roleFinder, serviceName, roleName string) (RoleModel, error) { + roles, err := client.FindRoles(serviceName) + if err != nil { + return RoleModel{}, err + } + + roleIdx := slices.IndexFunc(roles, func(r *mackerel.Role) bool { + return r.Name == roleName + }) + if roleIdx < 0 { + return RoleModel{}, fmt.Errorf("the name '%s' does not match any role in mackerel.io", roleName) + } + + role := roles[roleIdx] + return RoleModel{ + ID: types.StringValue(roleID(serviceName, roleName)), + ServiceName: types.StringValue(serviceName), + RoleName: types.StringValue(roleName), + Memo: types.StringValue(role.Memo), + }, nil +} + +func (m *RoleModel) Create(_ context.Context, client *Client) error { + serviceName := m.ServiceName.ValueString() + if _, err := client.CreateRole(serviceName, &mackerel.CreateRoleParam{ + Name: m.RoleName.ValueString(), + Memo: m.Memo.ValueString(), + }); err != nil { + return err + } + + m.ID = types.StringValue(roleID(serviceName, m.RoleName.ValueString())) + + return nil +} + +func (m *RoleModel) Read(ctx context.Context, client *Client) error { + return m.readInner(ctx, client) +} +func (m *RoleModel) readInner(ctx context.Context, client roleFinder) error { + // In ImportState, attributes other than `id` are unset. + var serviceName, roleName string + if !m.ID.IsNull() && !m.ID.IsUnknown() { + s, r, err := parseRoleID(m.ID.ValueString()) + if err != nil { + return err + } + serviceName, roleName = s, r + } else { + serviceName = m.ServiceName.ValueString() + roleName = m.RoleName.ValueString() + } + + r, err := readRoleInner(ctx, client, serviceName, roleName) + if err != nil { + return err + } + + m.ID = r.ID // computed + m.ServiceName = r.ServiceName // required + m.RoleName = r.RoleName // required + + // optional + if /* preserve null */ !m.Memo.IsNull() || r.Memo.ValueString() != "" { + m.Memo = r.Memo + } + + return nil +} + +func (m *RoleModel) Delete(_ context.Context, client *Client) error { + if _, err := client.DeleteRole( + m.ServiceName.ValueString(), + m.RoleName.ValueString(), + ); err != nil { + return err + } + return nil +} diff --git a/internal/mackerel/role_test.go b/internal/mackerel/role_test.go new file mode 100644 index 0000000..d9c8054 --- /dev/null +++ b/internal/mackerel/role_test.go @@ -0,0 +1,175 @@ +package mackerel + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mackerelio/mackerel-client-go" +) + +func Test_Role_ReadRole(t *testing.T) { + t.Parallel() + + defaultClient := func(service string) ([]*mackerel.Role, error) { + if service != "service0" { + return nil, fmt.Errorf("service not found") + } + return []*mackerel.Role{ + {Name: "role0", Memo: "memo"}, + {Name: "role1"}, + }, nil + } + + cases := map[string]struct { + inService string + inRole string + inClient roleFinderFunc + + wants RoleModel + wantErr bool + }{ + "valid": { + inService: "service0", + inRole: "role0", + inClient: defaultClient, + + wants: RoleModel{ + ID: types.StringValue("service0:role0"), + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role0"), + Memo: types.StringValue("memo"), + }, + }, + "no role": { + inService: "service0", + inRole: "role-not-exists", + inClient: defaultClient, + + wantErr: true, + }, + } + + ctx := context.Background() + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := readRoleInner(ctx, tt.inClient, tt.inService, tt.inRole) + if err != nil { + if !tt.wantErr { + t.Errorf("unexpected error: %+v", err) + } + return + } else if tt.wantErr { + t.Error("expected error, but got no error") + return + } + + if diff := cmp.Diff(data, tt.wants); diff != "" { + t.Error(diff) + } + }) + } +} + +func Test_RoleModel_Read(t *testing.T) { + t.Parallel() + + defaultClient := roleFinderFunc(func(service string) ([]*mackerel.Role, error) { + if service != "service0" { + return nil, fmt.Errorf("service not found") + } + return []*mackerel.Role{ + {Name: "role0", Memo: "memo"}, + {Name: "role1"}, + }, nil + }) + + cases := map[string]struct { + in RoleModel + inClient roleFinderFunc + + wants RoleModel + wantErr bool + }{ + "from id": { + in: RoleModel{ID: types.StringValue("service0:role0")}, + inClient: defaultClient, + + wants: RoleModel{ + ID: types.StringValue("service0:role0"), + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role0"), + Memo: types.StringValue("memo"), + }, + }, + "invalid id": { + in: RoleModel{ID: types.StringValue("invalid id")}, + inClient: defaultClient, + + wantErr: true, + }, + "from service and name": { + in: RoleModel{ + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role0"), + }, + inClient: defaultClient, + + wants: RoleModel{ + ID: types.StringValue("service0:role0"), + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role0"), + Memo: types.StringValue("memo"), + }, + }, + // TODO: smart handling for preserving null + /* "preserve null memo": { + in: RoleModel{ + ID: types.StringValue("service0:role1"), + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role1"), + Memo: types.StringNull(), + }, + inClient: defaultClient, + + wants: RoleModel{ + ID: types.StringValue("service0:role1"), + ServiceName: types.StringValue("service0"), + RoleName: types.StringValue("role1"), + Memo: types.StringNull(), // not types.StringValue("") + }, + }, */ + } + + ctx := context.Background() + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m := tt.in + if err := m.readInner(ctx, tt.inClient); err != nil { + if !tt.wantErr { + t.Errorf("unexpected error: %+v", err) + } + return + } else if tt.wantErr { + t.Error("expected error, but got no error") + return + } + + if diff := cmp.Diff(m, tt.wants); diff != "" { + t.Error(diff) + } + }) + } +} + +type roleFinderFunc func(string) ([]*mackerel.Role, error) + +func (f roleFinderFunc) FindRoles(serviceName string) ([]*mackerel.Role, error) { + return f(serviceName) +} From 706cb20137c3573f5f4647ac114956a1c78a6307 Mon Sep 17 00:00:00 2001 From: Keisuke Nitta Date: Thu, 4 Jul 2024 14:29:36 +0900 Subject: [PATCH 2/3] feat: reimplement data source "mackerel_role" --- .../provider/data_source_mackerel_role.go | 80 +++++++++++++++++++ .../data_source_mackerel_role_test.go | 25 ++++++ internal/provider/provider.go | 1 + mackerel/provider.go | 1 + 4 files changed, 107 insertions(+) create mode 100644 internal/provider/data_source_mackerel_role.go create mode 100644 internal/provider/data_source_mackerel_role_test.go diff --git a/internal/provider/data_source_mackerel_role.go b/internal/provider/data_source_mackerel_role.go new file mode 100644 index 0000000..a8aa795 --- /dev/null +++ b/internal/provider/data_source_mackerel_role.go @@ -0,0 +1,80 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/mackerel" +) + +var ( + _ datasource.DataSource = (*mackerelRoleDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*mackerelRoleDataSource)(nil) +) + +func NewMackerelRoleDataSource() datasource.DataSource { + return &mackerelRoleDataSource{} +} + +type mackerelRoleDataSource struct { + Client *mackerel.Client +} + +func (d *mackerelRoleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_role" +} + +func (d *mackerelRoleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "This data source allows access to details of a specific Role.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "service": schema.StringAttribute{ + Description: "The name of the service.", + Required: true, + Validators: []validator.String{mackerel.ServiceNameValidator()}, + }, + "name": schema.StringAttribute{ + Description: "The name of the role.", + Required: true, + Validators: []validator.String{mackerel.RoleNameValidator()}, + }, + "memo": schema.StringAttribute{ + Description: "Notes related to this role.", + Computed: true, + }, + }, + } +} + +func (d *mackerelRoleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + client, diags := retrieveClient(ctx, req.ProviderData) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + d.Client = client +} + +func (d *mackerelRoleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config mackerel.RoleModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + data, err := mackerel.ReadRole(ctx, d.Client, config.ServiceName.ValueString(), config.RoleName.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Unable to read Role", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/data_source_mackerel_role_test.go b/internal/provider/data_source_mackerel_role_test.go new file mode 100644 index 0000000..286a7ff --- /dev/null +++ b/internal/provider/data_source_mackerel_role_test.go @@ -0,0 +1,25 @@ +package provider_test + +import ( + "context" + "testing" + + fwdatasource "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/provider" +) + +func Test_MackerelRoleDataSource_schema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + req := fwdatasource.SchemaRequest{} + resp := fwdatasource.SchemaResponse{} + provider.NewMackerelRoleDataSource().Schema(ctx, req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema: %+v", resp.Diagnostics) + } + + if diags := resp.Schema.ValidateImplementation(ctx); diags.HasError() { + t.Fatalf("schema validation: %+v", diags) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0ba4bc1..4e633eb 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -92,6 +92,7 @@ func (m *mackerelProvider) Resources(context.Context) []func() resource.Resource func (m *mackerelProvider) DataSources(context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewMackerelNotificationGroupDataSource, + NewMackerelRoleDataSource, NewMackerelServiceDataSource, NewMackerelServiceMetadataDataSource, NewMackerelServiceMetricNamesDataSource, diff --git a/mackerel/provider.go b/mackerel/provider.go index 0abc2b5..cdea6ad 100644 --- a/mackerel/provider.go +++ b/mackerel/provider.go @@ -88,6 +88,7 @@ func protoV5ProviderServer(provider *schema.Provider) tfprotov5.ProviderServer { // Data Sources delete(provider.DataSourcesMap, "mackerel_notification_group") + delete(provider.DataSourcesMap, "mackerel_role") delete(provider.DataSourcesMap, "mackerel_service") delete(provider.DataSourcesMap, "mackerel_service_metadata") delete(provider.DataSourcesMap, "mackerel_service_metric_names") From e1fff878082ec90ce62ead9ab317bf9df87686d0 Mon Sep 17 00:00:00 2001 From: Keisuke Nitta Date: Thu, 4 Jul 2024 18:54:20 +0900 Subject: [PATCH 3/3] feat: reimplement resource "mackerel_role" --- internal/mackerel/role.go | 6 +- internal/mackerel/role_test.go | 17 -- internal/provider/provider.go | 1 + internal/provider/resource_mackerel_role.go | 147 ++++++++++++++++++ .../provider/resource_mackerel_role_test.go | 26 ++++ mackerel/provider.go | 1 + 6 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 internal/provider/resource_mackerel_role.go create mode 100644 internal/provider/resource_mackerel_role_test.go diff --git a/internal/mackerel/role.go b/internal/mackerel/role.go index 862669d..be3bc1a 100644 --- a/internal/mackerel/role.go +++ b/internal/mackerel/role.go @@ -113,11 +113,7 @@ func (m *RoleModel) readInner(ctx context.Context, client roleFinder) error { m.ID = r.ID // computed m.ServiceName = r.ServiceName // required m.RoleName = r.RoleName // required - - // optional - if /* preserve null */ !m.Memo.IsNull() || r.Memo.ValueString() != "" { - m.Memo = r.Memo - } + m.Memo = r.Memo // has default return nil } diff --git a/internal/mackerel/role_test.go b/internal/mackerel/role_test.go index d9c8054..f5e4bc5 100644 --- a/internal/mackerel/role_test.go +++ b/internal/mackerel/role_test.go @@ -126,23 +126,6 @@ func Test_RoleModel_Read(t *testing.T) { Memo: types.StringValue("memo"), }, }, - // TODO: smart handling for preserving null - /* "preserve null memo": { - in: RoleModel{ - ID: types.StringValue("service0:role1"), - ServiceName: types.StringValue("service0"), - RoleName: types.StringValue("role1"), - Memo: types.StringNull(), - }, - inClient: defaultClient, - - wants: RoleModel{ - ID: types.StringValue("service0:role1"), - ServiceName: types.StringValue("service0"), - RoleName: types.StringValue("role1"), - Memo: types.StringNull(), // not types.StringValue("") - }, - }, */ } ctx := context.Background() diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4e633eb..403bbd2 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -84,6 +84,7 @@ func (m *mackerelProvider) Configure(ctx context.Context, req provider.Configure func (m *mackerelProvider) Resources(context.Context) []func() resource.Resource { return []func() resource.Resource{ NewMackerelNotificationGroupResource, + NewMackerelRoleResource, NewMackerelServiceResource, NewMackerelServiceMetadataResource, } diff --git a/internal/provider/resource_mackerel_role.go b/internal/provider/resource_mackerel_role.go new file mode 100644 index 0000000..ff47807 --- /dev/null +++ b/internal/provider/resource_mackerel_role.go @@ -0,0 +1,147 @@ +package provider + +import ( + "context" + + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/mackerel" +) + +var ( + _ resource.Resource = (*mackerelRoleResource)(nil) + _ resource.ResourceWithConfigure = (*mackerelRoleResource)(nil) + _ resource.ResourceWithImportState = (*mackerelRoleResource)(nil) +) + +func NewMackerelRoleResource() resource.Resource { + return &mackerelRoleResource{} +} + +type mackerelRoleResource struct { + Client *mackerel.Client +} + +func (r *mackerelRoleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_role" +} + +func (r *mackerelRoleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "This resource allows creating and management of Roles.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), // immutable + }, + }, + "service": schema.StringAttribute{ + Description: "The name of a Service.", + Required: true, + Validators: []validator.String{ + mackerel.ServiceNameValidator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), // force new + }, + }, + "name": schema.StringAttribute{ + Description: "The name of a Role.", + Required: true, + Validators: []validator.String{ + mackerel.RoleNameValidator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "memo": schema.StringAttribute{ + Description: "Notes related to this role.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Default: stringdefault.StaticString(""), + }, + }, + } +} + +func (r *mackerelRoleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := retrieveClient(ctx, req.ProviderData) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + r.Client = client +} + +func (r *mackerelRoleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data mackerel.RoleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := data.Create(ctx, r.Client); err != nil { + resp.Diagnostics.AddError( + "Unable to create Role", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *mackerelRoleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data mackerel.RoleModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := data.Read(ctx, r.Client); err != nil { + resp.Diagnostics.AddError( + "Unable to read Role", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *mackerelRoleResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Unable to update Role", + "Mackerel Service Roles cannot be updated in-place. Please report this issue.", + ) +} + +func (r *mackerelRoleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data mackerel.RoleModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := data.Delete(ctx, r.Client); err != nil { + resp.Diagnostics.AddError( + "Unable to delete Role", + err.Error(), + ) + return + } +} + +func (r *mackerelRoleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/resource_mackerel_role_test.go b/internal/provider/resource_mackerel_role_test.go new file mode 100644 index 0000000..11da3c6 --- /dev/null +++ b/internal/provider/resource_mackerel_role_test.go @@ -0,0 +1,26 @@ +package provider_test + +import ( + "context" + "testing" + + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/provider" +) + +func Test_MackerelRoleResource_schema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + req := fwresource.SchemaRequest{} + resp := fwresource.SchemaResponse{} + provider.NewMackerelRoleResource().Schema(ctx, req, &resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema: %+v", resp.Diagnostics) + } + + if diags := resp.Schema.ValidateImplementation(ctx); diags.HasError() { + t.Fatalf("schema validation: %+v", diags) + } +} diff --git a/mackerel/provider.go b/mackerel/provider.go index cdea6ad..27bcd9c 100644 --- a/mackerel/provider.go +++ b/mackerel/provider.go @@ -83,6 +83,7 @@ func protoV5ProviderServer(provider *schema.Provider) tfprotov5.ProviderServer { // Resources delete(provider.ResourcesMap, "mackerel_notification_group") + delete(provider.ResourcesMap, "mackerel_role") delete(provider.ResourcesMap, "mackerel_service") delete(provider.ResourcesMap, "mackerel_service_metadata")