From e1707dcc0480fcc786bb49fc65eb8b0d92ddab47 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Wed, 12 Feb 2025 14:00:50 -0500 Subject: [PATCH 1/5] WIP: RZ Loopbacks Create() and Read() --- apstra/blueprint/routing_zone_loopback.go | 34 +++ apstra/blueprint/routing_zone_loopbacks.go | 196 ++++++++++++++++++ ...esource_routing_zone_loopback_addresses.go | 45 ++++ apstra/provider.go | 1 + ...acenter_routing_zone_loopback_addresses.go | 150 ++++++++++++++ go.mod | 2 +- go.sum | 4 +- 7 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 apstra/blueprint/routing_zone_loopback.go create mode 100644 apstra/blueprint/routing_zone_loopbacks.go create mode 100644 apstra/private/resource_routing_zone_loopback_addresses.go create mode 100644 apstra/resource_datacenter_routing_zone_loopback_addresses.go diff --git a/apstra/blueprint/routing_zone_loopback.go b/apstra/blueprint/routing_zone_loopback.go new file mode 100644 index 00000000..fc7d94aa --- /dev/null +++ b/apstra/blueprint/routing_zone_loopback.go @@ -0,0 +1,34 @@ +package blueprint + +import ( + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +type RoutingZoneLoopback struct { + Ipv4Addr cidrtypes.IPv4Prefix `tfsdk:"ipv4_addr"` + Ipv6Addr cidrtypes.IPv6Prefix `tfsdk:"ipv6_addr"` +} + +func (o RoutingZoneLoopback) AttrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "ipv4_addr": cidrtypes.IPv4PrefixType{}, + "ipv6_addr": cidrtypes.IPv6PrefixType{}, + } +} + +func (o RoutingZoneLoopback) ResourceAttributes() map[string]resourceSchema.Attribute { + return map[string]resourceSchema.Attribute{ + "ipv4_addr": resourceSchema.StringAttribute{ + CustomType: cidrtypes.IPv4PrefixType{}, + MarkdownDescription: "The IPv4 address to be assigned within the Routing Zone, in CIDR notation.", + Optional: true, + }, + "ipv6_addr": resourceSchema.StringAttribute{ + CustomType: cidrtypes.IPv6PrefixType{}, + MarkdownDescription: "The IPv6 address to be assigned within the Routing Zone, in CIDR notation.", + Optional: true, + }, + } +} diff --git a/apstra/blueprint/routing_zone_loopbacks.go b/apstra/blueprint/routing_zone_loopbacks.go new file mode 100644 index 00000000..0c437f70 --- /dev/null +++ b/apstra/blueprint/routing_zone_loopbacks.go @@ -0,0 +1,196 @@ +package blueprint + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "net/netip" + + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/terraform-provider-apstra/apstra/private" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type RoutingZoneLoopbacks struct { + BlueprintId types.String `tfsdk:"blueprint_id"` + RoutingZoneId types.String `tfsdk:"routing_zone_id"` + Loopbacks types.Map `tfsdk:"loopbacks"` +} + +func (o RoutingZoneLoopbacks) ResourceAttributes() map[string]resourceSchema.Attribute { + return map[string]resourceSchema.Attribute{ + "blueprint_id": resourceSchema.StringAttribute{ + MarkdownDescription: "Apstra Blueprint ID.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "routing_zone_id": resourceSchema.StringAttribute{ + MarkdownDescription: "Routing Zone ID.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "loopbacks": resourceSchema.MapNestedAttribute{ + MarkdownDescription: "Map of Loopback IPv4 and IPv6 addresses, keyed by System Node ID.", + Required: true, + NestedObject: resourceSchema.NestedAttributeObject{ + Attributes: RoutingZoneLoopback{}.ResourceAttributes(), + }, + }, + } +} + +func (o RoutingZoneLoopbacks) Request(ctx context.Context, bp *apstra.TwoStageL3ClosClient, ps private.State, diags *diag.Diagnostics) (map[apstra.ObjectId]apstra.SecurityZoneLoopback, *private.ResourceDatacenterRoutingZoneLoopbackAddresses) { + // API response will allow us to determine interface IDs from system IDs + szInfo, err := bp.GetSecurityZoneInfo(ctx, apstra.ObjectId(o.RoutingZoneId.ValueString())) + if err != nil { + diags.AddError("failed querying for security zone", err.Error()) + return nil, nil + } + + // convert API response to map (switchId -> loopbackId) for easy lookups + nodeIdToIfId := make(map[string]apstra.ObjectId) + for _, memberInterface := range szInfo.MemberInterfaces { + for _, loopback := range memberInterface.Loopbacks { + nodeIdToIfId[memberInterface.HostingSystem.Id.String()] = loopback.Id + } + } + + // extract the planned loopbacks + var planLoopbackMap map[string]RoutingZoneLoopback + diags.Append(o.Loopbacks.ElementsAs(ctx, &planLoopbackMap, false)...) + if diags.HasError() { + return nil, nil + } + + // extract private state (previously configured loopbacks) + var previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses + if ps != nil { // ps will be nil prior to initial creation + previousLoopbackMap.LoadPrivateState(ctx, ps, diags) + if diags.HasError() { + return nil, nil + } + } + + // we return these two maps + resultMap := make(map[apstra.ObjectId]apstra.SecurityZoneLoopback, len(planLoopbackMap)) + resultPrivate := make(private.ResourceDatacenterRoutingZoneLoopbackAddresses, len(planLoopbackMap)) + + for sysId, loopback := range planLoopbackMap { + // ensure the specified system ID exists in the RZ-specific map we got from the API + loopbackId, ok := nodeIdToIfId[sysId] + if !ok { + diags.AddError( + "System not participating in Routing Zone", + fmt.Sprintf("System %s not participating in routing zone %s", sysId, o.RoutingZoneId), + ) + return nil, nil + } + + var szl apstra.SecurityZoneLoopback // this will be a resultMap entry + var p struct { // this will be a resultPrivate entry + HasIpv4 bool `json:"has_ipv4"` + HasIpv6 bool `json:"has_ipv6"` + } + + if !loopback.Ipv4Addr.IsNull() { + szl.IPv4Addr = utils.ToPtr(netip.MustParsePrefix(loopback.Ipv4Addr.ValueString())) + p.HasIpv4 = true + } else { + if previousLoopbackMap[sysId].HasIpv4 { + szl.IPv4Addr = new(netip.Prefix) // signals to remove previous value + } + } + + if !loopback.Ipv6Addr.IsNull() { + szl.IPv6Addr = utils.ToPtr(netip.MustParsePrefix(loopback.Ipv6Addr.ValueString())) + p.HasIpv6 = true + } else { + if previousLoopbackMap[sysId].HasIpv6 { + szl.IPv6Addr = new(netip.Prefix) // signals to remove previous value + } + } + + // previous addresses (if any) are no longer of any interest + delete(previousLoopbackMap, sysId) + + resultPrivate[sysId] = p + resultMap[loopbackId] = szl + } + + // loop over remaining previous IP assignments; clear them as necessary + for sysId, previous := range previousLoopbackMap { + ifId, ok := nodeIdToIfId[sysId] + if !ok { + continue // system no longer exists + } + + var szl apstra.SecurityZoneLoopback + if previous.HasIpv4 { + szl.IPv4Addr = new(netip.Prefix) // bogus prefix clears entry from API + } + if previous.HasIpv6 { + szl.IPv6Addr = new(netip.Prefix) // bogus prefix clears entry from API + } + resultMap[ifId] = szl + } + + return resultMap, &resultPrivate +} + +func (o *RoutingZoneLoopbacks) LoadApiData(ctx context.Context, info *apstra.TwoStageL3ClosSecurityZoneInfo, ps private.State, diags *diag.Diagnostics) { + // extract private state (previously configured loopbacks) + var previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses + if ps != nil { + previousLoopbackMap.LoadPrivateState(ctx, ps, diags) + if diags.HasError() { + return + } + } + + loopbacks := make(map[string]RoutingZoneLoopback, len(previousLoopbackMap)) + for _, memberInterface := range info.MemberInterfaces { + switch len(memberInterface.Loopbacks) { + case 0: // weird, but whatever + continue + case 1: // expected case handled below + default: // error condition + diags.AddError( + "invalid API response", + fmt.Sprintf("System %q has %d loopback interfaces in Routing Zone %s, expected 0 or 1", + memberInterface.HostingSystem.Id, len(memberInterface.Loopbacks), o.RoutingZoneId), + ) + return + } + + previous, ok := previousLoopbackMap[memberInterface.HostingSystem.Id.String()] + if !ok { + continue + } + + var ipv4Addr cidrtypes.IPv4Prefix + if memberInterface.Loopbacks[0].Ipv4Addr != nil && previous.HasIpv4 { + ipv4Addr = cidrtypes.NewIPv4PrefixValue(memberInterface.Loopbacks[0].Ipv4Addr.String()) + } + + var ipv6Addr cidrtypes.IPv6Prefix + if memberInterface.Loopbacks[0].Ipv6Addr != nil && previous.HasIpv6 { + ipv6Addr = cidrtypes.NewIPv6PrefixValue(memberInterface.Loopbacks[0].Ipv6Addr.String()) + } + + loopbacks[memberInterface.HostingSystem.Id.String()] = RoutingZoneLoopback{ + Ipv4Addr: ipv4Addr, + Ipv6Addr: ipv6Addr, + } + } + + o.Loopbacks = utils.MapValueOrNull(ctx, types.ObjectType{AttrTypes: RoutingZoneLoopback{}.AttrTypes()}, loopbacks, diags) +} diff --git a/apstra/private/resource_routing_zone_loopback_addresses.go b/apstra/private/resource_routing_zone_loopback_addresses.go new file mode 100644 index 00000000..c1f6356b --- /dev/null +++ b/apstra/private/resource_routing_zone_loopback_addresses.go @@ -0,0 +1,45 @@ +package private + +import ( + "context" + "encoding/json" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// ResourceDatacenterRoutingZoneLoopbackAddresses is stored in private state by +// resourceDatacenterRoutingZoneLoopbackAddresses methods Create() and Update(). +// It contains a record of switch node IDs which previously had IPv4 and IPv6 +// loopback addresses configured. +// This record is consulted in the following methods: +// - Read() - we don't read all loopbacks, only ones previously configured. +// - Update() - previous assignments which do not appear in the plan are cleared. +// - Delete() - all previous assignments are cleared. +type ResourceDatacenterRoutingZoneLoopbackAddresses map[string]struct { + HasIpv4 bool `json:"has_ipv4"` + HasIpv6 bool `json:"has_ipv6"` +} + +func (o *ResourceDatacenterRoutingZoneLoopbackAddresses) LoadPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) { + b, d := ps.GetKey(ctx, fmt.Sprintf("%T", *o)) + diags.Append(d...) + if diags.HasError() { + return + } + + err := json.Unmarshal(b, &o) + if err != nil { + diags.AddError("failed to unmarshal private state", err.Error()) + return + } +} + +func (o *ResourceDatacenterRoutingZoneLoopbackAddresses) SetPrivateState(ctx context.Context, ps State, diags *diag.Diagnostics) { + b, err := json.Marshal(o) + if err != nil { + diags.AddError("failed to marshal private state", err.Error()) + return + } + + diags.Append(ps.SetKey(ctx, fmt.Sprintf("%T", *o), b)...) +} diff --git a/apstra/provider.go b/apstra/provider.go index 9cfd3369..e8fb7e20 100644 --- a/apstra/provider.go +++ b/apstra/provider.go @@ -632,6 +632,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return &resourceDatacenterRack{} }, func() resource.Resource { return &resourceDatacenterRoutingZone{} }, func() resource.Resource { return &resourceDatacenterRoutingZoneConstraint{} }, + func() resource.Resource { return &resourceDatacenterRoutingZoneLoopbackAddresses{} }, func() resource.Resource { return &resourceDatacenterRoutingPolicy{} }, func() resource.Resource { return &resourceDatacenterSecurityPolicy{} }, func() resource.Resource { return &resourceDatacenterIpLinkAddressing{} }, diff --git a/apstra/resource_datacenter_routing_zone_loopback_addresses.go b/apstra/resource_datacenter_routing_zone_loopback_addresses.go new file mode 100644 index 00000000..b49bb4e7 --- /dev/null +++ b/apstra/resource_datacenter_routing_zone_loopback_addresses.go @@ -0,0 +1,150 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/apstra-go-sdk/apstra/compatibility" + "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +var ( + _ resource.ResourceWithConfigure = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resourceWithSetDcBpClientFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resourceWithSetBpLockFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) +) + +type resourceDatacenterRoutingZoneLoopbackAddresses struct { + getBpClientFunc func(context.Context, string) (*apstra.TwoStageL3ClosClient, error) + lockFunc func(context.Context, string) error +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_datacenter_routing_zone_loopback_addresses" +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + configureResource(ctx, o, req, resp) +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryDatacenter + fmt.Sprintf("This resource configures loopback addresses "+ + "of *switch* nodes in a Datacenter Blueprint.\n\n"+ + "Note that the loopback addresses within the `default` routing zone can also be configured using the "+ + "`apstra_datacenter_device_allocation` resource. Configuring interfaces using both resources can lead to "+ + "configuration churn.\n\n"+ + "Note that loopback addresses can only be configured on Systems *actively participating* in the Routing "+ + "Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf "+ + "Switch. The Terraform project must be structured carefully to ensure that those bindings are created before "+ + "this resource is created.\n\n"+ + " Requires Apstra %s.", compatibility.SecurityZoneLoopbackApiSupported), + Attributes: blueprint.RoutingZoneLoopbacks{}.ResourceAttributes(), + } +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan blueprint.RoutingZoneLoopbacks + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // get a client for the datacenter reference design + bp, err := o.getBpClientFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + if utils.IsApstra404(err) { + resp.Diagnostics.AddError(fmt.Sprintf("blueprint %s not found", plan.BlueprintId), err.Error()) + return + } + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + // Lock the blueprint mutex. + err = o.lockFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("error locking blueprint %q mutex", plan.BlueprintId.ValueString()), + err.Error()) + return + } + + // create an API request + request, ps := plan.Request(ctx, bp, nil, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // record assignments to private state + ps.SetPrivateState(ctx, resp.Private, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // set loopback addresses + err = bp.SetSecurityZoneLoopbacks(ctx, apstra.ObjectId(plan.RoutingZoneId.ValueString()), request) + if err != nil { + resp.Diagnostics.AddError("failed to set loopback addresses", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state blueprint.RoutingZoneLoopbacks + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // get a client for the datacenter reference design + bp, err := o.getBpClientFunc(ctx, state.BlueprintId.ValueString()) + if err != nil { + if utils.IsApstra404(err) { + resp.Diagnostics.AddError(fmt.Sprintf("blueprint %s not found", state.BlueprintId), err.Error()) + return + } + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + api, err := bp.GetSecurityZoneInfo(ctx, apstra.ObjectId(state.RoutingZoneId.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("failed to get security zone info", err.Error()) + return + } + + state.LoadApiData(ctx, api, req.Private, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + //TODO implement me + panic("implement me") +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + //TODO implement me + panic("implement me") +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setBpClientFunc(f func(context.Context, string) (*apstra.TwoStageL3ClosClient, error)) { + o.getBpClientFunc = f +} + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setBpLockFunc(f func(context.Context, string) error) { + o.lockFunc = f +} diff --git a/go.mod b/go.mod index f568795d..90ff60d3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ toolchain go1.22.10 require ( github.com/IBM/netaddr v1.5.0 - github.com/Juniper/apstra-go-sdk v0.0.0-20250205141010-47c9794b99b0 + github.com/Juniper/apstra-go-sdk v0.0.0-20250207014126-202e8b829f30 github.com/chrismarget-j/go-licenses v0.0.0-20240224210557-f22f3e06d3d4 github.com/chrismarget-j/version-constraints v0.0.0-20240925155624-26771a0a6820 github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index 85ac21b2..79f2fb3e 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/IBM/netaddr v1.5.0 h1:IJlFZe1+nFs09TeMB/HOP4+xBnX2iM/xgiDOgZgTJq0= github.com/IBM/netaddr v1.5.0/go.mod h1:DDBPeYgbFzoXHjSz9Jwk7K8wmWV4+a/Kv0LqRnb8we4= -github.com/Juniper/apstra-go-sdk v0.0.0-20250205141010-47c9794b99b0 h1:25PK8gsNcqDm5NcaBsTg909sO4G+Lkfcdn3h7xizhPU= -github.com/Juniper/apstra-go-sdk v0.0.0-20250205141010-47c9794b99b0/go.mod h1:j0XhEo0IoltyST4cqdLwrDUNLDHC7JWJxBPDVffeSCg= +github.com/Juniper/apstra-go-sdk v0.0.0-20250207014126-202e8b829f30 h1:pXdgnEAY1p/qk8SNDdjHvjHSwsWPuqdr95+mOV1xsXY= +github.com/Juniper/apstra-go-sdk v0.0.0-20250207014126-202e8b829f30/go.mod h1:j0XhEo0IoltyST4cqdLwrDUNLDHC7JWJxBPDVffeSCg= github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= From 3dea665d8206f68fef84eea54302d42435a11088 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Thu, 13 Feb 2025 08:35:31 -0500 Subject: [PATCH 2/5] update, delete, tests --- apstra/blueprint/routing_zone_loopbacks.go | 19 +- apstra/export_test.go | 1 + ...acenter_routing_zone_loopback_addresses.go | 123 +++++- ...one_loopback_addresses_integration_test.go | 350 ++++++++++++++++++ ...acenter_routing_zone_loopback_addresses.md | 64 ++++ .../example.tf | 19 + 6 files changed, 554 insertions(+), 22 deletions(-) create mode 100644 apstra/resource_datacenter_routing_zone_loopback_addresses_integration_test.go create mode 100644 docs/resources/datacenter_routing_zone_loopback_addresses.md create mode 100644 examples/resources/apstra_datacenter_routing_zone_loopback_addresses/example.tf diff --git a/apstra/blueprint/routing_zone_loopbacks.go b/apstra/blueprint/routing_zone_loopbacks.go index 0c437f70..1e3df113 100644 --- a/apstra/blueprint/routing_zone_loopbacks.go +++ b/apstra/blueprint/routing_zone_loopbacks.go @@ -3,17 +3,18 @@ package blueprint import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "net/netip" "github.com/Juniper/apstra-go-sdk/apstra" "github.com/Juniper/terraform-provider-apstra/apstra/private" "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" 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" ) @@ -44,11 +45,12 @@ func (o RoutingZoneLoopbacks) ResourceAttributes() map[string]resourceSchema.Att NestedObject: resourceSchema.NestedAttributeObject{ Attributes: RoutingZoneLoopback{}.ResourceAttributes(), }, + Validators: []validator.Map{mapvalidator.SizeAtLeast(1)}, }, } } -func (o RoutingZoneLoopbacks) Request(ctx context.Context, bp *apstra.TwoStageL3ClosClient, ps private.State, diags *diag.Diagnostics) (map[apstra.ObjectId]apstra.SecurityZoneLoopback, *private.ResourceDatacenterRoutingZoneLoopbackAddresses) { +func (o RoutingZoneLoopbacks) Request(ctx context.Context, bp *apstra.TwoStageL3ClosClient, previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses, diags *diag.Diagnostics) (map[apstra.ObjectId]apstra.SecurityZoneLoopback, *private.ResourceDatacenterRoutingZoneLoopbackAddresses) { // API response will allow us to determine interface IDs from system IDs szInfo, err := bp.GetSecurityZoneInfo(ctx, apstra.ObjectId(o.RoutingZoneId.ValueString())) if err != nil { @@ -71,15 +73,6 @@ func (o RoutingZoneLoopbacks) Request(ctx context.Context, bp *apstra.TwoStageL3 return nil, nil } - // extract private state (previously configured loopbacks) - var previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses - if ps != nil { // ps will be nil prior to initial creation - previousLoopbackMap.LoadPrivateState(ctx, ps, diags) - if diags.HasError() { - return nil, nil - } - } - // we return these two maps resultMap := make(map[apstra.ObjectId]apstra.SecurityZoneLoopback, len(planLoopbackMap)) resultPrivate := make(private.ResourceDatacenterRoutingZoneLoopbackAddresses, len(planLoopbackMap)) diff --git a/apstra/export_test.go b/apstra/export_test.go index cef62fae..12ed93f7 100644 --- a/apstra/export_test.go +++ b/apstra/export_test.go @@ -25,6 +25,7 @@ var ( ResourceDatacenterIpLinkAddressing = resourceDatacenterIpLinkAddressing{} ResourceDatacenterRoutingZone = resourceDatacenterRoutingZone{} ResourceDatacenterRoutingZoneConstraint = resourceDatacenterRoutingZoneConstraint{} + ResourceDatacenterRoutingZoneLoopbackAddresses = resourceDatacenterRoutingZoneLoopbackAddresses{} ResourceDatacenterVirtualNetwork = resourceDatacenterVirtualNetwork{} ResourceFreeformAllocGroup = resourceFreeformAllocGroup{} ResourceFreeformBlueprint = resourceFreeformBlueprint{} diff --git a/apstra/resource_datacenter_routing_zone_loopback_addresses.go b/apstra/resource_datacenter_routing_zone_loopback_addresses.go index b49bb4e7..66f15c2b 100644 --- a/apstra/resource_datacenter_routing_zone_loopback_addresses.go +++ b/apstra/resource_datacenter_routing_zone_loopback_addresses.go @@ -3,12 +3,15 @@ package tfapstra import ( "context" "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" "github.com/Juniper/apstra-go-sdk/apstra/compatibility" "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" + "github.com/Juniper/terraform-provider-apstra/apstra/private" "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 ( @@ -37,11 +40,11 @@ func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Schema(_ context.Contex "Note that the loopback addresses within the `default` routing zone can also be configured using the "+ "`apstra_datacenter_device_allocation` resource. Configuring interfaces using both resources can lead to "+ "configuration churn.\n\n"+ - "Note that loopback addresses can only be configured on Systems *actively participating* in the Routing "+ - "Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf "+ - "Switch. The Terraform project must be structured carefully to ensure that those bindings are created before "+ - "this resource is created.\n\n"+ - " Requires Apstra %s.", compatibility.SecurityZoneLoopbackApiSupported), + "Note that loopback addresses can only be configured on Systems *actively participating* in the Given "+ + "Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to "+ + "the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist "+ + "before this resource is created or updated.\n\n"+ + "Requires Apstra %s.", compatibility.SecurityZoneLoopbackApiSupported), Attributes: blueprint.RoutingZoneLoopbacks{}.ResourceAttributes(), } } @@ -132,13 +135,115 @@ func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Read(ctx context.Contex } func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - //TODO implement me - panic("implement me") + var plan blueprint.RoutingZoneLoopbacks + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // get a client for the datacenter reference design + bp, err := o.getBpClientFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + if utils.IsApstra404(err) { + resp.Diagnostics.AddError(fmt.Sprintf("blueprint %s not found", plan.BlueprintId), err.Error()) + return + } + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + // Lock the blueprint mutex. + err = o.lockFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("error locking blueprint %q mutex", plan.BlueprintId.ValueString()), + err.Error()) + return + } + + // extract private state (previously configured loopbacks) + var previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses + previousLoopbackMap.LoadPrivateState(ctx, req.Private, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // create an API request + request, ps := plan.Request(ctx, bp, previousLoopbackMap, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // record assignments to private state + ps.SetPrivateState(ctx, resp.Private, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // set loopback addresses + err = bp.SetSecurityZoneLoopbacks(ctx, apstra.ObjectId(plan.RoutingZoneId.ValueString()), request) + if err != nil { + resp.Diagnostics.AddError("failed to set loopback addresses", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - //TODO implement me - panic("implement me") + var state blueprint.RoutingZoneLoopbacks + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // create a plan with empty loopback address map + plan := blueprint.RoutingZoneLoopbacks{ + BlueprintId: state.BlueprintId, + RoutingZoneId: state.RoutingZoneId, + Loopbacks: types.MapNull(types.ObjectType{AttrTypes: blueprint.RoutingZoneLoopback{}.AttrTypes()}), + } + + // get a client for the datacenter reference design + bp, err := o.getBpClientFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + if utils.IsApstra404(err) { + return // 404 is okay + } + resp.Diagnostics.AddError("failed to create blueprint client", err.Error()) + return + } + + // Lock the blueprint mutex. + err = o.lockFunc(ctx, plan.BlueprintId.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("error locking blueprint %q mutex", plan.BlueprintId.ValueString()), + err.Error()) + return + } + + // extract private state (previously configured loopbacks) + var previousLoopbackMap private.ResourceDatacenterRoutingZoneLoopbackAddresses + previousLoopbackMap.LoadPrivateState(ctx, req.Private, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // create an API request + request, _ := plan.Request(ctx, bp, previousLoopbackMap, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // clear loopback addresses + err = bp.SetSecurityZoneLoopbacks(ctx, apstra.ObjectId(plan.RoutingZoneId.ValueString()), request) + if err != nil { + resp.Diagnostics.AddError("failed to set loopback addresses", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setBpClientFunc(f func(context.Context, string) (*apstra.TwoStageL3ClosClient, error)) { diff --git a/apstra/resource_datacenter_routing_zone_loopback_addresses_integration_test.go b/apstra/resource_datacenter_routing_zone_loopback_addresses_integration_test.go new file mode 100644 index 00000000..e9a88546 --- /dev/null +++ b/apstra/resource_datacenter_routing_zone_loopback_addresses_integration_test.go @@ -0,0 +1,350 @@ +//go:build integration + +package tfapstra_test + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/apstra-go-sdk/apstra/compatibility" + "github.com/Juniper/apstra-go-sdk/apstra/enum" + tfapstra "github.com/Juniper/terraform-provider-apstra/apstra" + testutils "github.com/Juniper/terraform-provider-apstra/apstra/test_utils" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/stretchr/testify/require" +) + +const resourceDatacenterRoutingZoneLoopbackAddressHCL = `{ + ipv4_addr = %s + ipv6_addr = %s + } +` + +type resourceDatacenterRoutingZoneLoopbackAddress struct { + iPv4Addr string + iPv6Addr string +} + +func (o resourceDatacenterRoutingZoneLoopbackAddress) render() string { + return fmt.Sprintf(resourceDatacenterRoutingZoneLoopbackAddressHCL, + stringOrNull(o.iPv4Addr), + stringOrNull(o.iPv6Addr), + ) +} + +const resourceDatacenterRoutingZoneLoopbackAddressesHCL = `resource %q %q { + blueprint_id = %q + routing_zone_id = %q + loopbacks = %s +} +` + +type resourceDatacenterRoutingZoneLoopbackAddresses struct { + blueprintID string + routingZoneID string + loopbacks map[string]resourceDatacenterRoutingZoneLoopbackAddress +} + +func (o resourceDatacenterRoutingZoneLoopbackAddresses) render(rType, rName string) string { + var sb strings.Builder + sb.WriteString("{\n") + for k, v := range o.loopbacks { + sb.WriteString(fmt.Sprintf(" %q = %s", k, v.render())) + } + sb.WriteString(" }") + + return fmt.Sprintf(resourceDatacenterRoutingZoneLoopbackAddressesHCL, + rType, rName, + o.blueprintID, + o.routingZoneID, + sb.String(), + ) +} + +func (o resourceDatacenterRoutingZoneLoopbackAddresses) testChecks(t testing.TB, bpId apstra.ObjectId, rType, rName string) testChecks { + result := newTestChecks(rType + "." + rName) + + // required and computed attributes can always be checked + result.append(t, "TestCheckResourceAttr", "blueprint_id", o.blueprintID) + result.append(t, "TestCheckResourceAttr", "routing_zone_id", o.routingZoneID) + + result.append(t, "TestCheckResourceAttr", "loopbacks.%", strconv.Itoa(len(o.loopbacks))) + for k, v := range o.loopbacks { + if v.iPv4Addr != "" { + result.append(t, "TestCheckResourceAttr", fmt.Sprintf("loopbacks.%s.ipv4_addr", k), v.iPv4Addr) + } else { + result.append(t, "TestCheckNoResourceAttr", fmt.Sprintf("loopbacks.%s.ipv4_addr", k)) + } + + if v.iPv6Addr != "" { + result.append(t, "TestCheckResourceAttr", fmt.Sprintf("loopbacks.%s.ipv6_addr", k), v.iPv6Addr) + } else { + result.append(t, "TestCheckNoResourceAttr", fmt.Sprintf("loopbacks.%s.ipv6_addr", k)) + } + } + + return result +} + +func TestResourceDatacenterRoutingZoneLoopbackAddresses(t *testing.T) { + ctx := context.Background() + cleanup := true + + client := testutils.GetTestClient(t, ctx) + if !compatibility.SecurityZoneLoopbackApiSupported.Check(version.Must(version.NewVersion(client.ApiVersion()))) { + t.Skipf("skipping test due to version %s", client.ApiVersion()) + } + + // create a blueprint + bp := testutils.BlueprintG(t, ctx, cleanup) + + // enable ipv6 + settings, err := bp.GetFabricSettings(ctx) + require.NoError(t, err) + settings.Ipv6Enabled = utils.ToPtr(true) + require.NoError(t, bp.SetFabricSettings(ctx, settings)) + + // create a routing zone + rzLabel := acctest.RandString(6) + rzId, err := bp.CreateSecurityZone(ctx, &apstra.SecurityZoneData{ + Label: rzLabel, + SzType: apstra.SecurityZoneTypeEVPN, + VrfName: rzLabel, + }) + require.NoError(t, err) + + // discover leaf switch IDs + leafs := testutils.GetSystemIds(t, ctx, bp, "leaf") + + // create a VN to ensure the leaf switches participate in the RZ + vnBindings := make([]apstra.VnBinding, len(leafs)) + var i int + for _, leafId := range leafs { + vnBindings[i] = apstra.VnBinding{SystemId: leafId} + i++ + } + _, err = bp.CreateVirtualNetwork(ctx, &apstra.VirtualNetworkData{ + Label: acctest.RandString(6), + SecurityZoneId: rzId, + VnType: enum.VnTypeVxlan, + VnBindings: vnBindings, + }) + require.NoError(t, err) + + // make a slice of leafIds + i = 0 + leafIds := make([]apstra.ObjectId, len(leafs)) + for _, v := range leafs { + leafIds[i] = v + i++ + } + + type testStep struct { + config resourceDatacenterRoutingZoneLoopbackAddresses + } + + type testCase struct { + steps []testStep + versionConstraints version.Constraints + } + + testCases := map[string]testCase{ + "empty_populated_empty": { + steps: []testStep{ + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[0].String(): { + iPv4Addr: "", + iPv6Addr: "", + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[0].String(): { + iPv4Addr: utils.ToPtr(randomPrefix(t, "10.1.0.0/16", 32)).String(), + iPv6Addr: utils.ToPtr(randomPrefix(t, "3fff:1::/32", 128)).String(), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[0].String(): { + iPv4Addr: "", + iPv6Addr: "", + }, + }, + }}, + }, + }, + "populated_empty_populated": { + steps: []testStep{ + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[1].String(): { + iPv4Addr: utils.ToPtr(randomPrefix(t, "10.2.0.0/16", 32)).String(), + iPv6Addr: utils.ToPtr(randomPrefix(t, "3fff:2::/32", 128)).String(), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[1].String(): { + iPv4Addr: "", + iPv6Addr: "", + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[1].String(): { + iPv4Addr: utils.ToPtr(randomPrefix(t, "10.2.0.0/16", 32)).String(), + iPv6Addr: utils.ToPtr(randomPrefix(t, "3fff:2::/32", 128)).String(), + }, + }, + }}, + }, + }, + "two_leafs_at_once_random_values": { + steps: []testStep{ + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + {config: resourceDatacenterRoutingZoneLoopbackAddresses{ + blueprintID: bp.Id().String(), + routingZoneID: rzId.String(), + loopbacks: map[string]resourceDatacenterRoutingZoneLoopbackAddress{ + leafIds[2].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.30.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:30::/32", 128)).String(), ""), + }, + leafIds[3].String(): { + iPv4Addr: oneOf(utils.ToPtr(randomPrefix(t, "10.31.0.0/16", 32)).String(), ""), + iPv6Addr: oneOf(utils.ToPtr(randomPrefix(t, "3fff:31::/32", 128)).String(), ""), + }, + }, + }}, + }, + }, + } + + resourceType := tfapstra.ResourceName(ctx, &tfapstra.ResourceDatacenterRoutingZoneLoopbackAddresses) + + for tName, tCase := range testCases { + t.Run(tName, func(t *testing.T) { + t.Parallel() + + if !tCase.versionConstraints.Check(version.Must(version.NewVersion(bp.Client().ApiVersion()))) { + t.Skipf("test case %s requires Apstra %s", tName, tCase.versionConstraints.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, bp.Id(), 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/resources/datacenter_routing_zone_loopback_addresses.md b/docs/resources/datacenter_routing_zone_loopback_addresses.md new file mode 100644 index 00000000..0e5a87f8 --- /dev/null +++ b/docs/resources/datacenter_routing_zone_loopback_addresses.md @@ -0,0 +1,64 @@ +--- +page_title: "apstra_datacenter_routing_zone_loopback_addresses Resource - terraform-provider-apstra" +subcategory: "Reference Design: Datacenter" +description: |- + This resource configures loopback addresses of switch nodes in a Datacenter Blueprint. + Note that the loopback addresses within the default routing zone can also be configured using the apstra_datacenter_device_allocation resource. Configuring interfaces using both resources can lead to configuration churn. + Note that loopback addresses can only be configured on Systems actively participating in the Given Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist before this resource is created or updated. + Requires Apstra >=5.0.0. +--- + +# apstra_datacenter_routing_zone_loopback_addresses (Resource) + +This resource configures loopback addresses of *switch* nodes in a Datacenter Blueprint. + +Note that the loopback addresses within the `default` routing zone can also be configured using the `apstra_datacenter_device_allocation` resource. Configuring interfaces using both resources can lead to configuration churn. + +Note that loopback addresses can only be configured on Systems *actively participating* in the Given Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist before this resource is created or updated. + +Requires Apstra >=5.0.0. + + +## Example Usage + +```terraform +# This example sets the IPv4 and IPv6 loopback addresses for two leaf switches +# in the prescribed blueprint and routing zone. +# +# Note that in this case, the map keys are references, so they must be +# wrapped in parentheses. +resource "apstra_datacenter_routing_zone_loopback_addresses" "example" { + blueprint_id = local.blueprint_id + routing_zone_id = local.routing_zone_id + loopbacks = { + (local.leaf_1_id) = { + ipv4_addr = "192.0.2.1/32" + ipv6_addr = "3fff::1/128" + } + (local.leaf_2_id) = { + ipv4_addr = "192.0.2.2/32" + ipv6_addr = "3fff::2/128" + } + } +} +``` + + +## Schema + +### Required + +- `blueprint_id` (String) Apstra Blueprint ID. +- `loopbacks` (Attributes Map) Map of Loopback IPv4 and IPv6 addresses, keyed by System Node ID. (see [below for nested schema](#nestedatt--loopbacks)) +- `routing_zone_id` (String) Routing Zone ID. + + +### Nested Schema for `loopbacks` + +Optional: + +- `ipv4_addr` (String) The IPv4 address to be assigned within the Routing Zone, in CIDR notation. +- `ipv6_addr` (String) The IPv6 address to be assigned within the Routing Zone, in CIDR notation. + + + diff --git a/examples/resources/apstra_datacenter_routing_zone_loopback_addresses/example.tf b/examples/resources/apstra_datacenter_routing_zone_loopback_addresses/example.tf new file mode 100644 index 00000000..0396cad1 --- /dev/null +++ b/examples/resources/apstra_datacenter_routing_zone_loopback_addresses/example.tf @@ -0,0 +1,19 @@ +# This example sets the IPv4 and IPv6 loopback addresses for two leaf switches +# in the prescribed blueprint and routing zone. +# +# Note that in this case, the map keys are references, so they must be +# wrapped in parentheses. +resource "apstra_datacenter_routing_zone_loopback_addresses" "example" { + blueprint_id = local.blueprint_id + routing_zone_id = local.routing_zone_id + loopbacks = { + (local.leaf_1_id) = { + ipv4_addr = "192.0.2.1/32" + ipv6_addr = "3fff::1/128" + } + (local.leaf_2_id) = { + ipv4_addr = "192.0.2.2/32" + ipv6_addr = "3fff::2/128" + } + } +} From a82424ebdb875b510918d317128cf4957fd0f0ac Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Thu, 13 Feb 2025 08:37:09 -0500 Subject: [PATCH 3/5] gofumpt --- apstra/private/resource_routing_zone_loopback_addresses.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apstra/private/resource_routing_zone_loopback_addresses.go b/apstra/private/resource_routing_zone_loopback_addresses.go index c1f6356b..8b06104f 100644 --- a/apstra/private/resource_routing_zone_loopback_addresses.go +++ b/apstra/private/resource_routing_zone_loopback_addresses.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" ) From 4292ad84ff7f9e3e89d03dd68392403540e5eeb6 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Thu, 13 Feb 2025 10:54:50 -0500 Subject: [PATCH 4/5] update docs; add version compatibility check --- ...acenter_routing_zone_loopback_addresses.go | 46 ++++++++++++++----- ...acenter_routing_zone_loopback_addresses.md | 12 ++--- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apstra/resource_datacenter_routing_zone_loopback_addresses.go b/apstra/resource_datacenter_routing_zone_loopback_addresses.go index 66f15c2b..61d48e71 100644 --- a/apstra/resource_datacenter_routing_zone_loopback_addresses.go +++ b/apstra/resource_datacenter_routing_zone_loopback_addresses.go @@ -7,20 +7,25 @@ import ( "github.com/Juniper/apstra-go-sdk/apstra" "github.com/Juniper/apstra-go-sdk/apstra/compatibility" "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" + "github.com/Juniper/terraform-provider-apstra/apstra/constants" "github.com/Juniper/terraform-provider-apstra/apstra/private" "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/go-version" "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 = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) - _ resourceWithSetDcBpClientFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) - _ resourceWithSetBpLockFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resource.ResourceWithConfigure = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resource.ResourceWithValidateConfig = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resourceWithSetClient = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resourceWithSetDcBpClientFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) + _ resourceWithSetBpLockFunc = (*resourceDatacenterRoutingZoneLoopbackAddresses)(nil) ) type resourceDatacenterRoutingZoneLoopbackAddresses struct { + client *apstra.Client getBpClientFunc func(context.Context, string) (*apstra.TwoStageL3ClosClient, error) lockFunc func(context.Context, string) error } @@ -35,20 +40,33 @@ func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Configure(ctx context.C func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: docCategoryDatacenter + fmt.Sprintf("This resource configures loopback addresses "+ - "of *switch* nodes in a Datacenter Blueprint.\n\n"+ - "Note that the loopback addresses within the `default` routing zone can also be configured using the "+ - "`apstra_datacenter_device_allocation` resource. Configuring interfaces using both resources can lead to "+ - "configuration churn.\n\n"+ - "Note that loopback addresses can only be configured on Systems *actively participating* in the Given "+ - "Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to "+ - "the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist "+ - "before this resource is created or updated.\n\n"+ + MarkdownDescription: docCategoryDatacenter + fmt.Sprintf("This resource configures loopback interface "+ + "addresses of *switch* nodes in a Datacenter Blueprint.\n\n"+ + "Note that the loopback interface addresses within the `default` routing zone can also be configured "+ + "using the `apstra_datacenter_device_allocation` resource. Configuring loopback interface addresses using "+ + "both resources can lead to configuration churn, and should be avoided.\n\n"+ + "Note that loopback interface addresses can only be configured on switches *actively participating* in "+ + "the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation "+ + "requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform "+ + "project must be structured to ensure that those bindings exist before this resource is created or updated.\n\n"+ "Requires Apstra %s.", compatibility.SecurityZoneLoopbackApiSupported), Attributes: blueprint.RoutingZoneLoopbacks{}.ResourceAttributes(), } } +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) ValidateConfig(_ context.Context, _ resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + if o.client == nil { + return + } + + if !compatibility.SecurityZoneLoopbackApiSupported.Check(version.Must(version.NewVersion(o.client.ApiVersion()))) { + resp.Diagnostics.AddError( + constants.ErrInvalidConfig, + "this resource requires Apstra "+compatibility.SecurityZoneLoopbackApiSupported.String(), + ) + } +} + func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var plan blueprint.RoutingZoneLoopbacks resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) @@ -253,3 +271,7 @@ func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setBpClientFunc(f func( func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setBpLockFunc(f func(context.Context, string) error) { o.lockFunc = f } + +func (o *resourceDatacenterRoutingZoneLoopbackAddresses) setClient(client *apstra.Client) { + o.client = client +} diff --git a/docs/resources/datacenter_routing_zone_loopback_addresses.md b/docs/resources/datacenter_routing_zone_loopback_addresses.md index 0e5a87f8..92bec92d 100644 --- a/docs/resources/datacenter_routing_zone_loopback_addresses.md +++ b/docs/resources/datacenter_routing_zone_loopback_addresses.md @@ -2,19 +2,19 @@ page_title: "apstra_datacenter_routing_zone_loopback_addresses Resource - terraform-provider-apstra" subcategory: "Reference Design: Datacenter" description: |- - This resource configures loopback addresses of switch nodes in a Datacenter Blueprint. - Note that the loopback addresses within the default routing zone can also be configured using the apstra_datacenter_device_allocation resource. Configuring interfaces using both resources can lead to configuration churn. - Note that loopback addresses can only be configured on Systems actively participating in the Given Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist before this resource is created or updated. + This resource configures loopback interface addresses of switch nodes in a Datacenter Blueprint. + Note that the loopback interface addresses within the default routing zone can also be configured using the apstra_datacenter_device_allocation resource. Configuring loopback interface addresses using both resources can lead to configuration churn, and should be avoided. + Note that loopback interface addresses can only be configured on switches actively participating in the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform project must be structured to ensure that those bindings exist before this resource is created or updated. Requires Apstra >=5.0.0. --- # apstra_datacenter_routing_zone_loopback_addresses (Resource) -This resource configures loopback addresses of *switch* nodes in a Datacenter Blueprint. +This resource configures loopback interface addresses of *switch* nodes in a Datacenter Blueprint. -Note that the loopback addresses within the `default` routing zone can also be configured using the `apstra_datacenter_device_allocation` resource. Configuring interfaces using both resources can lead to configuration churn. +Note that the loopback interface addresses within the `default` routing zone can also be configured using the `apstra_datacenter_device_allocation` resource. Configuring loopback interface addresses using both resources can lead to configuration churn, and should be avoided. -Note that loopback addresses can only be configured on Systems *actively participating* in the Given Routing Zone. For leaf switches, this means a Virtual Network belonging to the Routing Zone is bound to the Leaf Switch. The Terraform project must be structured carefully to ensure that those bindings exist before this resource is created or updated. +Note that loopback interface addresses can only be configured on switches *actively participating* in the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform project must be structured to ensure that those bindings exist before this resource is created or updated. Requires Apstra >=5.0.0. From c68a573e024a8ecd0524ded4efc47171e1e4eca6 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Thu, 13 Feb 2025 18:19:44 -0500 Subject: [PATCH 5/5] update doc strings --- apstra/blueprint/routing_zone_loopbacks.go | 2 +- ...e_datacenter_routing_zone_loopback_addresses.go | 9 ++++++--- .../datacenter_routing_zone_loopback_addresses.md | 14 +++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apstra/blueprint/routing_zone_loopbacks.go b/apstra/blueprint/routing_zone_loopbacks.go index 1e3df113..24624f22 100644 --- a/apstra/blueprint/routing_zone_loopbacks.go +++ b/apstra/blueprint/routing_zone_loopbacks.go @@ -40,7 +40,7 @@ func (o RoutingZoneLoopbacks) ResourceAttributes() map[string]resourceSchema.Att PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, }, "loopbacks": resourceSchema.MapNestedAttribute{ - MarkdownDescription: "Map of Loopback IPv4 and IPv6 addresses, keyed by System Node ID.", + MarkdownDescription: "Map of Loopback IPv4 and IPv6 addresses, keyed by System (switch) Node ID.", Required: true, NestedObject: resourceSchema.NestedAttributeObject{ Attributes: RoutingZoneLoopback{}.ResourceAttributes(), diff --git a/apstra/resource_datacenter_routing_zone_loopback_addresses.go b/apstra/resource_datacenter_routing_zone_loopback_addresses.go index 61d48e71..b9dfc9f0 100644 --- a/apstra/resource_datacenter_routing_zone_loopback_addresses.go +++ b/apstra/resource_datacenter_routing_zone_loopback_addresses.go @@ -46,9 +46,12 @@ func (o *resourceDatacenterRoutingZoneLoopbackAddresses) Schema(_ context.Contex "using the `apstra_datacenter_device_allocation` resource. Configuring loopback interface addresses using "+ "both resources can lead to configuration churn, and should be avoided.\n\n"+ "Note that loopback interface addresses can only be configured on switches *actively participating* in "+ - "the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation "+ - "requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform "+ - "project must be structured to ensure that those bindings exist before this resource is created or updated.\n\n"+ + "the given Routing Zone. Leaf Switch participation in non-default Routing Zone requires one of these:\n\n"+ + " - A Virtual Network in the Routing Zone is bound to the switch\n"+ + " - A Connectivity Template with an IP Link primitive or routing information for the Routing Zones is assigned to the switch\n"+ + " - The switch is acting as a DCI gateway for the Routing Zone.\n\n"+ + "The Terraform project must be structured to ensure Routing Zone participation by switches mentioned in "+ + "this resource before the resource is created or updated.\n\n"+ "Requires Apstra %s.", compatibility.SecurityZoneLoopbackApiSupported), Attributes: blueprint.RoutingZoneLoopbacks{}.ResourceAttributes(), } diff --git a/docs/resources/datacenter_routing_zone_loopback_addresses.md b/docs/resources/datacenter_routing_zone_loopback_addresses.md index 92bec92d..90aee58f 100644 --- a/docs/resources/datacenter_routing_zone_loopback_addresses.md +++ b/docs/resources/datacenter_routing_zone_loopback_addresses.md @@ -4,7 +4,9 @@ subcategory: "Reference Design: Datacenter" description: |- This resource configures loopback interface addresses of switch nodes in a Datacenter Blueprint. Note that the loopback interface addresses within the default routing zone can also be configured using the apstra_datacenter_device_allocation resource. Configuring loopback interface addresses using both resources can lead to configuration churn, and should be avoided. - Note that loopback interface addresses can only be configured on switches actively participating in the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform project must be structured to ensure that those bindings exist before this resource is created or updated. + Note that loopback interface addresses can only be configured on switches actively participating in the given Routing Zone. Leaf Switch participation in non-default Routing Zone requires one of these: + A Virtual Network in the Routing Zone is bound to the switchA Connectivity Template with an IP Link primitive or routing information for the Routing Zones is assigned to the switchThe switch is acting as a DCI gateway for the Routing Zone. + The Terraform project must be structured to ensure Routing Zone participation by switches mentioned in this resource before the resource is created or updated. Requires Apstra >=5.0.0. --- @@ -14,7 +16,13 @@ This resource configures loopback interface addresses of *switch* nodes in a Dat Note that the loopback interface addresses within the `default` routing zone can also be configured using the `apstra_datacenter_device_allocation` resource. Configuring loopback interface addresses using both resources can lead to configuration churn, and should be avoided. -Note that loopback interface addresses can only be configured on switches *actively participating* in the given Routing Zone. For Leaf Switch loopback interfaces in non-default Routing Zones, participation requires that a Virtual Network belonging to the Routing Zone be bound to the Switch. The Terraform project must be structured to ensure that those bindings exist before this resource is created or updated. +Note that loopback interface addresses can only be configured on switches *actively participating* in the given Routing Zone. Leaf Switch participation in non-default Routing Zone requires one of these: + + - A Virtual Network in the Routing Zone is bound to the switch + - A Connectivity Template with an IP Link primitive or routing information for the Routing Zones is assigned to the switch + - The switch is acting as a DCI gateway for the Routing Zone. + +The Terraform project must be structured to ensure Routing Zone participation by switches mentioned in this resource before the resource is created or updated. Requires Apstra >=5.0.0. @@ -49,7 +57,7 @@ resource "apstra_datacenter_routing_zone_loopback_addresses" "example" { ### Required - `blueprint_id` (String) Apstra Blueprint ID. -- `loopbacks` (Attributes Map) Map of Loopback IPv4 and IPv6 addresses, keyed by System Node ID. (see [below for nested schema](#nestedatt--loopbacks)) +- `loopbacks` (Attributes Map) Map of Loopback IPv4 and IPv6 addresses, keyed by System (switch) Node ID. (see [below for nested schema](#nestedatt--loopbacks)) - `routing_zone_id` (String) Routing Zone ID.