diff --git a/apstra/data_source_freeform_blueprint.go b/apstra/data_source_freeform_blueprint.go new file mode 100644 index 00000000..82b53f2e --- /dev/null +++ b/apstra/data_source_freeform_blueprint.go @@ -0,0 +1,92 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/terraform-provider-apstra/apstra/freeform" + + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" +) + +var ( + _ datasource.DataSourceWithConfigure = &dataSourceFreeformBlueprint{} + _ datasourceWithSetClient = &dataSourceFreeformBlueprint{} + _ datasourceWithSetFfBpClientFunc = &dataSourceFreeformBlueprint{} +) + +type dataSourceFreeformBlueprint struct { + client *apstra.Client + getBpClientFunc func(context.Context, string) (*apstra.FreeformClient, error) +} + +func (o *dataSourceFreeformBlueprint) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_freeform_blueprint" +} + +func (o *dataSourceFreeformBlueprint) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + configureDataSource(ctx, o, req, resp) +} + +func (o *dataSourceFreeformBlueprint) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryFreeform + "This data source looks up summary details of a Freeform Blueprint.\n\n" + + "At least one optional attribute is required.", + Attributes: freeform.Blueprint{}.DataSourceAttributes(), + } +} + +func (o *dataSourceFreeformBlueprint) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config freeform.Blueprint + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + var err error + var apiData *apstra.BlueprintStatus + + switch { + case !config.Name.IsNull(): + apiData, err = o.client.GetBlueprintStatusByName(ctx, config.Name.ValueString()) + if utils.IsApstra404(err) { + resp.Diagnostics.AddAttributeError( + path.Root("name"), + "Blueprint not found", + fmt.Sprintf("Blueprint with name %q not found", config.Name.ValueString())) + return + } + case !config.Id.IsNull(): + apiData, err = o.client.GetBlueprintStatus(ctx, apstra.ObjectId(config.Id.ValueString())) + if utils.IsApstra404(err) { + resp.Diagnostics.AddAttributeError( + path.Root("name"), + "Blueprint not found", + fmt.Sprintf("Blueprint with ID %q not found", config.Id.ValueString())) + return + } + } + if err != nil { // catch errors other than 404 from above + resp.Diagnostics.AddError("Error retrieving Blueprint Status", err.Error()) + return + } + + config.Id = types.StringValue(apiData.Id.String()) + config.Name = types.StringValue(apiData.Label) + + // set state + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +func (o *dataSourceFreeformBlueprint) setClient(client *apstra.Client) { + o.client = client +} + +func (o *dataSourceFreeformBlueprint) setBpClientFunc(f func(context.Context, string) (*apstra.FreeformClient, error)) { + o.getBpClientFunc = f +} diff --git a/apstra/export_test.go b/apstra/export_test.go index 867ada4b..b0879c33 100644 --- a/apstra/export_test.go +++ b/apstra/export_test.go @@ -13,6 +13,7 @@ var ( ResourceDatacenterGenericSystem = resourceDatacenterGenericSystem{} ResourceDatacenterIpLinkAddressing = resourceDatacenterIpLinkAddressing{} ResourceDatacenterRoutingZone = resourceDatacenterRoutingZone{} + ResourceFreeformBlueprint = resourceFreeformBlueprint{} ResourceFreeformConfigTemplate = resourceFreeformConfigTemplate{} ResourceFreeformLink = resourceFreeformLink{} ResourceFreeformPropertySet = resourceFreeformPropertySet{} diff --git a/apstra/freeform/blueprint.go b/apstra/freeform/blueprint.go new file mode 100644 index 00000000..2cb6e7ef --- /dev/null +++ b/apstra/freeform/blueprint.go @@ -0,0 +1,107 @@ +package freeform + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/terraform-provider-apstra/apstra/constants" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + dataSourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + resourceSchema "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" +) + +type Blueprint struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` +} + +func (o Blueprint) DataSourceAttributes() map[string]dataSourceSchema.Attribute { + return map[string]dataSourceSchema.Attribute{ + "id": dataSourceSchema.StringAttribute{ + MarkdownDescription: "ID of the Blueprint. Required when `name` is omitted.", + Computed: true, + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRelative(), + path.MatchRoot("name"), + }...), + }, + }, + "name": dataSourceSchema.StringAttribute{ + MarkdownDescription: "Name of the Blueprint. Required when `id` is omitted.", + Computed: true, + Optional: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + } +} + +func (o Blueprint) ResourceAttributes() map[string]resourceSchema.Attribute { + return map[string]resourceSchema.Attribute{ + "id": resourceSchema.StringAttribute{ + MarkdownDescription: "Blueprint ID assigned by Apstra.", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": resourceSchema.StringAttribute{ + MarkdownDescription: "Blueprint name.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + } +} + +func (o *Blueprint) SetName(ctx context.Context, bpClient *apstra.FreeformClient, state *Blueprint, diags *diag.Diagnostics) { + if o.Name.Equal(state.Name) { + // nothing to do + return + } + + // struct used for GET and PATCH + type node struct { + Label string `json:"label,omitempty"` + Id apstra.ObjectId `json:"id,omitempty"` + } + + // GET target + response := &struct { + Nodes map[string]node `json:"nodes"` + }{} + + err := bpClient.Client().GetNodes(ctx, bpClient.Id(), apstra.NodeTypeMetadata, response) + if err != nil { + diags.AddError( + fmt.Sprintf(constants.ErrApiGetWithTypeAndId, "Blueprint Node", bpClient.Id()), + err.Error(), + ) + return + } + if len(response.Nodes) != 1 { + diags.AddError(fmt.Sprintf("wrong number of %s nodes", apstra.NodeTypeMetadata.String()), + fmt.Sprintf("expecting 1 got %d nodes", len(response.Nodes))) + return + } + + // pull the only value from the map + var nodeId apstra.ObjectId + for _, v := range response.Nodes { + nodeId = v.Id + } + + err = bpClient.Client().PatchNode(ctx, bpClient.Id(), nodeId, &node{Label: o.Name.ValueString()}, nil) + if err != nil { + diags.AddError( + fmt.Sprintf(constants.ErrApiGetWithTypeAndId, bpClient.Id(), nodeId), + err.Error(), + ) + return + } +} diff --git a/apstra/provider.go b/apstra/provider.go index 05bdfee2..1eefda60 100644 --- a/apstra/provider.go +++ b/apstra/provider.go @@ -550,6 +550,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource func() datasource.DataSource { return &dataSourceDatacenterVirtualNetwork{} }, func() datasource.DataSource { return &dataSourceDatacenterVirtualNetworks{} }, func() datasource.DataSource { return &dataSourceDeviceConfig{} }, + func() datasource.DataSource { return &dataSourceFreeformBlueprint{} }, func() datasource.DataSource { return &dataSourceFreeformConfigTemplate{} }, func() datasource.DataSource { return &dataSourceFreeformLink{} }, func() datasource.DataSource { return &dataSourceFreeformPropertySet{} }, @@ -610,6 +611,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return &resourceDatacenterIpLinkAddressing{} }, func() resource.Resource { return &resourceDatacenterVirtualNetwork{} }, func() resource.Resource { return &resourceDeviceAllocation{} }, + func() resource.Resource { return &resourceFreeformBlueprint{} }, func() resource.Resource { return &resourceFreeformConfigTemplate{} }, func() resource.Resource { return &resourceFreeformLink{} }, func() resource.Resource { return &resourceFreeformPropertySet{} }, diff --git a/apstra/resource_freeform_blueprint.go b/apstra/resource_freeform_blueprint.go new file mode 100644 index 00000000..b9502546 --- /dev/null +++ b/apstra/resource_freeform_blueprint.go @@ -0,0 +1,171 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/terraform-provider-apstra/apstra/freeform" + + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.ResourceWithConfigure = &resourceFreeformBlueprint{} + _ resourceWithSetClient = &resourceFreeformBlueprint{} + _ resourceWithSetFfBpClientFunc = &resourceFreeformBlueprint{} + _ resourceWithSetBpLockFunc = &resourceFreeformBlueprint{} + _ resourceWithSetBpUnlockFunc = &resourceFreeformBlueprint{} +) + +type resourceFreeformBlueprint struct { + client *apstra.Client + getBpClientFunc func(context.Context, string) (*apstra.FreeformClient, error) + lockFunc func(context.Context, string) error + unlockFunc func(context.Context, string) error +} + +func (o *resourceFreeformBlueprint) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_freeform_blueprint" +} + +func (o *resourceFreeformBlueprint) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + configureResource(ctx, o, req, resp) +} + +func (o *resourceFreeformBlueprint) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryFreeform + "This resource instantiates a Freeform Blueprint.", + Attributes: freeform.Blueprint{}.ResourceAttributes(), + } +} + +func (o *resourceFreeformBlueprint) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Retrieve values from plan. + var plan freeform.Blueprint + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Create the blueprint. + id, err := o.client.CreateFreeformBlueprint(ctx, plan.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed creating Blueprint", err.Error()) + return + } + + plan.Id = types.StringValue(id.String()) + + // Set state. + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (o *resourceFreeformBlueprint) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Get current state. + var state freeform.Blueprint + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // get a client for the freeform reference design + bp, err := o.getBpClientFunc(ctx, state.Id.ValueString()) + if err != nil { + if utils.IsApstra404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + // Retrieve the blueprint status + apiData, err := bp.Client().GetBlueprintStatus(ctx, apstra.ObjectId(state.Id.ValueString())) + if err != nil { + // no 404 check or RemoveResource() here because Apstra's /api/blueprints + // endpoint may return bogus 404s due to race condition (?) + resp.Diagnostics.AddError(fmt.Sprintf("failed fetching blueprint %s status", state.Id), err.Error()) + return + } + + state.Name = types.StringValue(apiData.Label) + + // Set state. + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update resource +func (o *resourceFreeformBlueprint) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve plan. + var plan freeform.Blueprint + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve state. + var state freeform.Blueprint + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // get a client for the freeform reference design + bp, err := o.getBpClientFunc(ctx, plan.Id.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + // Update the blueprint name if necessary + plan.SetName(ctx, bp, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Set state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete resource +func (o *resourceFreeformBlueprint) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state freeform.Blueprint + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Delete the blueprint + err := o.client.DeleteBlueprint(ctx, apstra.ObjectId(state.Id.ValueString())) + if err != nil { + if !utils.IsApstra404(err) { // 404 is okay, but we do not return because we must unlock + resp.Diagnostics.AddError("error deleting Blueprint", err.Error()) + } + } + + // Unlock the blueprint mutex. + err = o.unlockFunc(ctx, state.Id.ValueString()) + if err != nil { + resp.Diagnostics.AddError("error unlocking blueprint mutex", err.Error()) + } +} + +func (o *resourceFreeformBlueprint) setClient(client *apstra.Client) { + o.client = client +} + +func (o *resourceFreeformBlueprint) setBpClientFunc(f func(context.Context, string) (*apstra.FreeformClient, error)) { + o.getBpClientFunc = f +} + +func (o *resourceFreeformBlueprint) setBpLockFunc(f func(context.Context, string) error) { + o.lockFunc = f +} + +func (o *resourceFreeformBlueprint) setBpUnlockFunc(f func(context.Context, string) error) { + o.unlockFunc = f +} diff --git a/apstra/resource_freeform_blueprint_integration_test.go b/apstra/resource_freeform_blueprint_integration_test.go new file mode 100644 index 00000000..1ac7a4af --- /dev/null +++ b/apstra/resource_freeform_blueprint_integration_test.go @@ -0,0 +1,110 @@ +//go:build integration + +package tfapstra_test + +import ( + "context" + "fmt" + "testing" + + tfapstra "github.com/Juniper/terraform-provider-apstra/apstra" + testutils "github.com/Juniper/terraform-provider-apstra/apstra/test_utils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +const ( + resourceFreeformBlueprintHcl = ` +resource %q %q { + name = %q +} +` +) + +type resourceFreeformBlueprint struct { + name string +} + +func (o resourceFreeformBlueprint) render(rType, rName string) string { + return fmt.Sprintf(resourceFreeformBlueprintHcl, + rType, rName, + o.name, + ) +} + +func (o resourceFreeformBlueprint) testChecks(t testing.TB, rType, rName string) testChecks { + result := newTestChecks(rType + "." + rName) + + // required and computed attributes can always be checked + result.append(t, "TestCheckResourceAttrSet", "id") + result.append(t, "TestCheckResourceAttr", "name", o.name) + + return result +} + +func TestResourceFreeformBlueprint(t *testing.T) { + ctx := context.Background() + client := testutils.GetTestClient(t, ctx) + apiVersion := version.Must(version.NewVersion(client.ApiVersion())) + + type testStep struct { + config resourceFreeformBlueprint + } + + type testCase struct { + apiVersionConstraints version.Constraints + steps []testStep + } + + testCases := map[string]testCase{ + "simple_case": { + steps: []testStep{ + { + config: resourceFreeformBlueprint{ + name: acctest.RandString(6), + }, + }, + { + config: resourceFreeformBlueprint{ + name: acctest.RandString(6), + }, + }, + }, + }, + } + + resourceType := tfapstra.ResourceName(ctx, &tfapstra.ResourceFreeformBlueprint) + + for tName, tCase := range testCases { + tName, tCase := tName, tCase + t.Run(tName, func(t *testing.T) { + t.Parallel() + if !tCase.apiVersionConstraints.Check(apiVersion) { + t.Skipf("test case %s requires Apstra %s", tName, tCase.apiVersionConstraints.String()) + } + + steps := make([]resource.TestStep, len(tCase.steps)) + for i, step := range tCase.steps { + config := step.config.render(resourceType, tName) + checks := step.config.testChecks(t, resourceType, tName) + + chkLog := checks.string() + stepName := fmt.Sprintf("test case %q step %d", tName, i+1) + + t.Logf("\n// ------ begin config for %s ------\n%s// -------- end config for %s ------\n\n", stepName, config, stepName) + t.Logf("\n// ------ begin checks for %s ------\n%s// -------- end checks for %s ------\n\n", stepName, chkLog, stepName) + + steps[i] = resource.TestStep{ + Config: insecureProviderConfigHCL + config, + Check: resource.ComposeAggregateTestCheckFunc(checks.checks...), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: steps, + }) + }) + } +} diff --git a/docs/data-sources/freeform_blueprint.md b/docs/data-sources/freeform_blueprint.md new file mode 100644 index 00000000..7ffee56d --- /dev/null +++ b/docs/data-sources/freeform_blueprint.md @@ -0,0 +1,54 @@ +--- +page_title: "apstra_freeform_blueprint Data Source - terraform-provider-apstra" +subcategory: "Reference Design: Freeform" +description: |- + This data source looks up summary details of a Freeform Blueprint. + At least one optional attribute is required. +--- + +# apstra_freeform_blueprint (Data Source) + +This data source looks up summary details of a Freeform Blueprint. + +At least one optional attribute is required. + + +## Example Usage + +```terraform +# This example defines a freeform blueprint, then looks it up +# both by name and by ID. + +resource "apstra_freeform_blueprint" "test" { + name = "bar" +} + +data "apstra_freeform_blueprint" "by_name" { + name = apstra_freeform_blueprint.test.name +} + +data "apstra_freeform_blueprint" "by_id" { + id = apstra_freeform_blueprint.test.id +} + +output "by_name" { value = data.apstra_freeform_blueprint.by_name } +output "by_id" { value = data.apstra_freeform_blueprint.by_id } + +# The output looks like: +# by_id = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +# by_name = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +``` + + +## Schema + +### Optional + +- `id` (String) ID of the Blueprint. Required when `name` is omitted. +- `name` (String) Name of the Blueprint. Required when `id` is omitted. diff --git a/docs/resources/freeform_blueprint.md b/docs/resources/freeform_blueprint.md new file mode 100644 index 00000000..e276f772 --- /dev/null +++ b/docs/resources/freeform_blueprint.md @@ -0,0 +1,57 @@ +--- +page_title: "apstra_freeform_blueprint Resource - terraform-provider-apstra" +subcategory: "Reference Design: Freeform" +description: |- + This resource instantiates a Freeform Blueprint. +--- + +# apstra_freeform_blueprint (Resource) + +This resource instantiates a Freeform Blueprint. + + +## Example Usage + +```terraform +# This example defines a freeform blueprint, then looks it up +# both by name and by ID. + +resource "apstra_freeform_blueprint" "test" { + name = "bar" +} + +data "apstra_freeform_blueprint" "by_name" { + name = apstra_freeform_blueprint.test.name +} + +data "apstra_freeform_blueprint" "by_id" { + id = apstra_freeform_blueprint.test.id +} + +output "by_name" { value = data.apstra_freeform_blueprint.by_name } +output "by_id" { value = data.apstra_freeform_blueprint.by_id } + +# The output looks like: +# by_id = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +# by_name = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +``` + + +## Schema + +### Required + +- `name` (String) Blueprint name. + +### Read-Only + +- `id` (String) Blueprint ID assigned by Apstra. + + + diff --git a/examples/data-sources/apstra_freeform_blueprint/example.tf b/examples/data-sources/apstra_freeform_blueprint/example.tf new file mode 100644 index 00000000..c0bfa582 --- /dev/null +++ b/examples/data-sources/apstra_freeform_blueprint/example.tf @@ -0,0 +1,27 @@ +# This example defines a freeform blueprint, then looks it up +# both by name and by ID. + +resource "apstra_freeform_blueprint" "test" { + name = "bar" +} + +data "apstra_freeform_blueprint" "by_name" { + name = apstra_freeform_blueprint.test.name +} + +data "apstra_freeform_blueprint" "by_id" { + id = apstra_freeform_blueprint.test.id +} + +output "by_name" { value = data.apstra_freeform_blueprint.by_name } +output "by_id" { value = data.apstra_freeform_blueprint.by_id } + +# The output looks like: +# by_id = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +# by_name = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } diff --git a/examples/resources/apstra_freeform_blueprint/example.tf b/examples/resources/apstra_freeform_blueprint/example.tf new file mode 100644 index 00000000..c0bfa582 --- /dev/null +++ b/examples/resources/apstra_freeform_blueprint/example.tf @@ -0,0 +1,27 @@ +# This example defines a freeform blueprint, then looks it up +# both by name and by ID. + +resource "apstra_freeform_blueprint" "test" { + name = "bar" +} + +data "apstra_freeform_blueprint" "by_name" { + name = apstra_freeform_blueprint.test.name +} + +data "apstra_freeform_blueprint" "by_id" { + id = apstra_freeform_blueprint.test.id +} + +output "by_name" { value = data.apstra_freeform_blueprint.by_name } +output "by_id" { value = data.apstra_freeform_blueprint.by_id } + +# The output looks like: +# by_id = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# } +# by_name = { +# "id" = "37bc0ff6-b9fe-44b3-aa69-1a0ac0368421" +# "name" = "bar" +# }