diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6e09e4f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf + +go.sum linguist-generated=true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 262dc64..bfc1569 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,8 @@ jobs: name: Acceptance Tests needs: build runs-on: ubuntu-latest + env: + TF_ACC: "1" services: backstage: image: roadiehq/community-backstage-image @@ -82,13 +84,11 @@ jobs: terraform_wrapper: false - run: go mod download - env: - TF_ACC: "1" ACCTEST_SKIP_RESOURCE_TEST: "1" BACKSTAGE_BASE_URL: "https://demo.backstage.io" run: go test -v -cover ./backstage timeout-minutes: 10 - env: - TF_ACC: "1" BACKSTAGE_BASE_URL: "http://localhost:${{ job.services.backstage.ports[7000] }}" - run: go test -v -cover ./backstage/resource_location_test.go + run: go test -v -cover ./backstage -run TestAccResourceLocation timeout-minutes: 10 diff --git a/backstage/provider.go b/backstage/provider.go index b352e0f..3d5367d 100644 --- a/backstage/provider.go +++ b/backstage/provider.go @@ -139,7 +139,9 @@ func (p *backstageProvider) Configure(ctx context.Context, req provider.Configur } func (p *backstageProvider) Resources(context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{ + NewLocationResource, + } } func (p *backstageProvider) DataSources(context.Context) []func() datasource.DataSource { diff --git a/backstage/resource_location.go b/backstage/resource_location.go index d54d57a..567f2c1 100644 --- a/backstage/resource_location.go +++ b/backstage/resource_location.go @@ -1 +1,200 @@ package backstage + +import ( + "context" + "fmt" + "net/http" + "time" + + "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/hashicorp/terraform-plugin-framework/types" + "github.com/tdabasinskas/go-backstage/backstage" +) + +var ( + _ resource.Resource = &locationResource{} + _ resource.ResourceWithConfigure = &locationResource{} + _ resource.ResourceWithImportState = &locationResource{} +) + +// NewLocationResource is a helper function to simplify the provider implementation. +func NewLocationResource() resource.Resource { + return &locationResource{} +} + +// locationResource is the resource implementation. +type locationResource struct { + client *backstage.Client +} + +// locationResourceModel maps the resource schema data. +type locationResourceModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` + Target types.String `tfsdk:"target"` + LastUpdated types.String `tfsdk:"last_updated"` +} + +const ( + descriptionLocationID = "Identifier of the location." + descriptionLocationType = "Type of the location. Always `url`." + descriptionLocationTarget = "Target as a string. Should be a valid URL." + descriptionLocationLastUpdated = "Timestamp of the last Terraform update of the location." +) + +// Metadata returns the data source type name. +func (r *locationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_location" +} + +// Schema defines the schema for the resource. +func (r *locationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Use this resource to manage Backstage locations. \n\n" + + "In order for this resource to work, Backstage instance must NOT be running in " + + "[read-only mode](https://backstage.io/docs/features/software-catalog/configuration#readonly-mode).", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, Description: descriptionLocationID, PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }}, + "type": schema.StringAttribute{Optional: true, Computed: true, MarkdownDescription: descriptionLocationType, Validators: []validator.String{}, PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }}, + "target": schema.StringAttribute{Required: true, Description: descriptionLocationTarget, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}}, + "last_updated": schema.StringAttribute{Computed: true, Description: descriptionLocationLastUpdated}, + }, + } +} + +// Configure adds the provider configured client to the data source. +func (r *locationResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*backstage.Client) +} + +// Create registers a new location in Backstage and sets the initial Terraform state. +func (r *locationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan locationResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + location, response, err := r.client.Catalog.Locations.Create(ctx, plan.Target.ValueString(), false) + if err != nil { + resp.Diagnostics.AddError("Error creating location", + fmt.Sprintf("Could not create location, unexpected error: %s", err.Error()), + ) + return + } + + if response.StatusCode != http.StatusCreated { + resp.Diagnostics.AddError("Error creating location", + fmt.Sprintf("Could not create location, unexpected status code: %d", response.StatusCode), + ) + return + } + + plan.ID = types.StringValue(location.Location.ID) + plan.Type = types.StringValue(location.Location.Type) + plan.Target = types.StringValue(location.Location.Target) + plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read reads the existing location and refreshes the Terraform state with the latest data. +func (r *locationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state locationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + location, response, err := r.client.Catalog.Locations.GetByID(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error reading Backstage location", + fmt.Sprintf("Could not read Backstage location ID %s: %s", state.ID.ValueString(), err.Error()), + ) + return + } + + if response.StatusCode != http.StatusOK { + resp.Diagnostics.AddError("Error reading Backstage location", + fmt.Sprintf("Could not read Backstage location ID %s, unexpected status code: %d", state.ID.ValueString(), response.StatusCode), + ) + return + } + + state.Target = types.StringValue(location.Target) + state.Type = types.StringValue(location.Type) + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *locationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve values from plan + var plan locationResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state locationResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete deletes the location and removes the Terraform state on success. +func (r *locationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state locationResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + response, err := r.client.Catalog.Locations.DeleteByID(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error deleting Backstage location", + fmt.Sprintf("Could not delete location, unexpected error: %s", err.Error()), + ) + return + } + + if response.StatusCode != http.StatusNoContent { + resp.Diagnostics.AddError("Error deleting Backstage location", + fmt.Sprintf("Could not delete location, unexpected status code: %d", response.StatusCode), + ) + return + } +} + +// ImportState imports the resource into Terraform state. +func (r *locationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/backstage/resource_location_test.go b/backstage/resource_location_test.go index 87f8b5d..b2a8287 100644 --- a/backstage/resource_location_test.go +++ b/backstage/resource_location_test.go @@ -1,12 +1,59 @@ +//go:build !resources + package backstage import ( "os" "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) func TestAccResourceLocation(t *testing.T) { if os.Getenv("ACCTEST_SKIP_RESOURCE_TEST") != "" { t.Skip("Skipping as ACCTEST_SKIP_RESOURCE_LOCATION is set") } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create testing + { + Config: testAccProviderConfig + testAccResourceLocationConfig1, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("backstage_location.test", "type", "url"), + resource.TestCheckResourceAttr("backstage_location.test", "target", "http://test1"), + resource.TestCheckResourceAttrSet("backstage_location.test", "id"), + resource.TestCheckResourceAttrSet("backstage_location.test", "last_updated"), + ), + }, + // ImportState testing + { + ResourceName: "backstage_location.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"last_updated"}, + }, + // Update and Read testing + { + Config: testAccProviderConfig + testAccResourceLocationConfig2, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("backstage_location.test", "type", "url"), + resource.TestCheckResourceAttr("backstage_location.test", "target", "http://test2"), + ), + }, + }, + }) + +} + +const testAccResourceLocationConfig1 = ` +resource "backstage_location" "test" { + target = "http://test1" +} +` +const testAccResourceLocationConfig2 = ` +resource "backstage_location" "test" { + target = "http://test2" } +` diff --git a/docs/resources/location.md b/docs/resources/location.md new file mode 100644 index 0000000..48ff8bb --- /dev/null +++ b/docs/resources/location.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "backstage_location Resource - terraform-provider-backstage" +subcategory: "" +description: |- + Use this resource to manage Backstage locations. + In order for this resource to work, Backstage instance must NOT be running in read-only mode https://backstage.io/docs/features/software-catalog/configuration#readonly-mode. +--- + +# backstage_location (Resource) + +Use this resource to manage Backstage locations. + +In order for this resource to work, Backstage instance must NOT be running in [read-only mode](https://backstage.io/docs/features/software-catalog/configuration#readonly-mode). + +## Example Usage + +```terraform +# Ensures the location exists in Backstage. +resource "backstage_location" "example" { + # URL to the location target: + target = "http://example-target" +} +``` + + +## Schema + +### Required + +- `target` (String) Target as a string. Should be a valid URL. + +### Optional + +- `type` (String) Type of the location. Always `url`. + +### Read-Only + +- `id` (String) Identifier of the location. +- `last_updated` (String) Timestamp of the last Terraform update of the location. + + diff --git a/examples/resources/backstage_location/resource.tf b/examples/resources/backstage_location/resource.tf new file mode 100644 index 0000000..46742e0 --- /dev/null +++ b/examples/resources/backstage_location/resource.tf @@ -0,0 +1,5 @@ +# Ensures the location exists in Backstage. +resource "backstage_location" "example" { + # URL to the location target: + target = "http://example-target" +} diff --git a/go.mod b/go.mod index d834db8..2ca39b3 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/stretchr/testify v1.8.1 // indirect - github.com/tdabasinskas/go-backstage v1.0.2 // indirect + github.com/tdabasinskas/go-backstage v1.1.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect github.com/vmihailenco/tagparser v0.1.2 // indirect diff --git a/go.sum b/go.sum index a2df676..7c46e58 100644 --- a/go.sum +++ b/go.sum @@ -229,6 +229,8 @@ github.com/tdabasinskas/go-backstage v1.0.1 h1:XTtGEiYis4FCZexdOSxq+6lQhUz0jEWdp github.com/tdabasinskas/go-backstage v1.0.1/go.mod h1:aw7tu1VydRX26637tLcUO32vjnYVayspe34kja49200= github.com/tdabasinskas/go-backstage v1.0.2 h1:f6WOT5GIpU9mtoLJileUcb9EdKOIy4FFCXUTVUBKFJE= github.com/tdabasinskas/go-backstage v1.0.2/go.mod h1:aw7tu1VydRX26637tLcUO32vjnYVayspe34kja49200= +github.com/tdabasinskas/go-backstage v1.1.0 h1:OF0t5LdSgtMvMrqVkX09uOjRYj/WmqyMRK8r/6rFaU0= +github.com/tdabasinskas/go-backstage v1.1.0/go.mod h1:aw7tu1VydRX26637tLcUO32vjnYVayspe34kja49200= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=