diff --git a/go.mod b/go.mod index 3e47f36..ad8d376 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.22.0 require ( github.com/golangci/golangci-lint v1.50.1 + github.com/google/go-cmp v0.6.0 github.com/hashicorp/terraform-plugin-framework v1.8.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-mux v0.16.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 @@ -71,7 +73,6 @@ require ( github.com/golangci/misspell v0.3.5 // indirect github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 // indirect github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.4.2 // indirect diff --git a/go.sum b/go.sum index 315974b..d4d17cb 100644 --- a/go.sum +++ b/go.sum @@ -342,6 +342,8 @@ github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7 github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= github.com/hashicorp/terraform-plugin-framework v1.8.0 h1:P07qy8RKLcoBkCrY2RHJer5AEvJnDuXomBgou6fD8kI= github.com/hashicorp/terraform-plugin-framework v1.8.0/go.mod h1:/CpTukO88PcL/62noU7cuyaSJ4Rsim+A/pa+3rUVufY= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= diff --git a/internal/mackerel/config.go b/internal/mackerel/config.go new file mode 100644 index 0000000..2a5454f --- /dev/null +++ b/internal/mackerel/config.go @@ -0,0 +1,66 @@ +package mackerel + +import ( + "errors" + "net/http" + "os" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" + "github.com/mackerelio/mackerel-client-go" +) + +type Client = mackerel.Client + +type ClientConfigModel struct { + APIKey types.String `tfsdk:"api_key"` + APIBase types.String `tfsdk:"api_base"` +} + +var ( + ErrNoAPIKey = errors.New("API Key for Mackerel is not found.") +) + +func NewClientConfigFromEnv() ClientConfigModel { + var data ClientConfigModel + + var apiKey string + for _, env := range []string{"MACKEREL_APIKEY", "MACKEREL_API_KEY"} { + if apiKey == "" { + apiKey = os.Getenv(env) + } + } + if apiKey != "" { + data.APIKey = types.StringValue(apiKey) + } + + apiBase := os.Getenv("API_BASE") + if apiBase != "" { + data.APIBase = types.StringValue(apiBase) + } + + return data +} + +func (m *ClientConfigModel) NewClient() (*Client, error) { + apiKey := m.APIKey.ValueString() + if apiKey == "" { + return nil, ErrNoAPIKey + } + + apiBase := m.APIBase.ValueString() + + var client *mackerel.Client + if apiBase == "" { + client = mackerel.NewClient(apiKey) + } else { + // TODO: use logging transport with tflog (FYI: https://github.com/hashicorp/terraform-plugin-log/issues/91) + c, err := mackerel.NewClientWithOptions(apiKey, apiBase, false) + if err != nil { + return nil, err + } + client = c + } + client.HTTPClient.Transport = logging.NewSubsystemLoggingHTTPTransport("Mackerel", http.DefaultTransport) + return client, nil +} diff --git a/internal/mackerel/config_test.go b/internal/mackerel/config_test.go new file mode 100644 index 0000000..a732f47 --- /dev/null +++ b/internal/mackerel/config_test.go @@ -0,0 +1,57 @@ +package mackerel + +import ( + "errors" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func Test_ClientConfig_apiKeyCompat(t *testing.T) { + cases := map[string]struct { + MACKEREL_APIKEY string + MACKEREL_API_KEY string + want types.String + }{ + "no key": { + want: types.StringNull(), + }, + "MACKEREL_API_KEY": { + MACKEREL_API_KEY: "api_key", + + want: types.StringValue("api_key"), + }, + "MACKEREL_APIKEY": { + MACKEREL_APIKEY: "apikey", + + want: types.StringValue("apikey"), + }, + "both": { + MACKEREL_APIKEY: "apikey", + MACKEREL_API_KEY: "api_key", + + want: types.StringValue("apikey"), + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("MACKEREL_APIKEY", tt.MACKEREL_APIKEY) + t.Setenv("MACKEREL_API_KEY", tt.MACKEREL_API_KEY) + + c := NewClientConfigFromEnv() + if c.APIKey != tt.want { + t.Errorf("expected to be '%s', but got '%s'.", tt.want, c.APIKey) + } + }) + } +} + +func Test_ClientConfig_noApiKey(t *testing.T) { + t.Parallel() + + var config ClientConfigModel + if _, err := config.NewClient(); !errors.Is(err, ErrNoAPIKey) { + t.Errorf("expected to ErrNoAPIKey, but got: %v", err) + } +} diff --git a/internal/mackerel/service.go b/internal/mackerel/service.go new file mode 100644 index 0000000..61bf1c3 --- /dev/null +++ b/internal/mackerel/service.go @@ -0,0 +1,92 @@ +package mackerel + +import ( + "context" + "fmt" + "regexp" + "slices" + + "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" +) + +var serviceNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]+$`) + +func ServiceNameValidator() validator.String { + return stringvalidator.All( + stringvalidator.LengthBetween(2, 63), + stringvalidator.RegexMatches(serviceNameRegex, + "Must include only alphabets, numbers, hyphen and underscore, and it can not begin a hyphen or underscore"), + ) +} + +type ServiceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Memo types.String `tfsdk:"memo"` +} + +func ReadService(ctx context.Context, client *Client, name string) (*ServiceModel, error) { + return readServiceInner(ctx, client, name) +} + +type serviceFinder interface { + FindServices() ([]*mackerel.Service, error) +} + +func readServiceInner(_ context.Context, client serviceFinder, name string) (*ServiceModel, error) { + services, err := client.FindServices() + if err != nil { + return nil, err + } + + serviceIdx := slices.IndexFunc(services, func(s *mackerel.Service) bool { + return s.Name == name + }) + if serviceIdx == -1 { + return nil, fmt.Errorf("the name '%s' does not match any service in mackerel.io", name) + } + + service := services[serviceIdx] + return &ServiceModel{ + ID: types.StringValue(service.Name), + Name: types.StringValue(service.Name), + Memo: types.StringValue(service.Memo), + }, nil +} + +func (m *ServiceModel) Set(newData ServiceModel) { + if !newData.ID.IsUnknown() { + m.ID = newData.ID + } + m.Name = newData.Name + + // If Memo is an empty string, treat it as null + if newData.Memo.ValueString() != "" { + m.Memo = newData.Memo + } +} + +func (m *ServiceModel) Create(_ context.Context, client *Client) error { + param := mackerel.CreateServiceParam{ + Name: m.Name.ValueString(), + Memo: m.Memo.ValueString(), + } + + service, err := client.CreateService(¶m) + if err != nil { + return err + } + + m.ID = types.StringValue(service.Name) + return nil +} + +func (m *ServiceModel) Delete(_ context.Context, client *Client) error { + if _, err := client.DeleteService(m.ID.ValueString()); err != nil { + return err + } + return nil +} diff --git a/internal/mackerel/service_test.go b/internal/mackerel/service_test.go new file mode 100644 index 0000000..4cfa2b2 --- /dev/null +++ b/internal/mackerel/service_test.go @@ -0,0 +1,134 @@ +package mackerel + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mackerelio/mackerel-client-go" +) + +func Test_ServiceNameValidator(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + val types.String + wantError bool + }{ + "valid": { + val: types.StringValue("service1"), + }, + "too short": { + val: types.StringValue("a"), + wantError: true, + }, + "too long": { + val: types.StringValue("toooooooooooooooooooo-looooooooooooooooooooooooooooooooooooooooooong"), + wantError: true, + }, + "invalid char": { + val: types.StringValue("v('ω')v"), + wantError: true, + }, + } + + ctx := context.Background() + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: tt.val, + } + resp := &validator.StringResponse{} + ServiceNameValidator().ValidateString(ctx, req, resp) + + hasError := resp.Diagnostics.HasError() + if hasError != tt.wantError { + if tt.wantError { + t.Error("expected to have errors, but got no error") + } else { + t.Errorf("unexpected error: %+v", resp.Diagnostics.Errors()) + } + } + }) + } +} + +type serviceFinderFunc func() ([]*mackerel.Service, error) + +func (f serviceFinderFunc) FindServices() ([]*mackerel.Service, error) { + return f() +} + +func Test_ReadService(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + inClient serviceFinderFunc + inName string + want *ServiceModel + wantFail bool + }{ + "success": { + inClient: func() ([]*mackerel.Service, error) { + return []*mackerel.Service{ + { + Name: "service0", + }, + { + Name: "service1", + Memo: "memo", + }, + }, nil + }, + inName: "service1", + want: &ServiceModel{ + ID: types.StringValue("service1"), + Name: types.StringValue("service1"), + Memo: types.StringValue("memo"), + }, + }, + "no service": { + inClient: func() ([]*mackerel.Service, error) { + return []*mackerel.Service{ + { + Name: "service0", + }, + { + Name: "service1", + Memo: "memo", + }, + }, nil + }, + inName: "service2", + wantFail: true, + }, + } + + ctx := context.Background() + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + s, err := readServiceInner(ctx, tt.inClient, tt.inName) + if err != nil { + if !tt.wantFail { + t.Errorf("unexpected error: %+v", err) + } + return + } + if tt.wantFail { + t.Errorf("unexpected success") + } + + if diff := cmp.Diff(tt.want, s); diff != "" { + t.Errorf("%s", diff) + } + }) + } +} diff --git a/internal/provider/data_source_mackerel_service.go b/internal/provider/data_source_mackerel_service.go new file mode 100644 index 0000000..5cd513a --- /dev/null +++ b/internal/provider/data_source_mackerel_service.go @@ -0,0 +1,78 @@ +package provider + +import ( + "context" + "fmt" + + "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 = (*mackerelServiceDataSource)(nil) + _ datasource.DataSourceWithConfigure = (*mackerelServiceDataSource)(nil) +) + +func NewMackerelServiceDataSource() datasource.DataSource { + return &mackerelServiceDataSource{} +} + +type mackerelServiceDataSource struct { + Client *mackerel.Client +} + +func (d *mackerelServiceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service" +} + +func (d *mackerelServiceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Use this data source allows access to details of a specific Service.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "The name of the service.", + Validators: []validator.String{mackerel.ServiceNameValidator()}, + }, + "memo": schema.StringAttribute{ + Computed: true, + Description: "Notes related to this service.", + }, + }, + } +} + +func (d *mackerelServiceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + client, diags := retrieveClient(ctx, req.ProviderData) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + d.Client = client +} + +func (d *mackerelServiceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data mackerel.ServiceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + name := data.Name.ValueString() + newData, err := mackerel.ReadService(ctx, d.Client, name) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to read Service: %s", name), + err.Error(), + ) + return + } + + data.Set(*newData) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/data_source_mackerel_service_test.go b/internal/provider/data_source_mackerel_service_test.go new file mode 100644 index 0000000..dd8d07f --- /dev/null +++ b/internal/provider/data_source_mackerel_service_test.go @@ -0,0 +1,26 @@ +package provider_test + +import ( + "context" + "testing" + + fwdatasource "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/provider" +) + +func TestMackerelServiceDataSourceSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + req := fwdatasource.SchemaRequest{} + resp := &fwdatasource.SchemaResponse{} + provider.NewMackerelServiceDataSource().Schema(ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema method diagnostics: %+v", resp.Diagnostics) + } + + if diags := resp.Schema.ValidateImplementation(ctx); diags.HasError() { + t.Fatalf("schema validation diagnostics: %+v", diags) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 92060eb..5fe3556 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,25 +2,20 @@ package provider import ( "context" + "errors" "fmt" - "net/http" - "os" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" - "github.com/mackerelio/mackerel-client-go" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/mackerel" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/validatorutil" ) type mackerelProvider struct{} -type MackerelProviderModel struct { - APIKey types.String `tfsdk:"api_key"` - APIBase types.String `tfsdk:"api_base"` -} var _ provider.Provider = (*mackerelProvider)(nil) @@ -28,11 +23,11 @@ func New() provider.Provider { return &mackerelProvider{} } -func (m *mackerelProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { +func (m *mackerelProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "mackerel" } -func (m *mackerelProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { +func (m *mackerelProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "api_key": schema.StringAttribute{ @@ -44,61 +39,73 @@ func (m *mackerelProvider) Schema(ctx context.Context, req provider.SchemaReques Description: "Mackerel API BASE URL", Optional: true, Sensitive: true, - Validators: []validator.String{IsURLWithHTTPorHTTPS()}, + Validators: []validator.String{validatorutil.IsURLWithHTTPorHTTPS()}, }, }, } } func (m *mackerelProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { - var data MackerelProviderModel - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + var schemaConfig mackerel.ClientConfigModel + resp.Diagnostics.Append(req.Config.Get(ctx, &schemaConfig)...) if resp.Diagnostics.HasError() { return } - apiKey := data.APIKey.ValueString() - if data.APIKey.IsUnknown() || data.APIKey.IsNull() { - apiKey = os.Getenv("MACKEREL_APIKEY") - if apiKey == "" { - apiKey = os.Getenv("MACKEREL_API_KEY") - } + config := mackerel.NewClientConfigFromEnv() + // merge config + if config.APIKey.IsUnknown() { + config.APIKey = schemaConfig.APIKey } - if apiKey == "" { - resp.Diagnostics.AddError( - "no API Key", "no API Key for Mackerel is found", - ) - } - - apiBase := data.APIBase.ValueString() - if data.APIBase.IsUnknown() || data.APIBase.IsNull() { - apiBase = os.Getenv("API_BASE") + if config.APIBase.IsUnknown() { + config.APIBase = schemaConfig.APIBase } - var client *mackerel.Client - if apiBase == "" { - client = mackerel.NewClient(apiKey) - } else { - var err error - client, err = mackerel.NewClientWithOptions(apiKey, apiBase, false) - if err != nil { + client, err := config.NewClient() + if err != nil { + if errors.Is(err, mackerel.ErrNoAPIKey) { resp.Diagnostics.AddError( - "failed to create mackerel client", - fmt.Sprintf("failed to create mackerel client: %v", err), + "No API Key", + err.Error(), + ) + } else { + resp.Diagnostics.AddError( + "Unable to create Mackerel Client", + err.Error(), ) } + return } - // TODO: use logging transport with tflog (FYI: https://github.com/hashicorp/terraform-plugin-log/issues/91) - client.HTTPClient.Transport = logging.NewSubsystemLoggingHTTPTransport("Mackerel", http.DefaultTransport) - resp.ResourceData = client + resp.DataSourceData = client } func (m *mackerelProvider) Resources(context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{ + NewMackerelServiceResource, + } } func (m *mackerelProvider) DataSources(context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{} + return []func() datasource.DataSource{ + NewMackerelServiceDataSource, + } +} + +func retrieveClient(_ context.Context, providerData any) (client *mackerel.Client, diags diag.Diagnostics) { + if /* ConfigureProvider RPC is not called */ providerData == nil { + return + } + + client, ok := providerData.(*mackerel.Client) + if !ok { + diags.AddError( + + "No Mackerel Client is configured.", + fmt.Sprintf("Expected configured Mackerel client, but got: %T. Please report this issue.", providerData), + ) + return + } + return } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 3f19a9e..07ff95b 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -5,7 +5,6 @@ import ( "testing" fwprovider "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/mackerelio-labs/terraform-provider-mackerel/internal/provider" ) @@ -25,61 +24,3 @@ func TestMackerelProvider_schema(t *testing.T) { t.Fatalf("Schema validation: %+v", diags) } } - -func TestMackerelProvider_apiKey(t *testing.T) { - testCases := map[string]struct { - MACKEREL_API_KEY string - MACKEREL_APIKEY string - }{ - "MACKEREL_API_KEY": { - MACKEREL_API_KEY: "apikey1", - }, - "MACKEREL_APIKEY": { - MACKEREL_APIKEY: "apikey1", - }, - } - - ctx := context.Background() - for name, tt := range testCases { - t.Run(name, func(t *testing.T) { - t.Setenv("MACKEREL_API_KEY", tt.MACKEREL_API_KEY) - t.Setenv("MACKEREL_APIKEY", tt.MACKEREL_APIKEY) - - p := provider.New() - - creq := newProviderConfigureRequest(ctx, nil) - cresp := &fwprovider.ConfigureResponse{} - p.Configure(ctx, creq, cresp) - if cresp.Diagnostics.HasError() { - t.Errorf("Configure: %+v", cresp.Diagnostics) - return - } - }) - } -} - -func newProviderConfigureRequest( - ctx context.Context, - c *provider.MackerelProviderModel, -) fwprovider.ConfigureRequest { - if c == nil { - c = &provider.MackerelProviderModel{} - } - p := provider.New() - - sreq := fwprovider.SchemaRequest{} - sresp := &fwprovider.SchemaResponse{} - p.Schema(ctx, sreq, sresp) - schema := sresp.Schema - - state := tfsdk.State{ - Schema: schema, - } - state.Set(ctx, c) - return fwprovider.ConfigureRequest{ - Config: tfsdk.Config{ - Schema: schema, - Raw: state.Raw, - }, - } -} diff --git a/internal/provider/resource_mackerel_service.go b/internal/provider/resource_mackerel_service.go new file mode 100644 index 0000000..8d3936a --- /dev/null +++ b/internal/provider/resource_mackerel_service.go @@ -0,0 +1,155 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/mackerel" +) + +var ( + _ resource.Resource = (*mackerelServiceResource)(nil) + _ resource.ResourceWithConfigure = (*mackerelServiceResource)(nil) + _ resource.ResourceWithImportState = (*mackerelServiceResource)(nil) +) + +func NewMackerelServiceResource() resource.Resource { + return &mackerelServiceResource{} +} + +type mackerelServiceResource struct { + Client *mackerel.Client +} + +func (r *mackerelServiceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service" +} + +func (r *mackerelServiceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "The `mackerel_service` resource allows creating and management of Service.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "The name of service.", + Validators: []validator.String{ + mackerel.ServiceNameValidator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "memo": schema.StringAttribute{ + Optional: true, + Description: "Notes related to this service.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *mackerelServiceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + client, diags := retrieveClient(ctx, req.ProviderData) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + r.Client = client +} + +func (r *mackerelServiceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data mackerel.ServiceModel + 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 Service", + err.Error(), + ) + return + } + + data.ID = data.Name + + resp.Diagnostics.Append(r.read(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *mackerelServiceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data mackerel.ServiceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.read(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *mackerelServiceResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError( + "Unable to update Service", + "Mackerel services cannot be updated in-place. Please report this issue.", + ) +} + +func (r *mackerelServiceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data mackerel.ServiceModel + 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( + fmt.Sprintf("Unable to delete Service: %s", data.ID.ValueString()), + err.Error(), + ) + return + } +} + +func (r *mackerelServiceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *mackerelServiceResource) read(ctx context.Context, data *mackerel.ServiceModel) (diags diag.Diagnostics) { + + id := data.ID.ValueString() + newData, err := mackerel.ReadService(ctx, r.Client, id) + if err != nil { + diags.AddError( + fmt.Sprintf("Unable to read Service: %s", id), + err.Error(), + ) + } + + data.Set(*newData) + return +} diff --git a/internal/provider/resource_mackerel_service_test.go b/internal/provider/resource_mackerel_service_test.go new file mode 100644 index 0000000..c5fc22c --- /dev/null +++ b/internal/provider/resource_mackerel_service_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 TestMackerelServiceResourceSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + req := fwresource.SchemaRequest{} + resp := &fwresource.SchemaResponse{} + provider.NewMackerelServiceResource().Schema(ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema method diagnostics: %+v", resp.Diagnostics) + } + + if diag := resp.Schema.ValidateImplementation(ctx); diag.HasError() { + t.Fatalf("schema validation diagnostics: %+v", diag) + } +} diff --git a/internal/provider/validator.go b/internal/validatorutil/url.go similarity index 88% rename from internal/provider/validator.go rename to internal/validatorutil/url.go index 2044037..6db46f5 100644 --- a/internal/provider/validator.go +++ b/internal/validatorutil/url.go @@ -1,4 +1,4 @@ -package provider +package validatorutil import ( "context" @@ -60,12 +60,11 @@ func (uv *urlSchemeValidator) ValidateString(ctx context.Context, req validator. ) } - isSchemeValid := slices.Index(uv.validSchemes, u.Scheme) > 0 - if !isSchemeValid { + if !slices.Contains(uv.validSchemes, u.Scheme) { resp.Diagnostics.AddAttributeError( req.Path, "Invalid Scheme", - fmt.Sprintf("expected to have a url with scheme of: %q", strings.Join(uv.validSchemes, ",")), + fmt.Sprintf("expected to have a url with scheme of: %q, but got: %s", strings.Join(uv.validSchemes, ","), u.Scheme), ) } } diff --git a/internal/validatorutil/url_test.go b/internal/validatorutil/url_test.go new file mode 100644 index 0000000..77ff1ba --- /dev/null +++ b/internal/validatorutil/url_test.go @@ -0,0 +1,89 @@ +package validatorutil_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mackerelio-labs/terraform-provider-mackerel/internal/validatorutil" +) + +func Test_Validator_URL(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + val types.String + wantError bool + }{ + "https": { + val: types.StringValue("https://example.com/path/to/resource?q="), + }, + "http": { + val: types.StringValue("http://example.com/path/to/resource?q="), + }, + "empty": { + val: types.StringValue(""), + wantError: true, + }, + "not url": { + val: types.StringValue(":nonurlstring"), + wantError: true, + }, + "no host": { + val: types.StringValue("http:///path/to/resource"), + wantError: true, + }, + "invalid scheme": { + val: types.StringValue("ftp://example.com/path/to/resource"), + wantError: true, + }, + } + + ctx := context.Background() + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := validator.StringRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: tt.val, + } + resp := &validator.StringResponse{} + validatorutil.IsURLWithHTTPorHTTPS().ValidateString(ctx, req, resp) + + for _, d := range resp.Diagnostics { + assertDiagMatchPathExpr(t, d, path.MatchRoot("test")) + } + + hasError := resp.Diagnostics.HasError() + if hasError != tt.wantError { + if tt.wantError { + t.Error("expected to have errors, but got no error") + } else { + t.Errorf("unexpected error: %+v", resp.Diagnostics.Errors()) + } + } + }) + } +} + +func assertDiagMatchPathExpr(t testing.TB, d diag.Diagnostic, pathExpr path.Expression) bool { + t.Helper() + + dp, ok := d.(diag.DiagnosticWithPath) + if !ok { + t.Errorf("expected to have a path, but got no path: %+v", d) + return true + } + + if !pathExpr.Matches(dp.Path()) { + t.Errorf("expected to have a path that matches to %s, but got: %+v", pathExpr.String(), dp.Path()) + return true + } + + return false +} diff --git a/mackerel/data_source_mackerel_service_test.go b/mackerel/data_source_mackerel_service_test.go index 4d82cdf..2d1b308 100644 --- a/mackerel/data_source_mackerel_service_test.go +++ b/mackerel/data_source_mackerel_service_test.go @@ -49,7 +49,8 @@ func TestAccDataSourceMackerelServiceNotMatchAnyService(t *testing.T) { ProtoV5ProviderFactories: testAccProtoV5ProviderFactories, Steps: []resource.TestStep{ { - Config: fmt.Sprintf(`data "mackerel_service" "foo" { name = "%s" }`, name), + Config: fmt.Sprintf(`data "mackerel_service" "foo" { name = "%s" }`, name), + // FIXME: error message should not be tested ExpectError: regexp.MustCompile(fmt.Sprintf(`the name '%s' does not match any service in mackerel\.io`, name)), }, }, diff --git a/mackerel/provider.go b/mackerel/provider.go index 921f131..1188eb6 100644 --- a/mackerel/provider.go +++ b/mackerel/provider.go @@ -80,6 +80,13 @@ func protoV5ProviderServer(provider *schema.Provider) tfprotov5.ProviderServer { fwFlag := os.Getenv("MACKEREL_EXPERIMENTAL_TFFRAMEWORK") if fwFlag == "1" || fwFlag == "true" { log.Printf("[INFO] mackerel: use terraform-plugin-framework based implementation") + + // Resources + delete(provider.ResourcesMap, "mackerel_service") + + // Data Sources + delete(provider.DataSourcesMap, "mackerel_service") + mux, err := tf5muxserver.NewMuxServer( context.Background(), providerserver.NewProtocol5(mackerelfwprovider.New()),