diff --git a/internal/framework/config.go b/internal/framework/config.go new file mode 100644 index 0000000000..f88f3cf18f --- /dev/null +++ b/internal/framework/config.go @@ -0,0 +1,41 @@ +package framework + +import ( + "context" + "errors" + "fmt" + + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type Config struct { + Email string + APIKey string + APIUserServiceKey string + APIToken string + Options []cloudflare.Option +} + +func (c *Config) Client() (*cloudflare.API, error) { + var err error + var client *cloudflare.API + ctx := context.Background() + + if c.APIUserServiceKey != "" { + client, err = cloudflare.NewWithUserServiceKey(c.APIUserServiceKey, c.Options...) + } else if c.APIToken != "" { + client, err = cloudflare.NewWithAPIToken(c.APIToken, c.Options...) + } else if c.APIKey != "" { + client, err = cloudflare.New(c.APIKey, c.Email, c.Options...) + } else { + return nil, errors.New("no credentials detected") + } + + if err != nil { + return nil, fmt.Errorf("error creating new Cloudflare client: %w", err) + } + + tflog.Info(ctx, fmt.Sprintf("cloudflare Client configured for user: %s", c.Email)) + return client, nil +} diff --git a/internal/framework/data_source_example.go b/internal/framework/data_source_example.go new file mode 100644 index 0000000000..a0836bfe6f --- /dev/null +++ b/internal/framework/data_source_example.go @@ -0,0 +1,102 @@ +package framework + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ datasource.DataSource = &ExampleDataSource{} + +func NewExampleDataSource() datasource.DataSource { + return &ExampleDataSource{} +} + +// ExampleDataSource defines the data source implementation. +type ExampleDataSource struct { + client *http.Client +} + +// ExampleDataSourceModel describes the data source data model. +type ExampleDataSourceModel struct { + ConfigurableAttribute types.String `tfsdk:"configurable_attribute"` + Id types.String `tfsdk:"id"` +} + +func (d *ExampleDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_example" +} + +func (d *ExampleDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "Example data source", + + Attributes: map[string]schema.Attribute{ + "configurable_attribute": schema.StringAttribute{ + MarkdownDescription: "Example configurable attribute", + Optional: true, + }, + "id": schema.StringAttribute{ + MarkdownDescription: "Example identifier", + Computed: true, + }, + }, + } +} + +func (d *ExampleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*http.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *ExampleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data ExampleDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := d.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read example, got error: %s", err)) + // return + // } + + // For the purposes of this example code, hardcoding a response value to + // save into the Terraform state. + data.Id = types.StringValue("example-id") + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "read a data source") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/framework/data_source_example_test.go b/internal/framework/data_source_example_test.go new file mode 100644 index 0000000000..b2891d57a6 --- /dev/null +++ b/internal/framework/data_source_example_test.go @@ -0,0 +1,29 @@ +package framework + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccExampleDataSource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Read testing + { + Config: testAccExampleDataSourceConfig, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.scaffolding_example.test", "id", "example-id"), + ), + }, + }, + }) +} + +const testAccExampleDataSourceConfig = ` +data "scaffolding_example" "test" { + configurable_attribute = "example" +} +` diff --git a/internal/framework/provider.go b/internal/framework/provider.go new file mode 100644 index 0000000000..372e3e8168 --- /dev/null +++ b/internal/framework/provider.go @@ -0,0 +1,158 @@ +package framework + +import ( + "context" + + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "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/types" +) + +// Ensure CloudflareProvider satisfies various provider interfaces. +var _ provider.Provider = &CloudflareProvider{} + +// CloudflareProvider defines the provider implementation. +type CloudflareProvider struct { + // version is set to the provider version on release, "dev" when the + // provider is built and ran locally, and "test" when running acceptance + // testing. + version string +} + +// CloudflareProviderModel describes the provider data model. +type CloudflareProviderModel struct { + APIKey types.String `tfsdk:"api_key"` + APIUserServiceKey types.String `tfsdk:"api_user_service_key"` + Email types.String `tfsdk:"email"` + MinBackOff types.Int64 `tfsdk:"min_backoff"` + RPS types.Int64 `tfsdk:"rps"` + AccountID types.String `tfsdk:"account_id"` + APIBasePath types.String `tfsdk:"api_base_path"` + APIToken types.String `tfsdk:"api_token"` + Retries types.Int64 `tfsdk:"retries"` + MaxBackoff types.Int64 `tfsdk:"max_backoff"` + APIClientLogging types.Bool `tfsdk:"api_client_logging"` + APIHostname types.String `tfsdk:"api_hostname"` +} + +func (p *CloudflareProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "cloudflare" + resp.Version = p.version +} + +func (p *CloudflareProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A registered Cloudflare email address. Alternatively, can be configured using the `CLOUDFLARE_EMAIL` environment variable.", + }, + + "api_key": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The API key for operations. Alternatively, can be configured using the `CLOUDFLARE_API_KEY` environment variable. API keys are [now considered legacy by Cloudflare](https://developers.cloudflare.com/api/keys/#limitations), API tokens should be used instead.", + }, + + "api_token": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "The API Token for operations. Alternatively, can be configured using the `CLOUDFLARE_API_TOKEN` environment variable.", + }, + + "api_user_service_key": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A special Cloudflare API key good for a restricted set of endpoints. Alternatively, can be configured using the `CLOUDFLARE_API_USER_SERVICE_KEY` environment variable.", + }, + + "rps": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "RPS limit to apply when making calls to the API. Alternatively, can be configured using the `CLOUDFLARE_RPS` environment variable.", + }, + + "retries": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "Maximum number of retries to perform when an API request fails. Alternatively, can be configured using the `CLOUDFLARE_RETRIES` environment variable.", + }, + + "min_backoff": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "Minimum backoff period in seconds after failed API calls. Alternatively, can be configured using the `CLOUDFLARE_MIN_BACKOFF` environment variable.", + }, + + "max_backoff": schema.Int64Attribute{ + Optional: true, + MarkdownDescription: "Maximum backoff period in seconds after failed API calls. Alternatively, can be configured using the `CLOUDFLARE_MAX_BACKOFF` environment variable.", + }, + + "api_client_logging": schema.BoolAttribute{ + Optional: true, + MarkdownDescription: "Whether to print logs from the API client (using the default log library logger). Alternatively, can be configured using the `CLOUDFLARE_API_CLIENT_LOGGING` environment variable.", + }, + + "account_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Configure API client to always use a specific account. Alternatively, can be configured using the `CLOUDFLARE_ACCOUNT_ID` environment variable.", + DeprecationMessage: "Use resource specific `account_id` attributes instead.", + }, + + "api_hostname": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Configure the hostname used by the API client. Alternatively, can be configured using the `CLOUDFLARE_API_HOSTNAME` environment variable.", + }, + + "api_base_path": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Configure the base path used by the API client. Alternatively, can be configured using the `CLOUDFLARE_API_BASE_PATH` environment variable.", + }, + }, + } +} + +func (p *CloudflareProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var data CloudflareProviderModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Configuration values are now available. + // if data.Endpoint.IsNull() { /* ... */ } + + // Example client configuration for data sources and resources + options := []cloudflare.Option{} + + options = append(options, cloudflare.BaseURL("https://api.cloudflare.com/client/v4")) + config := Config{Options: options} + + client, _ := config.Client() + // if err != nil { + // resp.Diagnostics.Append(err...) + // return + // } + resp.DataSourceData = client + resp.ResourceData = client +} + +func (p *CloudflareProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewExampleResource, + } +} + +func (p *CloudflareProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewExampleDataSource, + } +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &CloudflareProvider{ + version: version, + } + } +} diff --git a/internal/framework/provider_test.go b/internal/framework/provider_test.go new file mode 100644 index 0000000000..84d50bc320 --- /dev/null +++ b/internal/framework/provider_test.go @@ -0,0 +1,22 @@ +package framework + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// testAccProtoV6ProviderFactories are used to instantiate a provider during +// acceptance testing. The factory function will be invoked for every Terraform +// CLI command executed to create a provider server to which the CLI can +// reattach. +var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "scaffolding": providerserver.NewProtocol6WithError(New("test")()), +} + +func testAccPreCheck(t *testing.T) { + // You can add code here to run prior to any test case execution, for example assertions + // about the appropriate environment variables being set are common to see in a pre-check + // function. +} diff --git a/internal/framework/resource_example.go b/internal/framework/resource_example.go new file mode 100644 index 0000000000..0ad1cbc968 --- /dev/null +++ b/internal/framework/resource_example.go @@ -0,0 +1,176 @@ +package framework + +import ( + "context" + "fmt" + "net/http" + + "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/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &ExampleResource{} +var _ resource.ResourceWithImportState = &ExampleResource{} + +func NewExampleResource() resource.Resource { + return &ExampleResource{} +} + +// ExampleResource defines the resource implementation. +type ExampleResource struct { + client *http.Client +} + +// ExampleResourceModel describes the resource data model. +type ExampleResourceModel struct { + ConfigurableAttribute types.String `tfsdk:"configurable_attribute"` + Id types.String `tfsdk:"id"` +} + +func (r *ExampleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_example" +} + +func (r *ExampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + MarkdownDescription: "Example resource", + + Attributes: map[string]schema.Attribute{ + "configurable_attribute": schema.StringAttribute{ + MarkdownDescription: "Example configurable attribute", + Optional: true, + }, + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Example identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *ExampleResource) Configure(ctx 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.(*http.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *ExampleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *ExampleResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := r.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create example, got error: %s", err)) + // return + // } + + // For the purposes of this example code, hardcoding a response value to + // save into the Terraform state. + data.Id = types.StringValue("example-id") + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "created a resource") + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ExampleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *ExampleResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := r.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read example, got error: %s", err)) + // return + // } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ExampleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data *ExampleResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := r.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update example, got error: %s", err)) + // return + // } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ExampleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *ExampleResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // If applicable, this is a great opportunity to initialize any necessary + // provider client data and make a call using it. + // httpResp, err := r.client.Do(httpReq) + // if err != nil { + // resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete example, got error: %s", err)) + // return + // } +} + +func (r *ExampleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/framework/resource_example_test.go b/internal/framework/resource_example_test.go new file mode 100644 index 0000000000..4d6bc988c2 --- /dev/null +++ b/internal/framework/resource_example_test.go @@ -0,0 +1,52 @@ +package framework + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccExampleResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccExampleResourceConfig("one"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("scaffolding_example.test", "configurable_attribute", "one"), + resource.TestCheckResourceAttr("scaffolding_example.test", "id", "example-id"), + ), + }, + // ImportState testing + { + ResourceName: "scaffolding_example.test", + ImportState: true, + ImportStateVerify: true, + // This is not normally necessary, but is here because this + // example code does not have an actual upstream service. + // Once the Read method is able to refresh information from + // the upstream service, this can be removed. + ImportStateVerifyIgnore: []string{"configurable_attribute"}, + }, + // Update and Read testing + { + Config: testAccExampleResourceConfig("two"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("scaffolding_example.test", "configurable_attribute", "two"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccExampleResourceConfig(configurableAttribute string) string { + return fmt.Sprintf(` +resource "scaffolding_example" "test" { + configurable_attribute = %[1]q +} +`, configurableAttribute) +}