Skip to content

Commit

Permalink
feat: datasource fallbacks (#167)
Browse files Browse the repository at this point in the history
This PR introduces the option to specify fallbacks for all current
datasources.

A fallback is any Backstage resource in its entirety. For example, the
fallback for the `System` will have the same input entity as the
datasource.

The fallback for a particular datasource is supplied as an input
variable for the datasource.

The fallback for a provided entity is only used when an error happens
e.g. the backstage instance is down or the client was misconfigured or
there's a proxy blocking connections suddenly.

Wrt Issue #160
  • Loading branch information
tdabasinskas authored Nov 22, 2024
2 parents a7ca881 + d9fd374 commit 1b194da
Show file tree
Hide file tree
Showing 27 changed files with 2,399 additions and 557 deletions.
209 changes: 152 additions & 57 deletions backstage/data_source_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type apiDataSourceModel struct {
Metadata *entityMetadataModel `tfsdk:"metadata"`
Relations []entityRelationModel `tfsdk:"relations"`
Spec *apiSpecModel `tfsdk:"spec"`
Fallback *apiFallbackModel `tfsdk:"fallback"`
}

type apiSpecModel struct {
Expand All @@ -49,6 +50,17 @@ type apiSpecModel struct {
System types.String `tfsdk:"system"`
}

type apiFallbackModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Namespace types.String `tfsdk:"namespace"`
ApiVersion types.String `tfsdk:"api_version"`
Kind types.String `tfsdk:"kind"`
Metadata *entityMetadataModel `tfsdk:"metadata"`
Relations []entityRelationModel `tfsdk:"relations"`
Spec *apiSpecModel `tfsdk:"spec"`
}

// Metadata returns the data source type name.
func (d *apiDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_api"
Expand All @@ -60,6 +72,7 @@ const (
descriptionApiSpecOwner = "An entity reference to the owner of the API"
descriptionApiSpecDefinition = "Definition of the API, based on the format defined by the type."
descriptionApiSpecSystem = "An entity reference to the system that the API belongs to."
descriptionApiFallback = "A complete replica of the `API` as it would exist in backstage. Set this to provide a fallback in case the Backstage instance is not functioning, is down, or is unrealiable."
)

// Schema defines the schema for the data source.
Expand Down Expand Up @@ -123,6 +136,63 @@ func (d *apiDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, re
"definition": schema.StringAttribute{Computed: true, Description: descriptionApiSpecDefinition},
"system": schema.StringAttribute{Computed: true, Description: descriptionApiSpecSystem},
}},
"fallback": schema.SingleNestedAttribute{Optional: true, Description: descriptionApiFallback, Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataUID},
"name": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataName, Validators: []validator.String{
stringvalidator.LengthBetween(1, 63),
stringvalidator.RegexMatches(
regexp.MustCompile(patternEntityName),
"must follow Backstage format restrictions",
),
}},
"namespace": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataNamespace, Validators: []validator.String{
stringvalidator.LengthBetween(1, 63),
stringvalidator.RegexMatches(
regexp.MustCompile(patternEntityName),
"must follow Backstage format restrictions",
),
}},
"api_version": schema.StringAttribute{Optional: true, Description: descriptionEntityApiVersion},
"kind": schema.StringAttribute{Optional: true, Description: descriptionEntityKind},
"metadata": schema.SingleNestedAttribute{Optional: true, Description: descriptionEntityMetadata, Attributes: map[string]schema.Attribute{
"uid": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataUID},
"etag": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataEtag},
"name": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataName},
"namespace": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataNamespace},
"title": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataTitle},
"description": schema.StringAttribute{Optional: true, Description: descriptionEntityMetadataDescription},
"labels": schema.MapAttribute{Optional: true, Description: descriptionEntityMetadataLabels, ElementType: types.StringType},
"annotations": schema.MapAttribute{Optional: true, Description: descriptionEntityMetadataAnnotations, ElementType: types.StringType},
"tags": schema.ListAttribute{Optional: true, Description: descriptionEntityMetadataTags, ElementType: types.StringType},
"links": schema.ListNestedAttribute{Optional: true, Description: descriptionEntityMetadataLinks, NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"url": schema.StringAttribute{Optional: true, Description: descriptionEntityLinkURL},
"title": schema.StringAttribute{Optional: true, Description: descriptionEntityLinkTitle},
"icon": schema.StringAttribute{Optional: true, Description: descriptionEntityLinkIco},
"type": schema.StringAttribute{Optional: true, Description: descriptionEntityLinkType},
},
}},
}},
"relations": schema.ListNestedAttribute{Optional: true, Description: descriptionEntityRelations, NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{Optional: true, Description: descriptionEntityRelationType},
"target_ref": schema.StringAttribute{Optional: true, Description: descriptionEntityRelationTargetRef},
"target": schema.SingleNestedAttribute{Optional: true, Description: descriptionEntityRelationTarget,
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{Optional: true, Description: descriptionEntityRelationTargetName},
"kind": schema.StringAttribute{Optional: true, Description: descriptionEntityRelationTargetKind},
"namespace": schema.StringAttribute{Optional: true, Description: descriptionEntityRelationTargetNamespace},
}},
},
}},
"spec": schema.SingleNestedAttribute{Optional: true, Description: descriptionEntitySpec, Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{Optional: true, Description: descriptionApiSpecType},
"lifecycle": schema.StringAttribute{Optional: true, Description: descriptionApiSpecLifecycle},
"owner": schema.StringAttribute{Optional: true, Description: descriptionApiSpecOwner},
"definition": schema.StringAttribute{Optional: true, Description: descriptionApiSpecDefinition},
"system": schema.StringAttribute{Optional: true, Description: descriptionApiSpecSystem},
}},
}},
},
}
}
Expand Down Expand Up @@ -152,74 +222,99 @@ func (d *apiDataSource) Read(ctx context.Context, req datasource.ReadRequest, re
tflog.Debug(ctx, fmt.Sprintf("Getting API kind %s/%s from Backstage API", state.Name.ValueString(), state.Namespace.ValueString()))
api, response, err := d.client.Catalog.APIs.Get(ctx, state.Name.ValueString(), state.Namespace.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error reading Backstage API kind",
fmt.Sprintf("Could not read Backstage API kind %s/%s: %s", state.Namespace.ValueString(), state.Name.ValueString(), err.Error()),
)
return
const shortErr = "Error reading Backstage API kind"
longErr := fmt.Sprintf("Could not read Backstage API kind %s/%s: %s", state.Namespace.ValueString(), state.Name.ValueString(), err.Error())
if state.Fallback == nil {
resp.Diagnostics.AddError(shortErr, longErr)
return
}
resp.Diagnostics.AddWarning(shortErr, longErr)
}

if response.StatusCode != http.StatusOK {
resp.Diagnostics.AddError(
"Error reading Backstage API kind",
fmt.Sprintf("Could not read Backstage API kind %s/%s: %s", state.Namespace.ValueString(), state.Name.ValueString(), response.Status),
)
return
const shortErr = "Error reading Backstage API kind"
longErr := fmt.Sprintf("Could not read Backstage API kind %s/%s: %s", state.Namespace.ValueString(), state.Name.ValueString(), response.Status)
if state.Fallback == nil {
resp.Diagnostics.AddError(shortErr, longErr)
return
}
resp.Diagnostics.AddWarning(shortErr, longErr)
}

state.ID = types.StringValue(api.Metadata.UID)
state.ApiVersion = types.StringValue(api.ApiVersion)
state.Kind = types.StringValue(api.Kind)

for _, i := range api.Relations {
state.Relations = append(state.Relations, entityRelationModel{
Type: types.StringValue(i.Type),
TargetRef: types.StringValue(i.TargetRef),
Target: &entityRelationTargetModel{
Kind: types.StringValue(i.Target.Kind),
Name: types.StringValue(i.Target.Name),
Namespace: types.StringValue(i.Target.Namespace)},
})
// Rebuild state from fallback when configured
if (err != nil || response.StatusCode != http.StatusOK) && state.Fallback != nil {
if state.Fallback.ID.IsNull() {
state.Fallback.ID = types.StringValue("123456789")
}
if state.Fallback.ApiVersion.IsNull() {
state.Fallback.ApiVersion = types.StringValue("backstage.io/v1alpha1")
}
if state.Fallback.Kind.IsNull() {
state.Fallback.Kind = types.StringValue(backstage.KindAPI)
}
state.ID = state.Fallback.ID
state.Name = state.Fallback.Name
state.Namespace = state.Fallback.Namespace
state.ApiVersion = state.Fallback.ApiVersion
state.Kind = state.Fallback.Kind
state.Metadata = state.Fallback.Metadata
state.Relations = state.Fallback.Relations
state.Spec = state.Fallback.Spec
}
if err == nil && response.StatusCode == http.StatusOK {
state.ID = types.StringValue(api.Metadata.UID)
state.ApiVersion = types.StringValue(api.ApiVersion)
state.Kind = types.StringValue(api.Kind)

state.Spec = &apiSpecModel{
Type: types.StringValue(api.Spec.Type),
Lifecycle: types.StringValue(api.Spec.Lifecycle),
Owner: types.StringValue(api.Spec.Owner),
Definition: types.StringValue(api.Spec.Definition),
System: types.StringValue(api.Spec.System),
}
for _, i := range api.Relations {
state.Relations = append(state.Relations, entityRelationModel{
Type: types.StringValue(i.Type),
TargetRef: types.StringValue(i.TargetRef),
Target: &entityRelationTargetModel{
Kind: types.StringValue(i.Target.Kind),
Name: types.StringValue(i.Target.Name),
Namespace: types.StringValue(i.Target.Namespace)},
})
}

state.Metadata = &entityMetadataModel{
UID: types.StringValue(api.Metadata.UID),
Etag: types.StringValue(api.Metadata.Etag),
Name: types.StringValue(api.Metadata.Name),
Namespace: types.StringValue(api.Metadata.Namespace),
Title: types.StringValue(api.Metadata.Title),
Description: types.StringValue(api.Metadata.Description),
Annotations: map[string]string{},
Labels: map[string]string{},
}
state.Spec = &apiSpecModel{
Type: types.StringValue(api.Spec.Type),
Lifecycle: types.StringValue(api.Spec.Lifecycle),
Owner: types.StringValue(api.Spec.Owner),
Definition: types.StringValue(api.Spec.Definition),
System: types.StringValue(api.Spec.System),
}

for k, v := range api.Metadata.Labels {
state.Metadata.Labels[k] = v
}
state.Metadata = &entityMetadataModel{
UID: types.StringValue(api.Metadata.UID),
Etag: types.StringValue(api.Metadata.Etag),
Name: types.StringValue(api.Metadata.Name),
Namespace: types.StringValue(api.Metadata.Namespace),
Title: types.StringValue(api.Metadata.Title),
Description: types.StringValue(api.Metadata.Description),
Annotations: map[string]string{},
Labels: map[string]string{},
}

for k, v := range api.Metadata.Annotations {
state.Metadata.Annotations[k] = v
}
for k, v := range api.Metadata.Labels {
state.Metadata.Labels[k] = v
}

for _, v := range api.Metadata.Tags {
state.Metadata.Tags = append(state.Metadata.Tags, types.StringValue(v))
}
for k, v := range api.Metadata.Annotations {
state.Metadata.Annotations[k] = v
}

for _, v := range api.Metadata.Tags {
state.Metadata.Tags = append(state.Metadata.Tags, types.StringValue(v))
}

for _, v := range api.Metadata.Links {
state.Metadata.Links = append(state.Metadata.Links, entityLinkModel{
URL: types.StringValue(v.URL),
Title: types.StringValue(v.Title),
Icon: types.StringValue(v.Icon),
Type: types.StringValue(v.Type),
})
for _, v := range api.Metadata.Links {
state.Metadata.Links = append(state.Metadata.Links, entityLinkModel{
URL: types.StringValue(v.URL),
Title: types.StringValue(v.Title),
Icon: types.StringValue(v.Icon),
Type: types.StringValue(v.Type),
})
}
}

diags := resp.State.Set(ctx, state)
Expand Down
43 changes: 43 additions & 0 deletions backstage/data_source_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,46 @@ data "backstage_api" "test" {
name = "streetlights"
}
`

func TestAccApiDataSource_WithFallback(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
data "backstage_api" "test" {
name = "non_existent_api_a9ab8"
namespace = "default"
fallback = {
id = "123456"
name = "fallback_api"
namespace = "default"
metadata = {
labels = {
"key" = "value"
}
}
spec = {
type = "openapi"
lifecycle = "production"
owner = "team-a"
definition = "https://example.com/api-spec"
system = "system-x"
}
}
}
`,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("data.backstage_api.test", "name", "fallback_api"),
resource.TestCheckResourceAttr("data.backstage_api.test", "namespace", "default"),
resource.TestCheckResourceAttr("data.backstage_api.test", "spec.type", "openapi"),
resource.TestCheckResourceAttr("data.backstage_api.test", "spec.lifecycle", "production"),
resource.TestCheckResourceAttr("data.backstage_api.test", "spec.owner", "team-a"),
resource.TestCheckResourceAttr("data.backstage_api.test", "spec.definition", "https://example.com/api-spec"),
resource.TestCheckResourceAttr("data.backstage_api.test", "spec.system", "system-x"),
resource.TestCheckResourceAttr("data.backstage_api.test", "metadata.labels.key", "value"),
),
},
},
})
}
Loading

0 comments on commit 1b194da

Please sign in to comment.