Skip to content

Commit

Permalink
Add location resource (#2)
Browse files Browse the repository at this point in the history
Add new `location` resource, which allows managing [Backstage
locations](https://backstage.io/docs/features/software-catalog/configuration#static-location-configuration).
  • Loading branch information
tdabasinskas committed Jan 27, 2023
2 parents af3882c + 724f13b commit fce25d2
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 5 deletions.
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* text=auto eol=lf

go.sum linguist-generated=true
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion backstage/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
199 changes: 199 additions & 0 deletions backstage/resource_location.go
Original file line number Diff line number Diff line change
@@ -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)
}
47 changes: 47 additions & 0 deletions backstage/resource_location_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
`
42 changes: 42 additions & 0 deletions docs/resources/location.md
Original file line number Diff line number Diff line change
@@ -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 generated by tfplugindocs -->
## 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.


5 changes: 5 additions & 0 deletions examples/resources/backstage_location/resource.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ensures the location exists in Backstage.
resource "backstage_location" "example" {
# URL to the location target:
target = "http://example-target"
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit fce25d2

Please sign in to comment.