From 07b00943010d69c09feebf44af68b17ab5e88b5c Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Thu, 2 Nov 2023 22:37:58 -0400 Subject: [PATCH 1/9] introduce resource apstra_datacenter_external_gateway (needs tests) --- Third_Party_Code/NOTICES.md | 20 +- .../blueprint/datacenter_external_gateway.go | 171 +++++++++++++ apstra/custom_types/ipv46_address_type.go | 137 +++++++++++ .../custom_types/ipv46_address_type_test.go | 185 ++++++++++++++ apstra/custom_types/ipv46_address_value.go | 102 ++++++++ .../custom_types/ipv46_address_value_test.go | 213 ++++++++++++++++ apstra/helpers.go | 29 +++ apstra/helpers_test.go | 92 +++++++ .../resource_datacenter_external_gateway.go | 230 ++++++++++++++++++ go.mod | 18 +- go.sum | 35 +-- 11 files changed, 1196 insertions(+), 36 deletions(-) create mode 100644 apstra/blueprint/datacenter_external_gateway.go create mode 100644 apstra/custom_types/ipv46_address_type.go create mode 100644 apstra/custom_types/ipv46_address_type_test.go create mode 100644 apstra/custom_types/ipv46_address_value.go create mode 100644 apstra/custom_types/ipv46_address_value_test.go create mode 100644 apstra/resource_datacenter_external_gateway.go diff --git a/Third_Party_Code/NOTICES.md b/Third_Party_Code/NOTICES.md index e8e33aa5..1e974161 100644 --- a/Third_Party_Code/NOTICES.md +++ b/Third_Party_Code/NOTICES.md @@ -5212,8 +5212,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/crypto * Name: golang.org/x/crypto -* Version: v0.10.0 -* License: [BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.10.0:LICENSE) +* Version: v0.14.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.14.0:LICENSE) ``` Copyright (c) 2009 The Go Authors. All rights reserved. @@ -5249,8 +5249,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/exp/constraints * Name: golang.org/x/exp/constraints -* Version: v0.0.0-20230713183714-613f0c0eb8a1 -* License: [BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/613f0c0e:LICENSE) +* Version: v0.0.0-20231006140011-7918f672742d +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/7918f672:LICENSE) ``` Copyright (c) 2009 The Go Authors. All rights reserved. @@ -5286,8 +5286,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/net * Name: golang.org/x/net -* Version: v0.11.0 -* License: [BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.11.0:LICENSE) +* Version: v0.16.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.16.0:LICENSE) ``` Copyright (c) 2009 The Go Authors. All rights reserved. @@ -5323,8 +5323,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/sys/unix * Name: golang.org/x/sys/unix -* Version: v0.9.0 -* License: [BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.9.0:LICENSE) +* Version: v0.13.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.13.0:LICENSE) ``` Copyright (c) 2009 The Go Authors. All rights reserved. @@ -5360,8 +5360,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ## golang.org/x/text * Name: golang.org/x/text -* Version: v0.10.0 -* License: [BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.10.0:LICENSE) +* Version: v0.13.0 +* License: [BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.13.0:LICENSE) ``` Copyright (c) 2009 The Go Authors. All rights reserved. diff --git a/apstra/blueprint/datacenter_external_gateway.go b/apstra/blueprint/datacenter_external_gateway.go new file mode 100644 index 00000000..6e3289df --- /dev/null +++ b/apstra/blueprint/datacenter_external_gateway.go @@ -0,0 +1,171 @@ +package blueprint + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + customtypes "github.com/Juniper/terraform-provider-apstra/apstra/custom_types" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "math" + "net" + "strings" +) + +type DatacenterExternalGateway struct { + Id types.String `tfsdk:"id"` + BlueprintId types.String `tfsdk:"blueprint_id"` + Name types.String `tfsdk:"name"` + IpAddress customtypes.IPv46Address `tfsdk:"ip_address"` + Asn types.Int64 `tfsdk:"asn"` + Ttl types.Int64 `tfsdk:"ttl"` + KeepaliveTime types.Int64 `tfsdk:"keepalive_time"` + HoldTime types.Int64 `tfsdk:"hold_time"` + EvpnRouteTypes types.String `tfsdk:"evpn_route_types"` + LocalGatewayNodes types.Set `tfsdk:"local_gateway_nodes"` +} + +func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchema.Attribute { + return map[string]resourceSchema.Attribute{ + "id": resourceSchema.StringAttribute{ + MarkdownDescription: "Apstra Object ID.", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "blueprint_id": resourceSchema.StringAttribute{ + MarkdownDescription: "Apstra ID of the Blueprint in which the External Gateway should be created.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "name": resourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway name", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "ip_address": resourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway IP address", + Required: true, + CustomType: customtypes.IPv46AddressType{}, + }, + "asn": resourceSchema.Int64Attribute{ + MarkdownDescription: "External Gateway AS Number", + Required: true, + Validators: []validator.Int64{int64validator.Between(1, int64(math.MaxUint32))}, + }, + "ttl": resourceSchema.Int64Attribute{ + MarkdownDescription: "BGP Time To Live. Omit to use device defaults.", + Optional: true, + Computed: true, + Validators: []validator.Int64{int64validator.Between(2, int64(math.MaxUint8))}, + }, + "keepalive_time": resourceSchema.Int64Attribute{ + MarkdownDescription: "BGP keepalive time (seconds).", + Optional: true, + Computed: true, + Validators: []validator.Int64{int64validator.Between(1, int64(math.MaxUint16))}, + }, + "hold_time": resourceSchema.Int64Attribute{ + MarkdownDescription: "BGP hold time (seconds).", + Optional: true, + Computed: true, + Validators: []validator.Int64{int64validator.Between(3, int64(math.MaxUint16))}, + }, + "evpn_route_types": resourceSchema.StringAttribute{ + MarkdownDescription: fmt.Sprintf(`EVPN route types. Valid values are: ["%s"]. Default: %q`, + strings.Join(apstra.RemoteGatewayRouteTypesEnum.Values(), `", "`), + apstra.RemoteGatewayRouteTypesAll.Value), + Optional: true, + Computed: true, + Default: stringdefault.StaticString(apstra.RemoteGatewayRouteTypesAll.Value), + Validators: []validator.String{stringvalidator.OneOf(apstra.RemoteGatewayRouteTypesEnum.Values()...)}, + }, + "local_gateway_nodes": resourceSchema.SetAttribute{ + MarkdownDescription: "Set of IDs of switch nodes which will be configured to peer with the External Gateway", + Required: true, + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), + }, + }, + } +} + +func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Diagnostics) *apstra.RemoteGatewayData { + routeTypes := apstra.RemoteGatewayRouteTypesEnum.Parse(o.EvpnRouteTypes.ValueString()) + // skipping nil check because input validation should make that impossible + + var localGwNodes []apstra.ObjectId + diags.Append(o.LocalGatewayNodes.ElementsAs(ctx, &localGwNodes, false)...) + if diags.HasError() { + return nil + } + + ttl := uint8(o.Ttl.ValueInt64()) + keepaliveTimer := uint16(o.KeepaliveTime.ValueInt64()) + holdtimeTimer := uint16(o.HoldTime.ValueInt64()) + + return &apstra.RemoteGatewayData{ + RouteTypes: *routeTypes, + LocalGwNodes: localGwNodes, + GwAsn: uint32(o.Asn.ValueInt64()), + GwIp: net.ParseIP(o.IpAddress.ValueString()), // skipping nil check because input + GwName: o.Name.ValueString(), // validation should make that impossible + Ttl: &ttl, + KeepaliveTimer: &keepaliveTimer, + HoldtimeTimer: &holdtimeTimer, + } +} + +func (o *DatacenterExternalGateway) Read(ctx context.Context, bp *apstra.TwoStageL3ClosClient, diags *diag.Diagnostics) { + remoteGateway, err := bp.GetRemoteGateway(ctx, apstra.ObjectId(o.Id.ValueString())) + if err != nil { + diags.AddError("failed to fetch remote gateway", err.Error()) + return + } + + o.loadApiData(ctx, remoteGateway.Data, diags) + if diags.HasError() { + return + } +} + +func (o *DatacenterExternalGateway) loadApiData(_ context.Context, in *apstra.RemoteGatewayData, _ *diag.Diagnostics) { + ttl := types.Int64Null() + if in.Ttl != nil { + ttl = types.Int64Value(int64(*in.Ttl)) + } + + keepaliveTime := types.Int64Null() + if in.KeepaliveTimer != nil { + keepaliveTime = types.Int64Value(int64(*in.KeepaliveTimer)) + } + + holdTime := types.Int64Null() + if in.HoldtimeTimer != nil { + holdTime = types.Int64Value(int64(*in.HoldtimeTimer)) + } + + localGatewayNodes := make([]attr.Value, len(in.LocalGwNodes)) + for i, localGatewayNode := range in.LocalGwNodes { + localGatewayNodes[i] = types.StringValue(localGatewayNode.String()) + } + + o.Name = types.StringValue(in.GwName) + o.IpAddress = customtypes.NewIPv46AddressValue(in.GwIp.String()) + o.Asn = types.Int64Value(int64(in.GwAsn)) + o.Ttl = ttl + o.KeepaliveTime = keepaliveTime + o.HoldTime = holdTime + o.EvpnRouteTypes = types.StringValue(in.RouteTypes.Value) + o.LocalGatewayNodes = types.SetValueMust(types.StringType, localGatewayNodes) +} diff --git a/apstra/custom_types/ipv46_address_type.go b/apstra/custom_types/ipv46_address_type.go new file mode 100644 index 00000000..57f5e2c8 --- /dev/null +++ b/apstra/custom_types/ipv46_address_type.go @@ -0,0 +1,137 @@ +package customtypes + +import ( + "context" + "fmt" + "net/netip" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.StringTypable = (*IPv46AddressType)(nil) + _ xattr.TypeWithValidate = (*IPv46AddressType)(nil) +) + +type IPv46AddressType struct { + basetypes.StringType +} + +// String returns a human readable string of the type name. +func (t IPv46AddressType) String() string { + return "customtypes.IPv46AddressType" +} + +// ValueType returns the Value type. +func (t IPv46AddressType) ValueType(_ context.Context) attr.Value { + return IPv46Address{} +} + +// Equal returns true if the given type is equivalent. +func (t IPv46AddressType) Equal(o attr.Type) bool { + other, ok := o.(IPv46AddressType) + + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +func (t IPv46AddressType) Validate(_ context.Context, in tftypes.Value, path path.Path) diag.Diagnostics { + var diags diag.Diagnostics + + if in.Type() == nil { + return diags + } + + if !in.Type().Is(tftypes.String) { + err := fmt.Errorf("expected String value, received %T with value: %v", in, in) + diags.AddAttributeError( + path, + "IPv46 Address Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + return diags + } + + if !in.IsKnown() || in.IsNull() { + return diags + } + + var valueString string + + if err := in.As(&valueString); err != nil { + diags.AddAttributeError( + path, + "IPv46 Address Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. "+ + "Please report the following to the provider developer:\n\n"+err.Error(), + ) + + return diags + } + + ipAddr, err := netip.ParseAddr(valueString) + if err != nil { + diags.AddAttributeError( + path, + "Invalid IPv46 Address String Value", + "A string value was provided that is not valid IPv4 or IPv6 string format.\n\n"+ + "Given Value: "+valueString+"\n"+ + "Error: "+err.Error(), + ) + + return diags + } + + if !ipAddr.IsValid() && (!ipAddr.Is4() && !ipAddr.Is6()) { + diags.AddAttributeError( + path, + "Invalid IPv46 Address String Value", + "A string value was provided that is not valid IPv4 or IPv6 string format.\n\n"+ + "Given Value: "+valueString+"\n", + ) + + return diags + } + + return diags +} + +// ValueFromString returns a StringValuable type given a StringValue. +func (t IPv46AddressType) ValueFromString(_ context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) { + return IPv46Address{ + StringValue: in, + }, nil +} + +// ValueFromTerraform returns a Value given a tftypes.Value. This is meant to convert the tftypes.Value into a more convenient Go type +// for the provider to consume the data with. +func (t IPv46AddressType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} diff --git a/apstra/custom_types/ipv46_address_type_test.go b/apstra/custom_types/ipv46_address_type_test.go new file mode 100644 index 00000000..25866eff --- /dev/null +++ b/apstra/custom_types/ipv46_address_type_test.go @@ -0,0 +1,185 @@ +package customtypes_test + +import ( + "context" + customtypes "github.com/Juniper/terraform-provider-apstra/apstra/custom_types" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestIPv46AddressTypeValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in tftypes.Value + expectedDiags diag.Diagnostics + }{ + "empty-struct": { + in: tftypes.Value{}, + }, + "null": { + in: tftypes.NewValue(tftypes.String, nil), + }, + "unknown": { + in: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + }, + "valid IPv4 address - broadcast": { + in: tftypes.NewValue(tftypes.String, "255.255.255.255"), + }, + "valid IPv4 address - loopback": { + in: tftypes.NewValue(tftypes.String, "127.0.0.1"), + }, + "valid IPv4 address - multicast": { + in: tftypes.NewValue(tftypes.String, "224.1.2.3"), + }, + "valid IPv4 address - zeros": { + in: tftypes.NewValue(tftypes.String, "0.0.0.0"), + }, + "valid IPv6 address - unspecified": { + in: tftypes.NewValue(tftypes.String, "::"), + }, + "valid IPv6 address - full": { + in: tftypes.NewValue(tftypes.String, "1:2:3:4:5:6:7:8"), + }, + "valid IPv6 address - trailing double colon": { + in: tftypes.NewValue(tftypes.String, "FF01::"), + }, + "valid IPv6 address - leading double colon": { + in: tftypes.NewValue(tftypes.String, "::8:800:200C:417A"), + }, + "valid IPv6 address - middle double colon": { + in: tftypes.NewValue(tftypes.String, "2001:DB8::8:800:200C:417A"), + }, + "valid IPv6 address - lowercase": { + in: tftypes.NewValue(tftypes.String, "2001:db8::8:800:200c:417a"), + }, + "valid IPv6 address - IPv4-Mapped": { + in: tftypes.NewValue(tftypes.String, "::FFFF:192.168.255.255"), + }, + "valid IPv6 address - IPv4-Compatible": { + in: tftypes.NewValue(tftypes.String, "::127.0.0.1"), + }, + "invalid IPv6 address - invalid colon end": { + in: tftypes.NewValue(tftypes.String, "0:0:0:0:0:0:0:"), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid IPv46 Address String Value", + "A string value was provided that is not valid IPv4 or IPv6 string format.\n\n"+ + "Given Value: 0:0:0:0:0:0:0:\n"+ + "Error: ParseAddr(\"0:0:0:0:0:0:0:\"): colon must be followed by more characters (at \":\")", + ), + }, + }, + "invalid IPv6 address - too many colons": { + in: tftypes.NewValue(tftypes.String, "0:0::1::"), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid IPv46 Address String Value", + "A string value was provided that is not valid IPv4 or IPv6 string format.\n\n"+ + "Given Value: 0:0::1::\n"+ + "Error: ParseAddr(\"0:0::1::\"): multiple :: in address (at \":\")", + ), + }, + }, + "invalid IPv6 address - trailing numbers": { + in: tftypes.NewValue(tftypes.String, "0:0:0:0:0:0:0:1:99"), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Invalid IPv46 Address String Value", + "A string value was provided that is not valid IPv4 or IPv6 string format.\n\n"+ + "Given Value: 0:0:0:0:0:0:0:1:99\n"+ + "Error: ParseAddr(\"0:0:0:0:0:0:0:1:99\"): trailing garbage after address (at \"99\")", + ), + }, + }, + "wrong-value-type": { + in: tftypes.NewValue(tftypes.Number, 123), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "IPv46 Address Type Validation Error", + "An unexpected error was encountered trying to validate an attribute value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "expected String value, received tftypes.Value with value: tftypes.Number<\"123\">", + ), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := customtypes.IPv46AddressType{}.Validate(context.Background(), testCase.in, path.Root("test")) + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("Unexpected diagnostics (-got, +expected): %s", diff) + } + }) + } +} + +func TestIPv46AddressTypeValueFromTerraform(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in tftypes.Value + expectation attr.Value + expectedErr string + }{ + "true": { + in: tftypes.NewValue(tftypes.String, "FF01::101"), + expectation: customtypes.NewIPv46AddressValue("FF01::101"), + }, + "unknown": { + in: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + expectation: customtypes.NewIPv46AddressUnknown(), + }, + "null": { + in: tftypes.NewValue(tftypes.String, nil), + expectation: customtypes.NewIPv46AddressNull(), + }, + "wrongType": { + in: tftypes.NewValue(tftypes.Number, 123), + expectedErr: "can't unmarshal tftypes.Number into *string, expected string", + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + got, err := customtypes.IPv46AddressType{}.ValueFromTerraform(ctx, testCase.in) + if err != nil { + if testCase.expectedErr == "" { + t.Fatalf("Unexpected error: %s", err) + } + if testCase.expectedErr != err.Error() { + t.Fatalf("Expected error to be %q, got %q", testCase.expectedErr, err.Error()) + } + return + } + if err == nil && testCase.expectedErr != "" { + t.Fatalf("Expected error to be %q, didn't get an error", testCase.expectedErr) + } + if !got.Equal(testCase.expectation) { + t.Errorf("Expected %+v, got %+v", testCase.expectation, got) + } + if testCase.expectation.IsNull() != testCase.in.IsNull() { + t.Errorf("Expected null-ness match: expected %t, got %t", testCase.expectation.IsNull(), testCase.in.IsNull()) + } + if testCase.expectation.IsUnknown() != !testCase.in.IsKnown() { + t.Errorf("Expected unknown-ness match: expected %t, got %t", testCase.expectation.IsUnknown(), !testCase.in.IsKnown()) + } + }) + } +} diff --git a/apstra/custom_types/ipv46_address_value.go b/apstra/custom_types/ipv46_address_value.go new file mode 100644 index 00000000..17be2fb8 --- /dev/null +++ b/apstra/custom_types/ipv46_address_value.go @@ -0,0 +1,102 @@ +package customtypes + +import ( + "context" + "fmt" + "net/netip" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ basetypes.StringValuable = (*IPv46Address)(nil) + _ basetypes.StringValuableWithSemanticEquals = (*IPv46Address)(nil) +) + +type IPv46Address struct { + basetypes.StringValue +} + +func (v IPv46Address) Type(_ context.Context) attr.Type { + return IPv46AddressType{} +} + +func (v IPv46Address) Equal(o attr.Value) bool { + other, ok := o.(IPv46Address) + + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +func (v IPv46Address) StringSemanticEquals(_ context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) { + var diags diag.Diagnostics + + newValue, ok := newValuable.(IPv46Address) + if !ok { + diags.AddError( + "Semantic Equality Check Error", + "An unexpected value type was received while performing semantic equality checks. "+ + "Please report this to the provider developers.\n\n"+ + "Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+ + "Got Value Type: "+fmt.Sprintf("%T", newValuable), + ) + + return false, diags + } + + newIpAddr, _ := netip.ParseAddr(newValue.ValueString()) + currentIpAddr, _ := netip.ParseAddr(v.ValueString()) + + return currentIpAddr == newIpAddr, diags +} + +func (v IPv46Address) ValueIPv46Address() (netip.Addr, diag.Diagnostics) { + var diags diag.Diagnostics + + if v.IsNull() { + diags.Append(diag.NewErrorDiagnostic("IPv46Address ValueIPv46Address Error", "address string value is null")) + return netip.Addr{}, diags + } + + if v.IsUnknown() { + diags.Append(diag.NewErrorDiagnostic("IPv46Address ValueIPv46Address Error", "address string value is unknown")) + return netip.Addr{}, diags + } + + ipv46Addr, err := netip.ParseAddr(v.ValueString()) + if err != nil { + diags.Append(diag.NewErrorDiagnostic("IPv46Address ValueIPv46Address Error", err.Error())) + return netip.Addr{}, diags + } + + return ipv46Addr, nil +} + +func NewIPv46AddressNull() IPv46Address { + return IPv46Address{ + StringValue: basetypes.NewStringNull(), + } +} + +func NewIPv46AddressUnknown() IPv46Address { + return IPv46Address{ + StringValue: basetypes.NewStringUnknown(), + } +} + +func NewIPv46AddressValue(value string) IPv46Address { + return IPv46Address{ + StringValue: basetypes.NewStringValue(value), + } +} + +func NewIPv46AddressPointerValue(value *string) IPv46Address { + return IPv46Address{ + StringValue: basetypes.NewStringPointerValue(value), + } +} diff --git a/apstra/custom_types/ipv46_address_value_test.go b/apstra/custom_types/ipv46_address_value_test.go new file mode 100644 index 00000000..c7fc1820 --- /dev/null +++ b/apstra/custom_types/ipv46_address_value_test.go @@ -0,0 +1,213 @@ +package customtypes_test + +import ( + "context" + "net/netip" + "testing" + + "github.com/Juniper/terraform-provider-apstra/apstra/custom_types" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-nettypes/iptypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestIPv46AddressStringSemanticEquals(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + currentIpAddr customtypes.IPv46Address + givenIpAddr basetypes.StringValuable + expectedMatch bool + expectedDiags diag.Diagnostics + }{ + "not equal - IPv6 address mismatch": { + currentIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:0"), + givenIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:1"), + expectedMatch: false, + }, + "not equal - IPv6 address compressed mismatch": { + currentIpAddr: customtypes.NewIPv46AddressValue("FF01::"), + givenIpAddr: customtypes.NewIPv46AddressValue("FF01::1"), + expectedMatch: false, + }, + "not equal - IPv4-Mapped IPv6 address mismatch": { + currentIpAddr: customtypes.NewIPv46AddressValue("::FFFF:192.168.255.255"), + givenIpAddr: customtypes.NewIPv46AddressValue("::FFFF:192.168.255.254"), + expectedMatch: false, + }, + "semantically equal - byte-for-byte match": { + currentIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:0"), + givenIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:0"), + expectedMatch: true, + }, + "semantically equal - case insensitive": { + currentIpAddr: customtypes.NewIPv46AddressValue("2001:0DB8:0000:0000:0008:0800:0200C:417A"), + givenIpAddr: customtypes.NewIPv46AddressValue("2001:0db8:0000:0000:0008:0800:0200c:417a"), + expectedMatch: true, + }, + "semantically equal - IPv4-Mapped byte-for-byte match": { + currentIpAddr: customtypes.NewIPv46AddressValue("::FFFF:192.168.255.255"), + givenIpAddr: customtypes.NewIPv46AddressValue("::FFFF:192.168.255.255"), + expectedMatch: true, + }, + "semantically equal - compressed all zeroes match": { + currentIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:0"), + givenIpAddr: customtypes.NewIPv46AddressValue("::"), + expectedMatch: true, + }, + "semantically equal - compressed all leading zeroes match": { + currentIpAddr: customtypes.NewIPv46AddressValue("2001:0DB8:0000:0000:0008:0800:0200C:417A"), + givenIpAddr: customtypes.NewIPv46AddressValue("2001:DB8::8:800:200C:417A"), + expectedMatch: true, + }, + "semantically equal - start compressed match": { + currentIpAddr: customtypes.NewIPv46AddressValue("::101"), + givenIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:101"), + expectedMatch: true, + }, + "semantically equal - middle compressed match": { + currentIpAddr: customtypes.NewIPv46AddressValue("2001:DB8::8:800:200C:417A"), + givenIpAddr: customtypes.NewIPv46AddressValue("2001:DB8:0:0:8:800:200C:417A"), + expectedMatch: true, + }, + "semantically equal - end compressed match": { + currentIpAddr: customtypes.NewIPv46AddressValue("FF01:0:0:0:0:0:0:0"), + givenIpAddr: customtypes.NewIPv46AddressValue("FF01::"), + expectedMatch: true, + }, + "semantically equal - IPv4-Mapped compressed match": { + currentIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:FFFF:192.168.255.255"), + givenIpAddr: customtypes.NewIPv46AddressValue("::FFFF:192.168.255.255"), + expectedMatch: true, + }, + "error - not given IPv6Address IPv6 value": { + currentIpAddr: customtypes.NewIPv46AddressValue("0:0:0:0:0:0:0:0"), + givenIpAddr: basetypes.NewStringValue("0:0:0:0:0:0:0:0"), + expectedMatch: false, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Semantic Equality Check Error", + "An unexpected value type was received while performing semantic equality checks. "+ + "Please report this to the provider developers.\n\n"+ + "Expected Value Type: customtypes.IPv46Address\n"+ + "Got Value Type: basetypes.StringValue", + ), + }, + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + match, diags := testCase.currentIpAddr.StringSemanticEquals(context.Background(), testCase.givenIpAddr) + + if testCase.expectedMatch != match { + t.Errorf("Expected StringSemanticEquals to return: %t, but got: %t", testCase.expectedMatch, match) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("Unexpected diagnostics (-got, +expected): %s", diff) + } + }) + } +} + +func TestIPv46AddressValueIPv4Address(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + ipValue iptypes.IPv4Address + expectedIpAddr netip.Addr + expectedDiags diag.Diagnostics + }{ + "IPv4 address value is null ": { + ipValue: iptypes.NewIPv4AddressNull(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "IPv4Address ValueIPv4Address Error", + "IPv4 address string value is null", + ), + }, + }, + "IPv4 address value is unknown ": { + ipValue: iptypes.NewIPv4AddressUnknown(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "IPv4Address ValueIPv4Address Error", + "IPv4 address string value is unknown", + ), + }, + }, + "valid IPv4 address ": { + ipValue: iptypes.NewIPv4AddressValue("192.0.2.1"), + expectedIpAddr: netip.MustParseAddr("192.0.2.1"), + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ipAddr, diags := testCase.ipValue.ValueIPv4Address() + + if ipAddr != testCase.expectedIpAddr { + t.Errorf("Unexpected difference in netip.Addr, got: %s, expected: %s", ipAddr, testCase.expectedIpAddr) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("Unexpected diagnostics (-got, +expected): %s", diff) + } + }) + } +} + +func TestIPv6AddressValueIPv6Address(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + ipValue iptypes.IPv6Address + expectedIpAddr netip.Addr + expectedDiags diag.Diagnostics + }{ + "IPv6 address value is null ": { + ipValue: iptypes.NewIPv6AddressNull(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "IPv6Address ValueIPv6Address Error", + "IPv6 address string value is null", + ), + }, + }, + "IPv6 address value is unknown ": { + ipValue: iptypes.NewIPv6AddressUnknown(), + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "IPv6Address ValueIPv6Address Error", + "IPv6 address string value is unknown", + ), + }, + }, + "valid IPv6 address ": { + ipValue: iptypes.NewIPv6AddressValue("2001:DB8::8:800:200C:417A"), + expectedIpAddr: netip.MustParseAddr("2001:DB8::8:800:200C:417A"), + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ipAddr, diags := testCase.ipValue.ValueIPv6Address() + + if ipAddr != testCase.expectedIpAddr { + t.Errorf("Unexpected difference in netip.Addr, got: %s, expected: %s", ipAddr, testCase.expectedIpAddr) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("Unexpected diagnostics (-got, +expected): %s", diff) + } + }) + } +} diff --git a/apstra/helpers.go b/apstra/helpers.go index 3657e05c..5bd53b91 100644 --- a/apstra/helpers.go +++ b/apstra/helpers.go @@ -1,9 +1,12 @@ package tfapstra import ( + "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/diag" "golang.org/x/exp/constraints" "math/rand" + "strings" "unsafe" ) @@ -38,3 +41,29 @@ func FillWithRandomIntegers[A constraints.Integer](a []A) { a[i] = A(rand.Uint64()) } } + +// SplitImportId splits string 'in' into len(fieldNames) strings using the first character as +// a separator for the fields. fieldNames is used mainly for its length. The values in fieldNames +// are ignored unless there's a need to print an error message. +func SplitImportId(_ context.Context, in string, fieldNames []string, diags *diag.Diagnostics) []string { + if len(in) < 2 { + diags.AddError("invalid import ID", "import ID minimum length is 2") + return nil + } + + sep := in[:1] + parts := strings.Split(in[:], sep)[1:] + + if len(fieldNames) != len(parts) { + form := "<" + strings.Join(fieldNames, "><") + ">" + diags.AddError( + fmt.Sprintf("cannot parse import ID: %q", in), + fmt.Sprintf("ID string for resource import must take this form:\n\n"+ + " %s\n\n"+ + "where is any single character not found in any of the delimited fields. "+ + "Expected %d parts after splitting on '%s', got %d parts", form, len(fieldNames), sep, len(parts))) + return nil + } + + return parts +} diff --git a/apstra/helpers_test.go b/apstra/helpers_test.go index 370d04e3..01f17745 100644 --- a/apstra/helpers_test.go +++ b/apstra/helpers_test.go @@ -1,7 +1,10 @@ package tfapstra import ( + "context" "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" "golang.org/x/exp/constraints" "testing" ) @@ -167,3 +170,92 @@ func TestRandomIntegers(t *testing.T) { t.Fail() } } + +func TestSplitImportId(t *testing.T) { + ctx := context.Background() + t.Parallel() + + type testCase struct { + in string + fields []string + expected []string + expectedDiags diag.Diagnostics + } + + testCases := map[string]testCase{ + "|1": { + in: "|foo", + fields: []string{"foo"}, + expected: []string{"foo"}, + }, + ".2": { + in: ".foo.bar", + fields: []string{"foo", "bar"}, + expected: []string{"foo", "bar"}, + }, + "nil": { + in: "", + fields: []string{}, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "invalid import ID", + "import ID minimum length is 2", + ), + }, + }, + "empty": { + in: "", + fields: []string{}, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "invalid import ID", + "import ID minimum length is 2", + ), + }, + }, + "too short": { + in: ".", + fields: []string{}, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "invalid import ID", + "import ID minimum length is 2", + ), + }, + }, + "fail embedded separator": { + in: ".abc.def.ghi", + fields: []string{"abc", "defghi"}, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + `cannot parse import ID: ".abc.def.ghi"`, + "ID string for resource import must take this form:\n\n"+ + " \n\n"+ + "where is any single character not found in any of the delimited fields. "+ + "Expected 2 parts after splitting on '.', got 3 parts", + ), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + var diags diag.Diagnostics + + t.Run(name, func(t *testing.T) { + t.Parallel() + + parts := SplitImportId(ctx, testCase.in, testCase.fields, &diags) + + if diff := cmp.Diff(testCase.expectedDiags, diags); diff != "" { + t.Fatalf("Unexpected diagnostics (-expected ,+got): %s", diff) + } + + if len(testCase.expectedDiags) == 0 { + if diff := cmp.Diff(testCase.expected, parts); diff != "" { + t.Fatalf("Unexpected result (-expected ,+got): %s", diff) + } + } + }) + } +} diff --git a/apstra/resource_datacenter_external_gateway.go b/apstra/resource_datacenter_external_gateway.go new file mode 100644 index 00000000..19361e2f --- /dev/null +++ b/apstra/resource_datacenter_external_gateway.go @@ -0,0 +1,230 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + "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" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ resource.ResourceWithConfigure = &resourceDatacenterExternalGateway{} +var _ resource.ResourceWithImportState = &resourceDatacenterExternalGateway{} + +type resourceDatacenterExternalGateway struct { + client *apstra.Client + lockFunc func(context.Context, string) error +} + +func (o *resourceDatacenterExternalGateway) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_datacenter_external_gateway" +} + +func (o *resourceDatacenterExternalGateway) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + o.client = ResourceGetClient(ctx, req, resp) + o.lockFunc = ResourceGetBlueprintLockFunc(ctx, req, resp) +} + +func (o *resourceDatacenterExternalGateway) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryDatacenter + "This resource creates an External Gateway within a Blueprint. " + + "Prior to Apstra 4.2 these were called \"EVPN Remote Gateway\"", + Attributes: blueprint.DatacenterExternalGateway{}.ResourceAttributes(), + } +} + +func (o *resourceDatacenterExternalGateway) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // req.ID takes the form: "blueprint_id:external_gateway_id" + fieldNames := []string{ + "blueprint_id", + "external_gateway_id", + } + + // split the supplied ID into the required fields + parts := SplitImportId(ctx, req.ID, fieldNames, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // create a state object preloaded with the critical details we need in advance + state := blueprint.DatacenterExternalGateway{ + BlueprintId: types.StringValue(parts[0]), + Id: types.StringValue(parts[1]), + } + + // create a client for the datacenter reference design + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(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(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, state.BlueprintId), err.Error()) + return + } + + state.Read(ctx, bp, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (o *resourceDatacenterExternalGateway) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Retrieve values from plan. + var plan blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // create a client for the datacenter reference design + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(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(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, plan.BlueprintId), 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 + } + + request := plan.Request(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + id, err := bp.CreateRemoteGateway(ctx, request) + if err != nil { + resp.Diagnostics.AddError("error creating external gateway", err.Error()) + return + } + + plan.Id = types.StringValue(id.String()) + plan.Read(ctx, bp, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (o *resourceDatacenterExternalGateway) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Retrieve values from state. + var state blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Create a blueprint client + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(state.BlueprintId.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, state.BlueprintId), err.Error()) + return + } + + state.Read(ctx, bp, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (o *resourceDatacenterExternalGateway) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Retrieve values from plan. + var plan blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Create a blueprint client + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(plan.BlueprintId.ValueString())) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, plan.BlueprintId), 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 + } + + request := plan.Request(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + err = bp.UpdateRemoteGateway(ctx, apstra.ObjectId(plan.Id.ValueString()), request) + if err != nil { + resp.Diagnostics.AddError("error updating remote gateway", err.Error()) + return + } + + plan.Read(ctx, bp, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (o *resourceDatacenterExternalGateway) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve values from state. + var state blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Create a client for the datacenter reference design + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(state.BlueprintId.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + return // 404 is okay + } + resp.Diagnostics.AddError(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, state.BlueprintId), err.Error()) + return + } + + // Lock the blueprint mutex. + err = o.lockFunc(ctx, state.BlueprintId.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("error locking blueprint %q mutex", state.BlueprintId.ValueString()), + err.Error()) + return + } + + // Delete the remote gateway + err = bp.DeleteRemoteGateway(ctx, apstra.ObjectId(state.Id.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + return // 404 is okay + } + resp.Diagnostics.AddError("error deleting remote gateway", err.Error()) + } +} diff --git a/go.mod b/go.mod index a5c59545..ea45a70e 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.20 require ( github.com/IBM/netaddr v1.5.0 - github.com/Juniper/apstra-go-sdk v0.0.0-20231024231608-5d733c01a440 + github.com/Juniper/apstra-go-sdk v0.0.0-20231102202421-a97e1d145d70 github.com/chrismarget-j/go-licenses v0.0.0-20230424163011-d60082a506e0 + github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/terraform-plugin-docs v0.13.0 github.com/hashicorp/terraform-plugin-framework v1.3.3 @@ -14,7 +15,7 @@ require ( github.com/hashicorp/terraform-plugin-go v0.18.0 github.com/hashicorp/terraform-plugin-testing v1.2.0 github.com/mitchellh/go-homedir v1.1.0 - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d honnef.co/go/tools v0.4.3 ) @@ -33,7 +34,6 @@ require ( github.com/go-logr/logr v1.2.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-licenses v1.6.0 // indirect github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 // indirect github.com/google/uuid v1.3.0 // indirect @@ -85,13 +85,13 @@ require ( github.com/xanzy/ssh-agent v0.3.0 // indirect github.com/zclconf/go-cty v1.13.1 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/crypto v0.10.0 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect - golang.org/x/mod v0.11.0 // indirect - golang.org/x/net v0.11.0 // indirect - golang.org/x/sys v0.9.0 // indirect - golang.org/x/text v0.10.0 // indirect - golang.org/x/tools v0.6.0 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.16.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/grpc v1.56.1 // indirect diff --git a/go.sum b/go.sum index 5cdd2427..51710137 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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-20231024231608-5d733c01a440 h1:zAE3k3T4EhBCJpoNrCWbv3Sow92+11O1vAhF1R/wsD0= -github.com/Juniper/apstra-go-sdk v0.0.0-20231024231608-5d733c01a440/go.mod h1:It+5cLgOj77K+s+7m5Nsnbms3Tu/4ObqDuBjsTR2Oh0= +github.com/Juniper/apstra-go-sdk v0.0.0-20231102202421-a97e1d145d70 h1:nUEmp2qfDjHt05muO+73JSj08D2DFQVooXPH+fZz9ag= +github.com/Juniper/apstra-go-sdk v0.0.0-20231102202421-a97e1d145d70/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= @@ -486,8 +486,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= -golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= -golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -498,8 +498,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -531,8 +531,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= -golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -581,8 +581,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= -golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -618,8 +618,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -693,15 +693,15 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -715,8 +715,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= -golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -774,8 +774,9 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 52793d0746515a3cef38acef8f6c1d2209aa9b10 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Fri, 3 Nov 2023 19:55:35 -0400 Subject: [PATCH 2/9] resource `apstra_datacenter_external_gateway` tests --- .../blueprint/datacenter_external_gateway.go | 54 +++--- apstra/provider.go | 1 + ...source_datacenter_external_gateway_test.go | 155 ++++++++++++++++++ apstra/test_helpers_test.go | 85 ++++++++++ apstra/test_provider_test.go | 22 +++ apstra/test_utils/test_utils.go | 1 + go.mod | 2 +- go.sum | 4 +- 8 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 apstra/resource_datacenter_external_gateway_test.go create mode 100644 apstra/test_helpers_test.go create mode 100644 apstra/test_provider_test.go diff --git a/apstra/blueprint/datacenter_external_gateway.go b/apstra/blueprint/datacenter_external_gateway.go index 6e3289df..e9454018 100644 --- a/apstra/blueprint/datacenter_external_gateway.go +++ b/apstra/blueprint/datacenter_external_gateway.go @@ -4,7 +4,8 @@ import ( "context" "fmt" "github.com/Juniper/apstra-go-sdk/apstra" - customtypes "github.com/Juniper/terraform-provider-apstra/apstra/custom_types" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework-nettypes/iptypes" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -22,16 +23,16 @@ import ( ) type DatacenterExternalGateway struct { - Id types.String `tfsdk:"id"` - BlueprintId types.String `tfsdk:"blueprint_id"` - Name types.String `tfsdk:"name"` - IpAddress customtypes.IPv46Address `tfsdk:"ip_address"` - Asn types.Int64 `tfsdk:"asn"` - Ttl types.Int64 `tfsdk:"ttl"` - KeepaliveTime types.Int64 `tfsdk:"keepalive_time"` - HoldTime types.Int64 `tfsdk:"hold_time"` - EvpnRouteTypes types.String `tfsdk:"evpn_route_types"` - LocalGatewayNodes types.Set `tfsdk:"local_gateway_nodes"` + Id types.String `tfsdk:"id"` + BlueprintId types.String `tfsdk:"blueprint_id"` + Name types.String `tfsdk:"name"` + IpAddress iptypes.IPv4Address `tfsdk:"ip_address"` + Asn types.Int64 `tfsdk:"asn"` + Ttl types.Int64 `tfsdk:"ttl"` + KeepaliveTime types.Int64 `tfsdk:"keepalive_time"` + HoldTime types.Int64 `tfsdk:"hold_time"` + EvpnRouteTypes types.String `tfsdk:"evpn_route_types"` + LocalGatewayNodes types.Set `tfsdk:"local_gateway_nodes"` } func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchema.Attribute { @@ -55,7 +56,7 @@ func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchem "ip_address": resourceSchema.StringAttribute{ MarkdownDescription: "External Gateway IP address", Required: true, - CustomType: customtypes.IPv46AddressType{}, + CustomType: iptypes.IPv4AddressType{}, }, "asn": resourceSchema.Int64Attribute{ MarkdownDescription: "External Gateway AS Number", @@ -92,6 +93,7 @@ func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchem "local_gateway_nodes": resourceSchema.SetAttribute{ MarkdownDescription: "Set of IDs of switch nodes which will be configured to peer with the External Gateway", Required: true, + ElementType: types.StringType, Validators: []validator.Set{ setvalidator.SizeAtLeast(1), setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), @@ -110,9 +112,23 @@ func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Dia return nil } - ttl := uint8(o.Ttl.ValueInt64()) - keepaliveTimer := uint16(o.KeepaliveTime.ValueInt64()) - holdtimeTimer := uint16(o.HoldTime.ValueInt64()) + var ttl *uint8 + if utils.Known(o.Ttl) { + t := uint8(o.Ttl.ValueInt64()) + ttl = &t + } + + var keepaliveTimer *uint16 + if utils.Known(o.KeepaliveTime) { + t := uint16(o.KeepaliveTime.ValueInt64()) + keepaliveTimer = &t + } + + var holdtimeTimer *uint16 + if utils.Known(o.HoldTime) { + t := uint16(o.HoldTime.ValueInt64()) + holdtimeTimer = &t + } return &apstra.RemoteGatewayData{ RouteTypes: *routeTypes, @@ -120,9 +136,9 @@ func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Dia GwAsn: uint32(o.Asn.ValueInt64()), GwIp: net.ParseIP(o.IpAddress.ValueString()), // skipping nil check because input GwName: o.Name.ValueString(), // validation should make that impossible - Ttl: &ttl, - KeepaliveTimer: &keepaliveTimer, - HoldtimeTimer: &holdtimeTimer, + Ttl: ttl, + KeepaliveTimer: keepaliveTimer, + HoldtimeTimer: holdtimeTimer, } } @@ -161,7 +177,7 @@ func (o *DatacenterExternalGateway) loadApiData(_ context.Context, in *apstra.Re } o.Name = types.StringValue(in.GwName) - o.IpAddress = customtypes.NewIPv46AddressValue(in.GwIp.String()) + o.IpAddress = iptypes.NewIPv4AddressValue(in.GwIp.String()) o.Asn = types.Int64Value(int64(in.GwAsn)) o.Ttl = ttl o.KeepaliveTime = keepaliveTime diff --git a/apstra/provider.go b/apstra/provider.go index 860a0f3e..7f5c5655 100644 --- a/apstra/provider.go +++ b/apstra/provider.go @@ -474,6 +474,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { func() resource.Resource { return &resourceDatacenterConfiglet{} }, func() resource.Resource { return &resourceDatacenterConnectivityTemplate{} }, func() resource.Resource { return &resourceDatacenterConnectivityTemplateAssignment{} }, + func() resource.Resource { return &resourceDatacenterExternalGateway{} }, func() resource.Resource { return &resourceDatacenterGenericSystem{} }, func() resource.Resource { return &resourceDatacenterPropertySet{} }, func() resource.Resource { return &resourceDatacenterRoutingZone{} }, diff --git a/apstra/resource_datacenter_external_gateway_test.go b/apstra/resource_datacenter_external_gateway_test.go new file mode 100644 index 00000000..3b730d22 --- /dev/null +++ b/apstra/resource_datacenter_external_gateway_test.go @@ -0,0 +1,155 @@ +package tfapstra_test + +import ( + "context" + "errors" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + testutils "github.com/Juniper/terraform-provider-apstra/apstra/test_utils" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "net" + "strconv" + "strings" + "testing" +) + +const ( + resourceDataCenterExternalGateway = ` +resource "apstra_datacenter_external_gateway" "test" { + blueprint_id = "%s" + name = "%s" + ip_address = "%s" + asn = %d + evpn_route_types = "%s" + local_gateway_nodes = ["%s"] + ttl = %s + keepalive_time = %s + hold_time = %s +} +` +) + +type testCaseResourceExternalGateway struct { + name string + ipAddress net.IP + asn uint32 + routeTypes apstra.RemoteGatewayRouteTypes + nodes string + ttl *uint8 + keepaliveTime *uint16 + holdTime *uint16 + testCheckFunc resource.TestCheckFunc +} + +func renderResourceDataCenterExternalGateway(tc testCaseResourceExternalGateway, bp *apstra.TwoStageL3ClosClient) string { + return fmt.Sprintf(resourceDataCenterExternalGateway, + bp.Id(), + tc.name, + tc.ipAddress, + tc.asn, + tc.routeTypes.Value, + tc.nodes, + intPtrOrNull(tc.ttl), + intPtrOrNull(tc.keepaliveTime), + intPtrOrNull(tc.holdTime), + ) +} + +func TestResourceDatacenterExternalGateway(t *testing.T) { + ctx := context.Background() + + bp, bpDelete, err := testutils.BlueprintC(ctx) + if err != nil { + t.Fatal(errors.Join(err, bpDelete(ctx))) + } + defer func() { + err = bpDelete(ctx) + if err != nil { + t.Error(err) + } + }() + + leafIds := systemIds(ctx, t, bp, "leaf") + uint8Val3 := uint8(3) + uint16Val1 := uint16(1) + uint16Val3 := uint16(3) + + testCases := []testCaseResourceExternalGateway{ + { + name: "name1", + ipAddress: net.IP{1, 1, 1, 1}, + asn: 1, + routeTypes: apstra.RemoteGatewayRouteTypesAll, + nodes: leafIds[0], + testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "name", "name1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ip_address", "1.1.1.1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "asn", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "evpn_route_types", apstra.RemoteGatewayRouteTypesAll.Value), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.0", leafIds[0]), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "30"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "10"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "30"), // default + }...), + }, + { + name: "name2", + ipAddress: net.IP{1, 1, 1, 2}, + asn: 2, + routeTypes: apstra.RemoteGatewayRouteTypesFiveOnly, + nodes: strings.Join(leafIds[1:], `","`), + testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "name", "name2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ip_address", "1.1.1.2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "asn", "2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "evpn_route_types", apstra.RemoteGatewayRouteTypesFiveOnly.Value), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", strconv.Itoa(len(leafIds)-1)), + resource.TestCheckTypeSetElemAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.*", leafIds[1]), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "30"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "10"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "30"), // default + }...), + }, + { + name: "name3", + ipAddress: net.IP{1, 1, 1, 3}, + asn: 3, + routeTypes: apstra.RemoteGatewayRouteTypesAll, + nodes: leafIds[0], + ttl: &uint8Val3, + keepaliveTime: &uint16Val1, + holdTime: &uint16Val3, + testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "name", "name3"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ip_address", "1.1.1.3"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "asn", "3"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "evpn_route_types", apstra.RemoteGatewayRouteTypesAll.Value), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.0", leafIds[0]), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "3"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "3"), + }...), + }, + } + + steps := make([]resource.TestStep, len(testCases)) + for i, tc := range testCases { + steps[i] = resource.TestStep{ + Config: insecureProviderConfigHCL + renderResourceDataCenterExternalGateway(tc, bp), + Check: tc.testCheckFunc, + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: steps, + }) +} diff --git a/apstra/test_helpers_test.go b/apstra/test_helpers_test.go new file mode 100644 index 00000000..ab4d2714 --- /dev/null +++ b/apstra/test_helpers_test.go @@ -0,0 +1,85 @@ +package tfapstra_test + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "golang.org/x/exp/constraints" + "net" + "testing" +) + +func systemIds(ctx context.Context, t *testing.T, bp *apstra.TwoStageL3ClosClient, role string) []string { + query := new(apstra.PathQuery). + SetBlueprintType(apstra.BlueprintTypeStaging). + SetBlueprintId(bp.Id()). + SetClient(bp.Client()). + Node([]apstra.QEEAttribute{ + apstra.NodeTypeSystem.QEEAttribute(), + {Key: "role", Value: apstra.QEStringVal(role)}, + {Key: "name", Value: apstra.QEStringVal("n_system")}, + }) + + var result struct { + Items []struct { + System struct { + Id string `json:"id"` + } `json:"n_system"` + } `json:"items"` + } + + err := query.Do(ctx, &result) + if err != nil { + t.Fatal(err) + } + + ids := make([]string, len(result.Items)) + for i, item := range result.Items { + ids[i] = item.System.Id + } + + return ids +} + +func stringPtrOrNull(in *string) string { + if in == nil { + return "null" + } + return `"` + *in + `"` +} + +func stringOrNull(in string) string { + if in == "" { + return "null" + } + return `"` + in + `"` +} + +func intPtrOrNull[A constraints.Integer](in *A) string { + if in == nil { + return "null" + } + return fmt.Sprintf("%d", *in) +} + +func ipOrNull(in *net.IPNet) string { + if in == nil { + return "null" + } + return `"` + in.String() + `"` +} + +func randIpAddressMust(t *testing.T, cidrBlock string) net.IP { + s, err := acctest.RandIpAddress(cidrBlock) + if err != nil { + t.Fatal(err) + } + + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("randIpAddressMust failed to parse IP address %q", s) + } + + return ip +} diff --git a/apstra/test_provider_test.go b/apstra/test_provider_test.go new file mode 100644 index 00000000..1f40714d --- /dev/null +++ b/apstra/test_provider_test.go @@ -0,0 +1,22 @@ +package tfapstra_test + +import ( + tfapstra "github.com/Juniper/terraform-provider-apstra/apstra" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +const ( + insecureProviderConfigHCL = ` +provider "apstra" { + tls_validation_disabled = true + blueprint_mutex_enabled = false +} +` +) + +var ( + testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "apstra": providerserver.NewProtocol6WithError(tfapstra.NewProvider()), + } +) diff --git a/apstra/test_utils/test_utils.go b/apstra/test_utils/test_utils.go index 3a7691f0..53b5bdf3 100644 --- a/apstra/test_utils/test_utils.go +++ b/apstra/test_utils/test_utils.go @@ -20,6 +20,7 @@ func GetTestClient(ctx context.Context) (*apstra.Client, error) { if err != nil { return nil, err } + clientCfg.Experimental = true clientCfg.HttpClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true sharedClient, err = clientCfg.NewClient(ctx) diff --git a/go.mod b/go.mod index ea45a70e..4432b097 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.20 require ( github.com/IBM/netaddr v1.5.0 - github.com/Juniper/apstra-go-sdk v0.0.0-20231102202421-a97e1d145d70 + github.com/Juniper/apstra-go-sdk v0.0.0-20231103220718-53fe09973dda github.com/chrismarget-j/go-licenses v0.0.0-20230424163011-d60082a506e0 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-version v1.6.0 diff --git a/go.sum b/go.sum index 51710137..1c027204 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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-20231102202421-a97e1d145d70 h1:nUEmp2qfDjHt05muO+73JSj08D2DFQVooXPH+fZz9ag= -github.com/Juniper/apstra-go-sdk v0.0.0-20231102202421-a97e1d145d70/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= +github.com/Juniper/apstra-go-sdk v0.0.0-20231103220718-53fe09973dda h1:X0WniizgAu48w/FdRtMUzkFLkwNfxF9c+Byzbuids7Y= +github.com/Juniper/apstra-go-sdk v0.0.0-20231103220718-53fe09973dda/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= From ce690385cdbefe30dc156dc51e5707d35df386aa Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Fri, 3 Nov 2023 19:58:41 -0400 Subject: [PATCH 3/9] comment out future-use functions --- apstra/test_helpers_test.go | 64 ++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/apstra/test_helpers_test.go b/apstra/test_helpers_test.go index ab4d2714..1084f817 100644 --- a/apstra/test_helpers_test.go +++ b/apstra/test_helpers_test.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "github.com/Juniper/apstra-go-sdk/apstra" - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "golang.org/x/exp/constraints" - "net" "testing" ) @@ -42,19 +40,19 @@ func systemIds(ctx context.Context, t *testing.T, bp *apstra.TwoStageL3ClosClien return ids } -func stringPtrOrNull(in *string) string { - if in == nil { - return "null" - } - return `"` + *in + `"` -} +//func stringPtrOrNull(in *string) string { +// if in == nil { +// return "null" +// } +// return `"` + *in + `"` +//} -func stringOrNull(in string) string { - if in == "" { - return "null" - } - return `"` + in + `"` -} +//func stringOrNull(in string) string { +// if in == "" { +// return "null" +// } +// return `"` + in + `"` +//} func intPtrOrNull[A constraints.Integer](in *A) string { if in == nil { @@ -63,23 +61,23 @@ func intPtrOrNull[A constraints.Integer](in *A) string { return fmt.Sprintf("%d", *in) } -func ipOrNull(in *net.IPNet) string { - if in == nil { - return "null" - } - return `"` + in.String() + `"` -} - -func randIpAddressMust(t *testing.T, cidrBlock string) net.IP { - s, err := acctest.RandIpAddress(cidrBlock) - if err != nil { - t.Fatal(err) - } - - ip := net.ParseIP(s) - if ip == nil { - t.Fatalf("randIpAddressMust failed to parse IP address %q", s) - } +//func ipOrNull(in *net.IPNet) string { +// if in == nil { +// return "null" +// } +// return `"` + in.String() + `"` +//} - return ip -} +//func randIpAddressMust(t *testing.T, cidrBlock string) net.IP { +// s, err := acctest.RandIpAddress(cidrBlock) +// if err != nil { +// t.Fatal(err) +// } +// +// ip := net.ParseIP(s) +// if ip == nil { +// t.Fatalf("randIpAddressMust failed to parse IP address %q", s) +// } +// +// return ip +//} From 30943e8a4bf230569bb8944b930760c600a0f397 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Fri, 3 Nov 2023 23:55:16 -0400 Subject: [PATCH 4/9] add support for password to external gateway resource --- .../blueprint/datacenter_external_gateway.go | 92 +++++++++++++++++++ ...source_datacenter_external_gateway_test.go | 47 ++++++++++ apstra/test_helpers_test.go | 12 +-- go.mod | 2 +- go.sum | 4 +- 5 files changed, 148 insertions(+), 9 deletions(-) diff --git a/apstra/blueprint/datacenter_external_gateway.go b/apstra/blueprint/datacenter_external_gateway.go index e9454018..0c7118c3 100644 --- a/apstra/blueprint/datacenter_external_gateway.go +++ b/apstra/blueprint/datacenter_external_gateway.go @@ -33,6 +33,7 @@ type DatacenterExternalGateway struct { HoldTime types.Int64 `tfsdk:"hold_time"` EvpnRouteTypes types.String `tfsdk:"evpn_route_types"` LocalGatewayNodes types.Set `tfsdk:"local_gateway_nodes"` + Password types.String `tfsdk:"password"` } func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchema.Attribute { @@ -99,6 +100,12 @@ func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchem setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)), }, }, + "password": resourceSchema.StringAttribute{ + MarkdownDescription: "BGP TCP authentication password", + Optional: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + //Sensitive: true, + }, } } @@ -130,6 +137,12 @@ func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Dia holdtimeTimer = &t } + var password *string + if utils.Known(o.Password) { + t := o.Password.ValueString() + password = &t + } + return &apstra.RemoteGatewayData{ RouteTypes: *routeTypes, LocalGwNodes: localGwNodes, @@ -139,6 +152,7 @@ func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Dia Ttl: ttl, KeepaliveTimer: keepaliveTimer, HoldtimeTimer: holdtimeTimer, + Password: password, } } @@ -153,6 +167,84 @@ func (o *DatacenterExternalGateway) Read(ctx context.Context, bp *apstra.TwoStag if diags.HasError() { return } + + o.ReadProtocolPassword(ctx, bp, diags) + if diags.HasError() { + return + } +} + +func (o *DatacenterExternalGateway) ReadProtocolPassword(ctx context.Context, bp *apstra.TwoStageL3ClosClient, diags *diag.Diagnostics) { + query := new(apstra.PathQuery). + SetClient(bp.Client()). + SetBlueprintId(bp.Id()). + SetBlueprintType(apstra.BlueprintTypeStaging). + Node([]apstra.QEEAttribute{ + apstra.NodeTypeSystem.QEEAttribute(), + {Key: "id", Value: apstra.QEStringVal(o.Id.ValueString())}, + }). + Out([]apstra.QEEAttribute{apstra.RelationshipTypeHostedInterfaces.QEEAttribute()}). + Node([]apstra.QEEAttribute{apstra.NodeTypeInterface.QEEAttribute()}). + Out([]apstra.QEEAttribute{apstra.RelationshipTypeProtocol.QEEAttribute()}). + Node([]apstra.QEEAttribute{ + apstra.NodeTypeProtocol.QEEAttribute(), + {Key: "name", Value: apstra.QEStringVal("n_protocol")}, + }) + + var queryResponse struct { + Items []struct { + Protocol struct { + Password *string `json:"password"` + } `json:"n_protocol"` + } `json:"items"` + } + + err := query.Do(ctx, &queryResponse) + if err != nil { + diags.AddError("failed while performing graph query", + fmt.Sprintf("error: %q\nquery: %q\n", err.Error(), query.String())) + return + } + + // count usage of each discovered password (there should only be one password, used everywhere) + pwUsageCounts := make(map[string]int) + var password string + for _, item := range queryResponse.Items { + if item.Protocol.Password == nil { + continue + } + + password = *item.Protocol.Password // save the (only?) password outside the map + pwUsageCounts[password]++ // increment the password use counter + } + + // how many passwords discovered? + switch len(pwUsageCounts) { + case 0: + o.Password = types.StringNull() // no passwords found - this is fine! + return + case 1: // expected case (only one password found) handled below + default: + diags.AddError("multiple protocol passwords found", + fmt.Sprintf("external gateway node %s sessions use mismatched passwords", o.Id)) + return + } + + // if we got here, only one password is in use. That's good, but is it in use on *every* protocol session? + if len(queryResponse.Items) > pwUsageCounts[password] { + diags.AddError("protocol password not used uniformly", + fmt.Sprintf("external gateway node %s has %d protocol sessions, but only %d of them use a password", + o.Id, len(queryResponse.Items), pwUsageCounts[password])) + return + } + + if len(queryResponse.Items) < pwUsageCounts[password] { + diags.AddWarning(errProviderBug, + "graph query found more protocol session passwords than sessions - this should be impossible") + return + } + + o.Password = types.StringValue(password) } func (o *DatacenterExternalGateway) loadApiData(_ context.Context, in *apstra.RemoteGatewayData, _ *diag.Diagnostics) { diff --git a/apstra/resource_datacenter_external_gateway_test.go b/apstra/resource_datacenter_external_gateway_test.go index 3b730d22..f73d978a 100644 --- a/apstra/resource_datacenter_external_gateway_test.go +++ b/apstra/resource_datacenter_external_gateway_test.go @@ -25,6 +25,7 @@ resource "apstra_datacenter_external_gateway" "test" { ttl = %s keepalive_time = %s hold_time = %s + password = %s } ` ) @@ -38,6 +39,7 @@ type testCaseResourceExternalGateway struct { ttl *uint8 keepaliveTime *uint16 holdTime *uint16 + password string testCheckFunc resource.TestCheckFunc } @@ -52,6 +54,7 @@ func renderResourceDataCenterExternalGateway(tc testCaseResourceExternalGateway, intPtrOrNull(tc.ttl), intPtrOrNull(tc.keepaliveTime), intPtrOrNull(tc.holdTime), + stringOrNull(tc.password), ) } @@ -124,6 +127,7 @@ func TestResourceDatacenterExternalGateway(t *testing.T) { ttl: &uint8Val3, keepaliveTime: &uint16Val1, holdTime: &uint16Val3, + password: "big secret1", testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), @@ -136,6 +140,49 @@ func TestResourceDatacenterExternalGateway(t *testing.T) { resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "3"), resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "1"), resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "3"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "password", "big secret1"), + }...), + }, + { + name: "name1", + ipAddress: net.IP{1, 1, 1, 1}, + asn: 1, + routeTypes: apstra.RemoteGatewayRouteTypesAll, + nodes: leafIds[0], + testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "name", "name1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ip_address", "1.1.1.1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "asn", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "evpn_route_types", apstra.RemoteGatewayRouteTypesAll.Value), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", "1"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.0", leafIds[0]), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "30"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "10"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "30"), // default + }...), + }, + { + name: "name2", + ipAddress: net.IP{1, 1, 1, 2}, + asn: 2, + routeTypes: apstra.RemoteGatewayRouteTypesFiveOnly, + nodes: strings.Join(leafIds[1:], `","`), + password: "big secret2", + testCheckFunc: resource.ComposeAggregateTestCheckFunc([]resource.TestCheckFunc{ + resource.TestCheckResourceAttrSet("apstra_datacenter_external_gateway.test", "id"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "name", "name2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ip_address", "1.1.1.2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "asn", "2"), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "evpn_route_types", apstra.RemoteGatewayRouteTypesFiveOnly.Value), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", strconv.Itoa(len(leafIds)-1)), + resource.TestCheckTypeSetElemAttr("apstra_datacenter_external_gateway.test", "local_gateway_nodes.*", leafIds[1]), + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "ttl", "30"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "keepalive_time", "10"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "hold_time", "30"), // default + resource.TestCheckResourceAttr("apstra_datacenter_external_gateway.test", "password", "big secret2"), }...), }, } diff --git a/apstra/test_helpers_test.go b/apstra/test_helpers_test.go index 1084f817..1a646efa 100644 --- a/apstra/test_helpers_test.go +++ b/apstra/test_helpers_test.go @@ -47,12 +47,12 @@ func systemIds(ctx context.Context, t *testing.T, bp *apstra.TwoStageL3ClosClien // return `"` + *in + `"` //} -//func stringOrNull(in string) string { -// if in == "" { -// return "null" -// } -// return `"` + in + `"` -//} +func stringOrNull(in string) string { + if in == "" { + return "null" + } + return `"` + in + `"` +} func intPtrOrNull[A constraints.Integer](in *A) string { if in == nil { diff --git a/go.mod b/go.mod index 4432b097..cfc6c6d7 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.20 require ( github.com/IBM/netaddr v1.5.0 - github.com/Juniper/apstra-go-sdk v0.0.0-20231103220718-53fe09973dda + github.com/Juniper/apstra-go-sdk v0.0.0-20231104035306-7705f4b238ff github.com/chrismarget-j/go-licenses v0.0.0-20230424163011-d60082a506e0 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-version v1.6.0 diff --git a/go.sum b/go.sum index 1c027204..043b45c8 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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-20231103220718-53fe09973dda h1:X0WniizgAu48w/FdRtMUzkFLkwNfxF9c+Byzbuids7Y= -github.com/Juniper/apstra-go-sdk v0.0.0-20231103220718-53fe09973dda/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= +github.com/Juniper/apstra-go-sdk v0.0.0-20231104035306-7705f4b238ff h1:nn0MliXN2+J8YP1LzpL3duDxt4qDJeXcifgpLViMG4w= +github.com/Juniper/apstra-go-sdk v0.0.0-20231104035306-7705f4b238ff/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= From 96157d8d9efea4717529a7f8507aeefaca0cfe38 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Sat, 4 Nov 2023 07:44:54 -0400 Subject: [PATCH 5/9] evpn gateway resource documentation --- apstra/resource_datacenter_external_gateway.go | 4 ++-- .../example.tf | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 examples/resources/apstra_datacenter_external_gateway/example.tf diff --git a/apstra/resource_datacenter_external_gateway.go b/apstra/resource_datacenter_external_gateway.go index 19361e2f..2ac1c79c 100644 --- a/apstra/resource_datacenter_external_gateway.go +++ b/apstra/resource_datacenter_external_gateway.go @@ -30,8 +30,8 @@ func (o *resourceDatacenterExternalGateway) Configure(ctx context.Context, req r func (o *resourceDatacenterExternalGateway) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: docCategoryDatacenter + "This resource creates an External Gateway within a Blueprint. " + - "Prior to Apstra 4.2 these were called \"EVPN Remote Gateway\"", + MarkdownDescription: docCategoryDatacenter + "This resource creates a DCI External Gateway within a Blueprint. " + + "Prior to Apstra 4.2 these were called \"Remote EVPN Gateways\"", Attributes: blueprint.DatacenterExternalGateway{}.ResourceAttributes(), } } diff --git a/examples/resources/apstra_datacenter_external_gateway/example.tf b/examples/resources/apstra_datacenter_external_gateway/example.tf new file mode 100644 index 00000000..2441ca19 --- /dev/null +++ b/examples/resources/apstra_datacenter_external_gateway/example.tf @@ -0,0 +1,18 @@ +# This example creates an "over the top" DCI External Gateway. +# Note: Prior to Apstra 4.2 these were known as "Remote EVPN Gateways" + +resource "apstra_datacenter_external_gateway" "example" { + blueprint_id = "b4c4ed6a-9c6a-4577-b3d4-78705c08a272" + name = "example gateway" + ip_address = "192.0.2.1" + asn = 64510 + evpn_route_types = "all" # "all" or "type5_only" + ttl = 10 + keepalive_time = 3 + hold_time = 9 + password = "big secret" + local_gateway_nodes = [ + "JGcTJy_jP4898Z13WHU", // use apstra_datacenter_systems data + "Fx-fVa7t_LYp7JtQ_nU", // source to find node IDs + ] +} From af2f51df8f9afbb5b76f4b6583f780da719a9a3a Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Sat, 4 Nov 2023 19:30:50 -0400 Subject: [PATCH 6/9] introduce external gateway data source --- .../blueprint/datacenter_external_gateway.go | 96 ++++++++++- ...data_source_datacenter_external_gateway.go | 61 +++++++ ...source_datacenter_external_gateway_test.go | 150 ++++++++++++++++++ apstra/provider.go | 1 + apstra/test_helpers_test.go | 28 ++-- 5 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 apstra/data_source_datacenter_external_gateway.go create mode 100644 apstra/data_source_datacenter_external_gateway_test.go diff --git a/apstra/blueprint/datacenter_external_gateway.go b/apstra/blueprint/datacenter_external_gateway.go index 0c7118c3..4f4956c3 100644 --- a/apstra/blueprint/datacenter_external_gateway.go +++ b/apstra/blueprint/datacenter_external_gateway.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" + 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/stringdefault" @@ -104,7 +106,70 @@ func (o DatacenterExternalGateway) ResourceAttributes() map[string]resourceSchem MarkdownDescription: "BGP TCP authentication password", Optional: true, Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, - //Sensitive: true, + Sensitive: true, + }, + } +} + +func (o DatacenterExternalGateway) DataSourceAttributes() map[string]dataSourceSchema.Attribute { + return map[string]dataSourceSchema.Attribute{ + "id": dataSourceSchema.StringAttribute{ + MarkdownDescription: "Apstra Object ID.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRelative(), + path.MatchRoot("name"), + }...), + }, + }, + "blueprint_id": dataSourceSchema.StringAttribute{ + MarkdownDescription: "Apstra ID of the Blueprint in which the External Gateway should be created.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "name": dataSourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway name", + Optional: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "ip_address": dataSourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway IP address", + Computed: true, + CustomType: iptypes.IPv4AddressType{}, + }, + "asn": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "External Gateway AS Number", + Computed: true, + }, + "ttl": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP Time To Live. Omit to use device defaults.", + Computed: true, + }, + "keepalive_time": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP keepalive time (seconds).", + Computed: true, + }, + "hold_time": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP hold time (seconds).", + Computed: true, + }, + "evpn_route_types": dataSourceSchema.StringAttribute{ + MarkdownDescription: fmt.Sprintf(`EVPN route types. Valid values are: ["%s"]. Default: %q`, + strings.Join(apstra.RemoteGatewayRouteTypesEnum.Values(), `", "`), + apstra.RemoteGatewayRouteTypesAll.Value), + Computed: true, + }, + "local_gateway_nodes": dataSourceSchema.SetAttribute{ + MarkdownDescription: "Set of IDs of switch nodes which will be configured to peer with the External Gateway", + Computed: true, + ElementType: types.StringType, + }, + "password": dataSourceSchema.StringAttribute{ + MarkdownDescription: "BGP TCP authentication password", + Computed: true, + Sensitive: true, }, } } @@ -157,13 +222,36 @@ func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Dia } func (o *DatacenterExternalGateway) Read(ctx context.Context, bp *apstra.TwoStageL3ClosClient, diags *diag.Diagnostics) { - remoteGateway, err := bp.GetRemoteGateway(ctx, apstra.ObjectId(o.Id.ValueString())) + var err error + var api *apstra.RemoteGateway + + switch { + case !o.Id.IsNull(): + api, err = bp.GetRemoteGateway(ctx, apstra.ObjectId(o.Id.ValueString())) + if utils.IsApstra404(err) { + diags.AddAttributeError( + path.Root("id"), + "External Gateway not found", + fmt.Sprintf("External Gateway with ID %s not found", o.Id)) + return + } + case !o.Name.IsNull(): + api, err = bp.GetRemoteGatewayByName(ctx, o.Name.ValueString()) + if utils.IsApstra404(err) { + diags.AddAttributeError( + path.Root("name"), + "External Gateway not found", + fmt.Sprintf("External Gateway with Name %s not found", o.Name)) + return + } + o.Id = types.StringValue(api.Id.String()) + } if err != nil { - diags.AddError("failed to fetch remote gateway", err.Error()) + diags.AddError("Failed reading Remote Gateway", err.Error()) return } - o.loadApiData(ctx, remoteGateway.Data, diags) + o.loadApiData(ctx, api.Data, diags) if diags.HasError() { return } diff --git a/apstra/data_source_datacenter_external_gateway.go b/apstra/data_source_datacenter_external_gateway.go new file mode 100644 index 00000000..6e94b44d --- /dev/null +++ b/apstra/data_source_datacenter_external_gateway.go @@ -0,0 +1,61 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var _ datasource.DataSourceWithConfigure = &dataSourceDatacenterExternalGateway{} + +type dataSourceDatacenterExternalGateway struct { + client *apstra.Client +} + +func (o *dataSourceDatacenterExternalGateway) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_datacenter_external_gateway" +} + +func (o *dataSourceDatacenterExternalGateway) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + o.client = DataSourceGetClient(ctx, req, resp) +} + +func (o *dataSourceDatacenterExternalGateway) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryDatacenter + "This resource returns details of a Routing Zone within a Datacenter Blueprint.\n\n" + + "At least one optional attribute is required.", + Attributes: blueprint.DatacenterExternalGateway{}.DataSourceAttributes(), + } +} + +func (o *dataSourceDatacenterExternalGateway) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + // Retrieve values from config. + var config blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(config.BlueprintId.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + resp.Diagnostics.AddError(fmt.Sprintf("blueprint %s not found", + config.BlueprintId), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, config.BlueprintId), err.Error()) + return + } + + config.Read(ctx, bp, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // set state + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} diff --git a/apstra/data_source_datacenter_external_gateway_test.go b/apstra/data_source_datacenter_external_gateway_test.go new file mode 100644 index 00000000..d5752c6d --- /dev/null +++ b/apstra/data_source_datacenter_external_gateway_test.go @@ -0,0 +1,150 @@ +package tfapstra_test + +import ( + "context" + "errors" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + testutils "github.com/Juniper/terraform-provider-apstra/apstra/test_utils" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "math/rand" + "testing" +) + +const ( + dataSourceDataCenterExternalGatewayByIdHCL = ` +data "apstra_datacenter_external_gateway" "test" { + blueprint_id = "%s" + id = "%s" +} +` + + dataSourceDataCenterExternalGatewayByNameHCL = ` +data "apstra_datacenter_external_gateway" "test" { + blueprint_id = "%s" + name = "%s" +} +` +) + +func TestDatacenterExternalGateway(t *testing.T) { + ctx := context.Background() + + bp, bpDelete, err := testutils.BlueprintC(ctx) + if err != nil { + t.Fatal(errors.Join(err, bpDelete(ctx))) + } + defer func() { + err = bpDelete(ctx) + if err != nil { + t.Error(err) + } + }() + + leafIdStrings := systemIds(ctx, t, bp, "leaf") + leafIds := make([]apstra.ObjectId, len(leafIdStrings)) + for i, id := range leafIdStrings { + leafIds[i] = apstra.ObjectId(id) + } + + ttl := uint8(5) + keepalive := uint16(6) + hold := uint16(18) + password := "big secret" + + rgConfigs := []apstra.RemoteGatewayData{ + { + RouteTypes: apstra.RemoteGatewayRouteTypesAll, + LocalGwNodes: leafIds, + GwAsn: rand.Uint32(), + GwIp: randIpAddressMust(t, "10.0.0.0/8"), + GwName: acctest.RandString(5), + Ttl: &ttl, + KeepaliveTimer: &keepalive, + HoldtimeTimer: &hold, + Password: &password, + }, + { + RouteTypes: apstra.RemoteGatewayRouteTypesFiveOnly, + LocalGwNodes: leafIds, + GwAsn: rand.Uint32(), + GwIp: randIpAddressMust(t, "10.0.0.0/8"), + GwName: acctest.RandString(5), + Ttl: &ttl, + KeepaliveTimer: &keepalive, + HoldtimeTimer: &hold, + Password: &password, + }, + } + + rgIds := make([]apstra.ObjectId, len(rgConfigs)) + for i, rgConfig := range rgConfigs { + rgIds[i], err = bp.CreateRemoteGateway(ctx, &rgConfig) + if err != nil { + t.Fatal(err) + } + } + + rgs := make([]apstra.RemoteGateway, len(rgIds)) + for i, rgData := range rgConfigs { + rgData := rgData + rgs[i] = apstra.RemoteGateway{ + Id: rgIds[i], + Data: &rgData, + } + } + + genTestCheckFuncs := func(rg apstra.RemoteGateway) []resource.TestCheckFunc { + result := []resource.TestCheckFunc{ + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "id", rg.Id.String()), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "blueprint_id", bp.Id().String()), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "name", rg.Data.GwName), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "ip_address", rg.Data.GwIp.String()), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "asn", fmt.Sprintf("%d", rg.Data.GwAsn)), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "ttl", fmt.Sprintf("%d", *rg.Data.Ttl)), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "keepalive_time", fmt.Sprintf("%d", *rg.Data.KeepaliveTimer)), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "hold_time", fmt.Sprintf("%d", *rg.Data.HoldtimeTimer)), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "evpn_route_types", rg.Data.RouteTypes.Value), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "local_gateway_nodes.#", fmt.Sprintf("%d", len(rg.Data.LocalGwNodes))), + resource.TestCheckResourceAttr("data.apstra_datacenter_external_gateway.test", "password", *rg.Data.Password), + } + + for _, id := range leafIdStrings { + tcf := resource.TestCheckTypeSetElemAttr( + "data.apstra_datacenter_external_gateway.test", + "local_gateway_nodes.*", id, + ) + result = append(result, tcf) + } + + return result + } + + testCheckFuncsByRgId := make(map[apstra.ObjectId][]resource.TestCheckFunc, len(rgs)) + for _, rg := range rgs { + testCheckFuncsByRgId[rg.Id] = genTestCheckFuncs(rg) + } + + stepsById := make([]resource.TestStep, len(rgs)) + for i, rg := range rgs { + stepsById[i] = resource.TestStep{ + Config: insecureProviderConfigHCL + fmt.Sprintf(dataSourceDataCenterExternalGatewayByIdHCL, bp.Id(), rg.Id), + Check: resource.ComposeAggregateTestCheckFunc(testCheckFuncsByRgId[rg.Id]...), + } + } + + stepsByName := make([]resource.TestStep, len(rgs)) + for i, rg := range rgs { + stepsByName[i] = resource.TestStep{ + Config: insecureProviderConfigHCL + fmt.Sprintf(dataSourceDataCenterExternalGatewayByNameHCL, bp.Id(), rg.Data.GwName), + Check: resource.ComposeAggregateTestCheckFunc(genTestCheckFuncs(rg)...), + } + } + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + //Steps: stepsById, + Steps: append(stepsById, stepsByName...), + }) +} diff --git a/apstra/provider.go b/apstra/provider.go index 7f5c5655..25966757 100644 --- a/apstra/provider.go +++ b/apstra/provider.go @@ -422,6 +422,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource func() datasource.DataSource { return &dataSourceDatacenterCtStaticRoute{} }, func() datasource.DataSource { return &dataSourceDatacenterCtVnSingle{} }, func() datasource.DataSource { return &dataSourceDatacenterCtVnMultiple{} }, + func() datasource.DataSource { return &dataSourceDatacenterExternalGateway{} }, func() datasource.DataSource { return &dataSourceDatacenterPropertySet{} }, func() datasource.DataSource { return &dataSourceDatacenterPropertySets{} }, func() datasource.DataSource { return &dataSourceDatacenterRoutingPolicies{} }, diff --git a/apstra/test_helpers_test.go b/apstra/test_helpers_test.go index 1a646efa..005e957e 100644 --- a/apstra/test_helpers_test.go +++ b/apstra/test_helpers_test.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "github.com/Juniper/apstra-go-sdk/apstra" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "golang.org/x/exp/constraints" + "net" "testing" ) @@ -68,16 +70,16 @@ func intPtrOrNull[A constraints.Integer](in *A) string { // return `"` + in.String() + `"` //} -//func randIpAddressMust(t *testing.T, cidrBlock string) net.IP { -// s, err := acctest.RandIpAddress(cidrBlock) -// if err != nil { -// t.Fatal(err) -// } -// -// ip := net.ParseIP(s) -// if ip == nil { -// t.Fatalf("randIpAddressMust failed to parse IP address %q", s) -// } -// -// return ip -//} +func randIpAddressMust(t *testing.T, cidrBlock string) net.IP { + s, err := acctest.RandIpAddress(cidrBlock) + if err != nil { + t.Fatal(err) + } + + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("randIpAddressMust failed to parse IP address %q", s) + } + + return ip +} From 6f438f852f9f977d321972a2727080caf6c7d899 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Sat, 4 Nov 2023 23:31:05 -0400 Subject: [PATCH 7/9] introduce data source `apstra_datacenter_external_gateways` --- Third_Party_Code/NOTICES.md | 4 +- .../{cidrtypes => }/LICENSE | 0 .../blueprint/datacenter_external_gateway.go | 115 ++++++++++++- ...data_source_datacenter_external_gateway.go | 2 +- ...ata_source_datacenter_external_gateways.go | 157 ++++++++++++++++++ ...data_source_datacenter_routing_policies.go | 2 +- apstra/provider.go | 1 + .../datacenter_external_gateway.md | 49 ++++++ .../datacenter_external_gateways.md | 87 ++++++++++ .../datacenter_routing_policies.md | 4 +- docs/resources/datacenter_external_gateway.md | 57 +++++++ .../example.tf | 7 + .../example.tf | 33 ++++ 13 files changed, 510 insertions(+), 8 deletions(-) rename Third_Party_Code/github.com/hashicorp/terraform-plugin-framework-nettypes/{cidrtypes => }/LICENSE (100%) create mode 100644 apstra/data_source_datacenter_external_gateways.go create mode 100644 docs/data-sources/datacenter_external_gateway.md create mode 100644 docs/data-sources/datacenter_external_gateways.md create mode 100644 docs/resources/datacenter_external_gateway.md create mode 100644 examples/data-sources/apstra_datacenter_external_gateway/example.tf create mode 100644 examples/data-sources/apstra_datacenter_external_gateways/example.tf diff --git a/Third_Party_Code/NOTICES.md b/Third_Party_Code/NOTICES.md index 1e974161..f0c74a95 100644 --- a/Third_Party_Code/NOTICES.md +++ b/Third_Party_Code/NOTICES.md @@ -1815,9 +1815,9 @@ Exhibit B - “Incompatible With Secondary Licenses” Notice ``` -## github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes +## github.com/hashicorp/terraform-plugin-framework-nettypes -* Name: github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes +* Name: github.com/hashicorp/terraform-plugin-framework-nettypes * Version: v0.1.0 * License: [MPL-2.0](https://github.com/hashicorp/terraform-plugin-framework-nettypes/blob/v0.1.0/LICENSE) diff --git a/Third_Party_Code/github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes/LICENSE b/Third_Party_Code/github.com/hashicorp/terraform-plugin-framework-nettypes/LICENSE similarity index 100% rename from Third_Party_Code/github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes/LICENSE rename to Third_Party_Code/github.com/hashicorp/terraform-plugin-framework-nettypes/LICENSE diff --git a/apstra/blueprint/datacenter_external_gateway.go b/apstra/blueprint/datacenter_external_gateway.go index 4f4956c3..2baa1ad1 100644 --- a/apstra/blueprint/datacenter_external_gateway.go +++ b/apstra/blueprint/datacenter_external_gateway.go @@ -174,6 +174,62 @@ func (o DatacenterExternalGateway) DataSourceAttributes() map[string]dataSourceS } } +func (o DatacenterExternalGateway) DataSourceAttributesAsFilter() map[string]dataSourceSchema.Attribute { + return map[string]dataSourceSchema.Attribute{ + "id": dataSourceSchema.StringAttribute{ + MarkdownDescription: "Apstra Object ID.", + Optional: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "blueprint_id": dataSourceSchema.StringAttribute{ + MarkdownDescription: "Not applicable in filter context. Ignore.", + Computed: true, + }, + "name": dataSourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway name", + Optional: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "ip_address": dataSourceSchema.StringAttribute{ + MarkdownDescription: "External Gateway IP address", + Optional: true, + CustomType: iptypes.IPv4AddressType{}, + }, + "asn": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "External Gateway AS Number", + Optional: true, + }, + "ttl": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP Time To Live. Omit to use device defaults.", + Optional: true, + }, + "keepalive_time": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP keepalive time (seconds).", + Optional: true, + }, + "hold_time": dataSourceSchema.Int64Attribute{ + MarkdownDescription: "BGP hold time (seconds).", + Optional: true, + }, + "evpn_route_types": dataSourceSchema.StringAttribute{ + MarkdownDescription: fmt.Sprintf(`EVPN route types. Valid values are: ["%s"]. Default: %q`, + strings.Join(apstra.RemoteGatewayRouteTypesEnum.Values(), `", "`), + apstra.RemoteGatewayRouteTypesAll.Value), + Optional: true, + }, + "local_gateway_nodes": dataSourceSchema.SetAttribute{ + MarkdownDescription: "Set of IDs of switch nodes which will be configured to peer with the External Gateway", + Optional: true, + ElementType: types.StringType, + }, + "password": dataSourceSchema.StringAttribute{ + MarkdownDescription: "BGP TCP authentication password", + Optional: true, + Sensitive: true, + }, + } +} + func (o *DatacenterExternalGateway) Request(ctx context.Context, diags *diag.Diagnostics) *apstra.RemoteGatewayData { routeTypes := apstra.RemoteGatewayRouteTypesEnum.Parse(o.EvpnRouteTypes.ValueString()) // skipping nil check because input validation should make that impossible @@ -251,7 +307,7 @@ func (o *DatacenterExternalGateway) Read(ctx context.Context, bp *apstra.TwoStag return } - o.loadApiData(ctx, api.Data, diags) + o.LoadApiData(ctx, api.Data, diags) if diags.HasError() { return } @@ -335,7 +391,7 @@ func (o *DatacenterExternalGateway) ReadProtocolPassword(ctx context.Context, bp o.Password = types.StringValue(password) } -func (o *DatacenterExternalGateway) loadApiData(_ context.Context, in *apstra.RemoteGatewayData, _ *diag.Diagnostics) { +func (o *DatacenterExternalGateway) LoadApiData(_ context.Context, in *apstra.RemoteGatewayData, _ *diag.Diagnostics) { ttl := types.Int64Null() if in.Ttl != nil { ttl = types.Int64Value(int64(*in.Ttl)) @@ -365,3 +421,58 @@ func (o *DatacenterExternalGateway) loadApiData(_ context.Context, in *apstra.Re o.EvpnRouteTypes = types.StringValue(in.RouteTypes.Value) o.LocalGatewayNodes = types.SetValueMust(types.StringType, localGatewayNodes) } + +func (o *DatacenterExternalGateway) FilterMatch(_ context.Context, in *DatacenterExternalGateway, _ *diag.Diagnostics) bool { + if !o.Id.IsNull() && !o.Id.Equal(in.Id) { + return false + } + + if !o.Name.IsNull() && !o.Name.Equal(in.Name) { + return false + } + + if !o.IpAddress.IsNull() && !o.IpAddress.Equal(in.IpAddress) { + return false + } + + if !o.Asn.IsNull() && !o.Asn.Equal(in.Asn) { + return false + } + + if !o.Ttl.IsNull() && !o.Ttl.Equal(in.Ttl) { + return false + } + + if !o.KeepaliveTime.IsNull() && !o.KeepaliveTime.Equal(in.KeepaliveTime) { + return false + } + + if !o.HoldTime.IsNull() && !o.HoldTime.Equal(in.HoldTime) { + return false + } + + if !o.EvpnRouteTypes.IsNull() && !o.EvpnRouteTypes.Equal(in.EvpnRouteTypes) { + return false + } + + if !o.Password.IsNull() && !o.Password.Equal(in.Password) { + return false + } + + if !o.LocalGatewayNodes.IsNull() { + // extract the candidate localGatewayNodes as a map for quick lookups + candidateItems := make(map[string]bool, len(in.LocalGatewayNodes.Elements())) + for _, item := range in.LocalGatewayNodes.Elements() { + candidateItems[item.(types.String).ValueString()] = true + } + + // fail if any required item is missing from candidate items + for _, requiredItem := range o.LocalGatewayNodes.Elements() { + if !candidateItems[requiredItem.(types.String).ValueString()] { + return false + } + } + } + + return true +} diff --git a/apstra/data_source_datacenter_external_gateway.go b/apstra/data_source_datacenter_external_gateway.go index 6e94b44d..d4da7638 100644 --- a/apstra/data_source_datacenter_external_gateway.go +++ b/apstra/data_source_datacenter_external_gateway.go @@ -26,7 +26,7 @@ func (o *dataSourceDatacenterExternalGateway) Configure(ctx context.Context, req func (o *dataSourceDatacenterExternalGateway) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: docCategoryDatacenter + "This resource returns details of a Routing Zone within a Datacenter Blueprint.\n\n" + + MarkdownDescription: docCategoryDatacenter + "This resource returns details of a DCI External Gateway within a Datacenter Blueprint.\n\n" + "At least one optional attribute is required.", Attributes: blueprint.DatacenterExternalGateway{}.DataSourceAttributes(), } diff --git a/apstra/data_source_datacenter_external_gateways.go b/apstra/data_source_datacenter_external_gateways.go new file mode 100644 index 00000000..3a3fbfe9 --- /dev/null +++ b/apstra/data_source_datacenter_external_gateways.go @@ -0,0 +1,157 @@ +package tfapstra + +import ( + "context" + "fmt" + "github.com/Juniper/apstra-go-sdk/apstra" + apstravalidator "github.com/Juniper/terraform-provider-apstra/apstra/apstra_validator" + "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" + "github.com/Juniper/terraform-provider-apstra/apstra/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSourceWithConfigure = &dataSourceDatacenterExternalGateways{} + +type dataSourceDatacenterExternalGateways struct { + client *apstra.Client +} + +func (o *dataSourceDatacenterExternalGateways) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_datacenter_external_gateways" +} + +func (o *dataSourceDatacenterExternalGateways) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + o.client = DataSourceGetClient(ctx, req, resp) +} + +func (o *dataSourceDatacenterExternalGateways) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: docCategoryDatacenter + "This data source returns Graph DB node IDs of DCI External Gateways within a Blueprint.\n\n" + + "Optional `filters` can be used to select only interesting External Gateways.", + Attributes: map[string]schema.Attribute{ + "blueprint_id": schema.StringAttribute{ + MarkdownDescription: "Apstra Blueprint to search.", + Required: true, + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "filters": schema.ListNestedAttribute{ + MarkdownDescription: "List of filters used to select only desired External Gateways. " + + "To match a filter, all specified attributes must match (each attribute within a " + + "filter is AND-ed together). The returned IDs represent the gateways matched by " + + "all of the filters together (filters are OR-ed together).", + Optional: true, + Validators: []validator.List{listvalidator.SizeAtLeast(1)}, + NestedObject: schema.NestedAttributeObject{ + Attributes: blueprint.DatacenterExternalGateway{}.DataSourceAttributesAsFilter(), + Validators: []validator.Object{ + apstravalidator.AtLeastNAttributes( + 1, + "id", "name", "ip_address", "asn", "ttl", + "keepalive_time", "hold_time", "evpn_route_types", + "local_gateway_nodes", "password", + ), + }, + }, + }, + "ids": schema.SetAttribute{ + MarkdownDescription: "IDs of matching `routing_policy` Graph DB nodes.", + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (o *dataSourceDatacenterExternalGateways) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config struct { + BlueprintId types.String `tfsdk:"blueprint_id"` + Filters types.List `tfsdk:"filters"` + Ids types.Set `tfsdk:"ids"` + } + + // Retrieve values from config. + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // extract filters from the config + var filters []blueprint.DatacenterExternalGateway + resp.Diagnostics.Append(config.Filters.ElementsAs(ctx, &filters, false)...) + if resp.Diagnostics.HasError() { + return + } + + // create a blueprint client + bp, err := o.client.NewTwoStageL3ClosClient(ctx, apstra.ObjectId(config.BlueprintId.ValueString())) + if err != nil { + if utils.IsApstra404(err) { + resp.Diagnostics.AddError(fmt.Sprintf("blueprint %s not found", + config.BlueprintId), err.Error()) + return + } + resp.Diagnostics.AddError(fmt.Sprintf(blueprint.ErrDCBlueprintCreate, config.BlueprintId), err.Error()) + return + } + + // collect all external gateways in the blueprint + apiResponse, err := bp.GetAllRemoteGateways(ctx) + if err != nil { + resp.Diagnostics.AddError("failed to fetch external gateways", err.Error()) + return + } + + // Did the user send any filters? + if len(filters) == 0 { // no filter shortcut! return all IDs without inspection + + // collect the IDs into config.Ids + ids := make([]attr.Value, len(apiResponse)) + for i, rgw := range apiResponse { + ids[i] = types.StringValue(rgw.Id.String()) + } + config.Ids = types.SetValueMust(types.StringType, ids) + + // set the state + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) + return + } + + // extract the API response items so that they can be filtered + externalGateways := make([]blueprint.DatacenterExternalGateway, len(apiResponse)) + for i := range apiResponse { + externalGateway := blueprint.DatacenterExternalGateway{Id: types.StringValue(apiResponse[i].Id.String())} + externalGateway.LoadApiData(ctx, apiResponse[i].Data, &resp.Diagnostics) + externalGateway.ReadProtocolPassword(ctx, bp, &resp.Diagnostics) + externalGateways[i] = externalGateway + } + if resp.Diagnostics.HasError() { + return + } + + // collect ids by applying each filter to each discovered routing policy. + var ids []attr.Value +candidateLoop: + for _, candidate := range externalGateways { + for _, filter := range filters { + if filter.FilterMatch(ctx, &candidate, &resp.Diagnostics) { + ids = append(ids, candidate.Id) + continue candidateLoop + } + } + } + + // pack the IDs into config.Ids + config.Ids = utils.SetValueOrNull(ctx, types.StringType, ids, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // set state + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} diff --git a/apstra/data_source_datacenter_routing_policies.go b/apstra/data_source_datacenter_routing_policies.go index f3fca087..a844ab5b 100644 --- a/apstra/data_source_datacenter_routing_policies.go +++ b/apstra/data_source_datacenter_routing_policies.go @@ -33,7 +33,7 @@ func (o *dataSourceDatacenterRoutingPolicies) Configure(ctx context.Context, req func (o *dataSourceDatacenterRoutingPolicies) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: docCategoryDatacenter + "This data source returns Graph DB node IDs of *routing_policy* nodes within a Blueprint.\n\n" + - "Optional `filters` can be used select only interesting nodes.", + "Optional `filters` can be used to select only interesting nodes.", Attributes: map[string]schema.Attribute{ "blueprint_id": schema.StringAttribute{ MarkdownDescription: "Apstra Blueprint to search.", diff --git a/apstra/provider.go b/apstra/provider.go index 25966757..a46b11b7 100644 --- a/apstra/provider.go +++ b/apstra/provider.go @@ -423,6 +423,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource func() datasource.DataSource { return &dataSourceDatacenterCtVnSingle{} }, func() datasource.DataSource { return &dataSourceDatacenterCtVnMultiple{} }, func() datasource.DataSource { return &dataSourceDatacenterExternalGateway{} }, + func() datasource.DataSource { return &dataSourceDatacenterExternalGateways{} }, func() datasource.DataSource { return &dataSourceDatacenterPropertySet{} }, func() datasource.DataSource { return &dataSourceDatacenterPropertySets{} }, func() datasource.DataSource { return &dataSourceDatacenterRoutingPolicies{} }, diff --git a/docs/data-sources/datacenter_external_gateway.md b/docs/data-sources/datacenter_external_gateway.md new file mode 100644 index 00000000..9cadd339 --- /dev/null +++ b/docs/data-sources/datacenter_external_gateway.md @@ -0,0 +1,49 @@ +--- +page_title: "apstra_datacenter_external_gateway Data Source - terraform-provider-apstra" +subcategory: "Reference Design: Datacenter" +description: |- + This resource returns details of a DCI External Gateway within a Datacenter Blueprint. + At least one optional attribute is required. +--- + +# apstra_datacenter_external_gateway (Data Source) + +This resource returns details of a DCI External Gateway within a Datacenter Blueprint. + +At least one optional attribute is required. + + +## Example Usage + +```terraform +# This example pulls details of the external gateway named "DC2A" +# from blueprint "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + +data "apstra_datacenter_external_gateway" "example" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + name = "DC2A" +} +``` + + +## Schema + +### Required + +- `blueprint_id` (String) Apstra ID of the Blueprint in which the External Gateway should be created. + +### Optional + +- `id` (String) Apstra Object ID. +- `name` (String) External Gateway name + +### Read-Only + +- `asn` (Number) External Gateway AS Number +- `evpn_route_types` (String) EVPN route types. Valid values are: ["all", "type5_only"]. Default: "all" +- `hold_time` (Number) BGP hold time (seconds). +- `ip_address` (String) External Gateway IP address +- `keepalive_time` (Number) BGP keepalive time (seconds). +- `local_gateway_nodes` (Set of String) Set of IDs of switch nodes which will be configured to peer with the External Gateway +- `password` (String, Sensitive) BGP TCP authentication password +- `ttl` (Number) BGP Time To Live. Omit to use device defaults. diff --git a/docs/data-sources/datacenter_external_gateways.md b/docs/data-sources/datacenter_external_gateways.md new file mode 100644 index 00000000..281d3668 --- /dev/null +++ b/docs/data-sources/datacenter_external_gateways.md @@ -0,0 +1,87 @@ +--- +page_title: "apstra_datacenter_external_gateways Data Source - terraform-provider-apstra" +subcategory: "Reference Design: Datacenter" +description: |- + This data source returns Graph DB node IDs of DCI External Gateways within a Blueprint. + Optional filters can be used to select only interesting External Gateways. +--- + +# apstra_datacenter_external_gateways (Data Source) + +This data source returns Graph DB node IDs of DCI External Gateways within a Blueprint. + +Optional `filters` can be used to select only interesting External Gateways. + + +## Example Usage + +```terraform +# This example collects the IDs of external gateways configured on all leaf +# switches, and external gateways configured on spine switches which share +# all route types. + +// find leaf switch IDs +data "apstra_datacenter_systems" "leaf_switches" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { system_type = "switch", role = "leaf" }, + ] +} + +// find spine switch IDs +data "apstra_datacenter_systems" "spine_switches" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { system_type = "switch", role = "spine" }, + ] +} + +// find interesting external gateway IDs +data "apstra_datacenter_external_gateways" "leaf_peers_and_spine_peers_with_all_routes" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { + local_gateway_nodes = data.apstra_datacenter_systems.leaf_switches.ids + }, + { + local_gateway_nodes = data.apstra_datacenter_systems.spine_switches.ids + evpn_route_types = "all" + }, + ] +} +``` + + +## Schema + +### Required + +- `blueprint_id` (String) Apstra Blueprint to search. + +### Optional + +- `filters` (Attributes List) List of filters used to select only desired External Gateways. To match a filter, all specified attributes must match (each attribute within a filter is AND-ed together). The returned IDs represent the gateways matched by all of the filters together (filters are OR-ed together). (see [below for nested schema](#nestedatt--filters)) + +### Read-Only + +- `ids` (Set of String) IDs of matching `routing_policy` Graph DB nodes. + + +### Nested Schema for `filters` + +Optional: + +- `asn` (Number) External Gateway AS Number +- `evpn_route_types` (String) EVPN route types. Valid values are: ["all", "type5_only"]. Default: "all" +- `hold_time` (Number) BGP hold time (seconds). +- `id` (String) Apstra Object ID. +- `ip_address` (String) External Gateway IP address +- `keepalive_time` (Number) BGP keepalive time (seconds). +- `local_gateway_nodes` (Set of String) Set of IDs of switch nodes which will be configured to peer with the External Gateway +- `name` (String) External Gateway name +- `password` (String, Sensitive) BGP TCP authentication password +- `ttl` (Number) BGP Time To Live. Omit to use device defaults. + +Read-Only: + +- `blueprint_id` (String) Not applicable in filter context. Ignore. diff --git a/docs/data-sources/datacenter_routing_policies.md b/docs/data-sources/datacenter_routing_policies.md index 2e45b140..3d236962 100644 --- a/docs/data-sources/datacenter_routing_policies.md +++ b/docs/data-sources/datacenter_routing_policies.md @@ -3,14 +3,14 @@ page_title: "apstra_datacenter_routing_policies Data Source - terraform-provider subcategory: "Reference Design: Datacenter" description: |- This data source returns Graph DB node IDs of routing_policy nodes within a Blueprint. - Optional filters can be used select only interesting nodes. + Optional filters can be used to select only interesting nodes. --- # apstra_datacenter_routing_policies (Data Source) This data source returns Graph DB node IDs of *routing_policy* nodes within a Blueprint. -Optional `filters` can be used select only interesting nodes. +Optional `filters` can be used to select only interesting nodes. ## Example Usage diff --git a/docs/resources/datacenter_external_gateway.md b/docs/resources/datacenter_external_gateway.md new file mode 100644 index 00000000..4233f1a1 --- /dev/null +++ b/docs/resources/datacenter_external_gateway.md @@ -0,0 +1,57 @@ +--- +page_title: "apstra_datacenter_external_gateway Resource - terraform-provider-apstra" +subcategory: "Reference Design: Datacenter" +description: |- + This resource creates a DCI External Gateway within a Blueprint. Prior to Apstra 4.2 these were called "Remote EVPN Gateways" +--- + +# apstra_datacenter_external_gateway (Resource) + +This resource creates a DCI External Gateway within a Blueprint. Prior to Apstra 4.2 these were called "Remote EVPN Gateways" + + +## Example Usage + +```terraform +# This example creates an "over the top" DCI External Gateway. +# Note: Prior to Apstra 4.2 these were known as "Remote EVPN Gateways" + +resource "apstra_datacenter_external_gateway" "example" { + blueprint_id = "b4c4ed6a-9c6a-4577-b3d4-78705c08a272" + name = "example gateway" + ip_address = "192.0.2.1" + asn = 64510 + evpn_route_types = "all" # "all" or "type5_only" + ttl = 10 + keepalive_time = 3 + hold_time = 9 + password = "big secret" + local_gateway_nodes = [ + "JGcTJy_jP4898Z13WHU", // use apstra_datacenter_systems data + "Fx-fVa7t_LYp7JtQ_nU", // source to find node IDs + ] +} +``` + + +## Schema + +### Required + +- `asn` (Number) External Gateway AS Number +- `blueprint_id` (String) Apstra ID of the Blueprint in which the External Gateway should be created. +- `ip_address` (String) External Gateway IP address +- `local_gateway_nodes` (Set of String) Set of IDs of switch nodes which will be configured to peer with the External Gateway +- `name` (String) External Gateway name + +### Optional + +- `evpn_route_types` (String) EVPN route types. Valid values are: ["all", "type5_only"]. Default: "all" +- `hold_time` (Number) BGP hold time (seconds). +- `keepalive_time` (Number) BGP keepalive time (seconds). +- `password` (String, Sensitive) BGP TCP authentication password +- `ttl` (Number) BGP Time To Live. Omit to use device defaults. + +### Read-Only + +- `id` (String) Apstra Object ID. diff --git a/examples/data-sources/apstra_datacenter_external_gateway/example.tf b/examples/data-sources/apstra_datacenter_external_gateway/example.tf new file mode 100644 index 00000000..e383570f --- /dev/null +++ b/examples/data-sources/apstra_datacenter_external_gateway/example.tf @@ -0,0 +1,7 @@ +# This example pulls details of the external gateway named "DC2A" +# from blueprint "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + +data "apstra_datacenter_external_gateway" "example" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + name = "DC2A" +} diff --git a/examples/data-sources/apstra_datacenter_external_gateways/example.tf b/examples/data-sources/apstra_datacenter_external_gateways/example.tf new file mode 100644 index 00000000..f2e7079d --- /dev/null +++ b/examples/data-sources/apstra_datacenter_external_gateways/example.tf @@ -0,0 +1,33 @@ +# This example collects the IDs of external gateways configured on all leaf +# switches, and external gateways configured on spine switches which share +# all route types. + +// find leaf switch IDs +data "apstra_datacenter_systems" "leaf_switches" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { system_type = "switch", role = "leaf" }, + ] +} + +// find spine switch IDs +data "apstra_datacenter_systems" "spine_switches" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { system_type = "switch", role = "spine" }, + ] +} + +// find interesting external gateway IDs +data "apstra_datacenter_external_gateways" "leaf_peers_and_spine_peers_with_all_routes" { + blueprint_id = "007723b7-a387-4bb3-8a5e-b5e9f265de0d" + filters = [ + { + local_gateway_nodes = data.apstra_datacenter_systems.leaf_switches.ids + }, + { + local_gateway_nodes = data.apstra_datacenter_systems.spine_switches.ids + evpn_route_types = "all" + }, + ] +} From f2010bc1696dce57283aa259b2e539c0dbc67440 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Mon, 6 Nov 2023 11:26:46 -0500 Subject: [PATCH 8/9] convert import ID string from delimited values to JSON --- apstra/constants.go | 1 + apstra/helpers.go | 29 ------ apstra/helpers_test.go | 92 ------------------- .../resource_datacenter_external_gateway.go | 29 ++++-- docs/resources/agent_profile.md | 3 + docs/resources/asn_pool.md | 3 + docs/resources/blueprint_deployment.md | 3 + docs/resources/blueprint_iba_dashboard.md | 3 + docs/resources/blueprint_iba_probe.md | 3 + docs/resources/blueprint_iba_widget.md | 3 + docs/resources/configlet.md | 3 + docs/resources/datacenter_blueprint.md | 3 + docs/resources/datacenter_configlet.md | 3 + .../datacenter_connectivity_template.md | 3 + ...center_connectivity_template_assignment.md | 3 + .../resources/datacenter_device_allocation.md | 3 + docs/resources/datacenter_external_gateway.md | 30 ++++++ docs/resources/datacenter_generic_system.md | 3 + docs/resources/datacenter_property_set.md | 3 + .../datacenter_resource_pool_allocation.md | 3 + docs/resources/datacenter_routing_policy.md | 3 + docs/resources/datacenter_routing_zone.md | 3 + docs/resources/datacenter_virtual_network.md | 3 + docs/resources/integer_pool.md | 3 + docs/resources/interface_map.md | 3 + docs/resources/ipv4_pool.md | 3 + docs/resources/ipv6_pool.md | 3 + docs/resources/logical_device.md | 3 + docs/resources/managed_device.md | 3 + docs/resources/managed_device_ack.md | 3 + docs/resources/modular_device_profile.md | 3 + docs/resources/property_set.md | 3 + docs/resources/rack_type.md | 3 + docs/resources/tag.md | 3 + docs/resources/template_rack_based.md | 3 + docs/resources/vni_pool.md | 3 + .../import.sh | 23 +++++ go.mod | 2 +- go.sum | 4 +- templates/resources.md.tmpl | 7 ++ 40 files changed, 177 insertions(+), 133 deletions(-) create mode 100644 examples/resources/apstra_datacenter_external_gateway/import.sh diff --git a/apstra/constants.go b/apstra/constants.go index ff277534..ca5f73f7 100644 --- a/apstra/constants.go +++ b/apstra/constants.go @@ -16,6 +16,7 @@ const ( errTemplateTypeInvalidElement = "template '%s' has type '%s' which never permits '%s' to be set" errDataSourceReadFail = "Data Source Read() failure'" errResourceReadFail = "Resource Read() failure'" + errImportJsonMissingRequiredField = "Import ID JSON missing required field" docCategorySeparator = " --- " docCategoryDesign = "Design" + docCategorySeparator diff --git a/apstra/helpers.go b/apstra/helpers.go index 5bd53b91..3657e05c 100644 --- a/apstra/helpers.go +++ b/apstra/helpers.go @@ -1,12 +1,9 @@ package tfapstra import ( - "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/diag" "golang.org/x/exp/constraints" "math/rand" - "strings" "unsafe" ) @@ -41,29 +38,3 @@ func FillWithRandomIntegers[A constraints.Integer](a []A) { a[i] = A(rand.Uint64()) } } - -// SplitImportId splits string 'in' into len(fieldNames) strings using the first character as -// a separator for the fields. fieldNames is used mainly for its length. The values in fieldNames -// are ignored unless there's a need to print an error message. -func SplitImportId(_ context.Context, in string, fieldNames []string, diags *diag.Diagnostics) []string { - if len(in) < 2 { - diags.AddError("invalid import ID", "import ID minimum length is 2") - return nil - } - - sep := in[:1] - parts := strings.Split(in[:], sep)[1:] - - if len(fieldNames) != len(parts) { - form := "<" + strings.Join(fieldNames, "><") + ">" - diags.AddError( - fmt.Sprintf("cannot parse import ID: %q", in), - fmt.Sprintf("ID string for resource import must take this form:\n\n"+ - " %s\n\n"+ - "where is any single character not found in any of the delimited fields. "+ - "Expected %d parts after splitting on '%s', got %d parts", form, len(fieldNames), sep, len(parts))) - return nil - } - - return parts -} diff --git a/apstra/helpers_test.go b/apstra/helpers_test.go index 01f17745..370d04e3 100644 --- a/apstra/helpers_test.go +++ b/apstra/helpers_test.go @@ -1,10 +1,7 @@ package tfapstra import ( - "context" "github.com/Juniper/terraform-provider-apstra/apstra/utils" - "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/diag" "golang.org/x/exp/constraints" "testing" ) @@ -170,92 +167,3 @@ func TestRandomIntegers(t *testing.T) { t.Fail() } } - -func TestSplitImportId(t *testing.T) { - ctx := context.Background() - t.Parallel() - - type testCase struct { - in string - fields []string - expected []string - expectedDiags diag.Diagnostics - } - - testCases := map[string]testCase{ - "|1": { - in: "|foo", - fields: []string{"foo"}, - expected: []string{"foo"}, - }, - ".2": { - in: ".foo.bar", - fields: []string{"foo", "bar"}, - expected: []string{"foo", "bar"}, - }, - "nil": { - in: "", - fields: []string{}, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "invalid import ID", - "import ID minimum length is 2", - ), - }, - }, - "empty": { - in: "", - fields: []string{}, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "invalid import ID", - "import ID minimum length is 2", - ), - }, - }, - "too short": { - in: ".", - fields: []string{}, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "invalid import ID", - "import ID minimum length is 2", - ), - }, - }, - "fail embedded separator": { - in: ".abc.def.ghi", - fields: []string{"abc", "defghi"}, - expectedDiags: diag.Diagnostics{ - diag.NewErrorDiagnostic( - `cannot parse import ID: ".abc.def.ghi"`, - "ID string for resource import must take this form:\n\n"+ - " \n\n"+ - "where is any single character not found in any of the delimited fields. "+ - "Expected 2 parts after splitting on '.', got 3 parts", - ), - }, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - var diags diag.Diagnostics - - t.Run(name, func(t *testing.T) { - t.Parallel() - - parts := SplitImportId(ctx, testCase.in, testCase.fields, &diags) - - if diff := cmp.Diff(testCase.expectedDiags, diags); diff != "" { - t.Fatalf("Unexpected diagnostics (-expected ,+got): %s", diff) - } - - if len(testCase.expectedDiags) == 0 { - if diff := cmp.Diff(testCase.expected, parts); diff != "" { - t.Fatalf("Unexpected result (-expected ,+got): %s", diff) - } - } - }) - } -} diff --git a/apstra/resource_datacenter_external_gateway.go b/apstra/resource_datacenter_external_gateway.go index 2ac1c79c..411876fc 100644 --- a/apstra/resource_datacenter_external_gateway.go +++ b/apstra/resource_datacenter_external_gateway.go @@ -2,6 +2,7 @@ package tfapstra import ( "context" + "encoding/json" "fmt" "github.com/Juniper/apstra-go-sdk/apstra" "github.com/Juniper/terraform-provider-apstra/apstra/blueprint" @@ -37,22 +38,32 @@ func (o *resourceDatacenterExternalGateway) Schema(_ context.Context, _ resource } func (o *resourceDatacenterExternalGateway) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - // req.ID takes the form: "blueprint_id:external_gateway_id" - fieldNames := []string{ - "blueprint_id", - "external_gateway_id", + var importId struct { + BlueprintId string `json:"blueprint_id"` + ExternalGatewayId string `json:"external_gateway_id"` } - // split the supplied ID into the required fields - parts := SplitImportId(ctx, req.ID, fieldNames, &resp.Diagnostics) - if resp.Diagnostics.HasError() { + // parse the user-supplied import ID string JSON + err := json.Unmarshal([]byte(req.ID), &importId) + if err != nil { + resp.Diagnostics.AddError("failed parsing import id JSON string", err.Error()) + return + } + + if importId.BlueprintId == "" { + resp.Diagnostics.AddError(errImportJsonMissingRequiredField, fmt.Sprintf("'blueprint_id element of import ID string cannot be empty")) + return + } + + if importId.ExternalGatewayId == "" { + resp.Diagnostics.AddError(errImportJsonMissingRequiredField, fmt.Sprintf("'external_gateway_id' element of import ID string cannot be empty")) return } // create a state object preloaded with the critical details we need in advance state := blueprint.DatacenterExternalGateway{ - BlueprintId: types.StringValue(parts[0]), - Id: types.StringValue(parts[1]), + BlueprintId: types.StringValue(importId.BlueprintId), + Id: types.StringValue(importId.ExternalGatewayId), } // create a client for the datacenter reference design diff --git a/docs/resources/agent_profile.md b/docs/resources/agent_profile.md index fcbbdb5c..0e03b1a8 100644 --- a/docs/resources/agent_profile.md +++ b/docs/resources/agent_profile.md @@ -46,3 +46,6 @@ resource "apstra_agent_profile" "profile_with_options" { - `has_password` (Boolean) Indicates whether a password has been set. - `has_username` (Boolean) Indicates whether a username has been set. - `id` (String) Apstra ID of the Agent Profile. + + + diff --git a/docs/resources/asn_pool.md b/docs/resources/asn_pool.md index f1b9c9da..c5efe378 100644 --- a/docs/resources/asn_pool.md +++ b/docs/resources/asn_pool.md @@ -59,3 +59,6 @@ Read-Only: - `total` (Number) Total number of ASNs in the ASN Pool Range. - `used` (Number) Count of used ASNs in the ASN Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. - `used_percentage` (Number) Percent of used ASNs in the ASN Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. + + + diff --git a/docs/resources/blueprint_deployment.md b/docs/resources/blueprint_deployment.md index 6aecbb3b..26c9456c 100644 --- a/docs/resources/blueprint_deployment.md +++ b/docs/resources/blueprint_deployment.md @@ -107,3 +107,6 @@ resource "apstra_blueprint_deployment" "deploy" { - `has_uncommitted_changes` (Boolean) True when there are uncommited changes in the staging Blueprint. - `revision_active` (Number) Revision numbers increment with each Blueprint change. This is the currently deployed revision number. - `revision_staged` (Number) Revision numbers increment with each Blueprint change. This is the revision number currently in staging. + + + diff --git a/docs/resources/blueprint_iba_dashboard.md b/docs/resources/blueprint_iba_dashboard.md index a91e7629..18fc4042 100644 --- a/docs/resources/blueprint_iba_dashboard.md +++ b/docs/resources/blueprint_iba_dashboard.md @@ -79,3 +79,6 @@ resource "apstra_blueprint_iba_dashboard" "b" { - `id` (String) IBA Dashboard ID. - `predefined_dashboard` (String) Id of predefined IBA Dashboard if any - `updated_by` (String) The user who updated the IBA Dashboard last + + + diff --git a/docs/resources/blueprint_iba_probe.md b/docs/resources/blueprint_iba_probe.md index 72ec5412..9db73bf1 100644 --- a/docs/resources/blueprint_iba_probe.md +++ b/docs/resources/blueprint_iba_probe.md @@ -79,3 +79,6 @@ output "o"{ - `id` (String) IBA Probe ID. - `name` (String) IBA Probe Name. - `stages` (Set of String) Set of names of stages in the IBA Probe + + + diff --git a/docs/resources/blueprint_iba_widget.md b/docs/resources/blueprint_iba_widget.md index 8b975d3c..6b1ce228 100644 --- a/docs/resources/blueprint_iba_widget.md +++ b/docs/resources/blueprint_iba_widget.md @@ -74,3 +74,6 @@ output "o"{ ### Read-Only - `id` (String) IBA Widget ID + + + diff --git a/docs/resources/configlet.md b/docs/resources/configlet.md index 3220b9f0..a32e1dfa 100644 --- a/docs/resources/configlet.md +++ b/docs/resources/configlet.md @@ -71,3 +71,6 @@ Optional: - `filename` (String) FileName - `negation_template_text` (String) Negation Template Text + + + diff --git a/docs/resources/datacenter_blueprint.md b/docs/resources/datacenter_blueprint.md index 6551504e..56080878 100644 --- a/docs/resources/datacenter_blueprint.md +++ b/docs/resources/datacenter_blueprint.md @@ -119,3 +119,6 @@ resource "apstra_blueprint_deployment" "deploy" { - `status` (String) Deployment status of the Blueprint - `superspine_count` (Number) For 5-stage topologies, the count of superspine devices - `version` (Number) Currently active blueprint version + + + diff --git a/docs/resources/datacenter_configlet.md b/docs/resources/datacenter_configlet.md index 55b15b80..4a06d38c 100644 --- a/docs/resources/datacenter_configlet.md +++ b/docs/resources/datacenter_configlet.md @@ -191,3 +191,6 @@ Optional: - `filename` (String) FileName - `negation_template_text` (String) Negation Template Text + + + diff --git a/docs/resources/datacenter_connectivity_template.md b/docs/resources/datacenter_connectivity_template.md index e277d302..75382367 100644 --- a/docs/resources/datacenter_connectivity_template.md +++ b/docs/resources/datacenter_connectivity_template.md @@ -99,3 +99,6 @@ resource "apstra_datacenter_connectivity_template" "t" { ### Read-Only - `id` (String) Apstra Object ID. + + + diff --git a/docs/resources/datacenter_connectivity_template_assignment.md b/docs/resources/datacenter_connectivity_template_assignment.md index 1c093eca..04c205dd 100644 --- a/docs/resources/datacenter_connectivity_template_assignment.md +++ b/docs/resources/datacenter_connectivity_template_assignment.md @@ -40,3 +40,6 @@ resource "apstra_datacenter_connectivity_template_assignment" "a" { - `application_point_id` (String) Apstra node ID of the Interface or System where the Connectivity Template should be applied. - `blueprint_id` (String) Apstra Blueprint ID. - `connectivity_template_ids` (Set of String) Set of Connectivity Template IDs which should be applied to the Application Point. + + + diff --git a/docs/resources/datacenter_device_allocation.md b/docs/resources/datacenter_device_allocation.md index 9e5a7e71..32733220 100644 --- a/docs/resources/datacenter_device_allocation.md +++ b/docs/resources/datacenter_device_allocation.md @@ -92,3 +92,6 @@ resource "apstra_datacenter_device_allocation" "r" { - `device_profile_node_id` (String) Device Profiles specify attributes of specific hardware models. - `interface_map_name` (String) The Interface Map Name is recorded only at creation time toaid in detection of changes to the Interface Map made outside of Terraform. - `node_id` (String) Graph node ID of the fabric node to which we're allocating an Interface Map (and possibly a Managed Device.) + + + diff --git a/docs/resources/datacenter_external_gateway.md b/docs/resources/datacenter_external_gateway.md index 4233f1a1..bc592b16 100644 --- a/docs/resources/datacenter_external_gateway.md +++ b/docs/resources/datacenter_external_gateway.md @@ -55,3 +55,33 @@ resource "apstra_datacenter_external_gateway" "example" { ### Read-Only - `id` (String) Apstra Object ID. + + + +## Import + +```shell +# Importing a apstra_datacenter_external_gateway requires expressing both +# the blueprint ID and the external gateway ID in a JSON document: +# +# { +# "blueprint_id": "007723b7-a387-4bb3-8a5e-b5e9f265de0d", +# "external_gateway_id": "3zxDY0C8M0Y2m-xQFJQ" +# } + +# Legacy import: + +echo 'resource "apstra_datacenter_external_gateway" "legacy_import" {}' >> legacy_import.tf +terraform import 'apstra_datacenter_external_gateway.legacy_import' '{"blueprint_id":"007723b7-a387-4bb3-8a5e-b5e9f265de0d","external_gateway_id":"3zxDY0C8M0Y2m-xQFJQ"}' + +# Terraform 1.5+ block import: + +cat >> block_import.tf << EOF +import { + to = apstra_datacenter_external_gateway.imported + id = "{\"blueprint_id\":\"007723b7-a387-4bb3-8a5e-b5e9f265de0d\",\"external_gateway_id\":\"3zxDY0C8M0Y2m-xQFJQ\"}" +} +EOF +terraform plan -generate-config-out=generated.tf +terraform apply +``` diff --git a/docs/resources/datacenter_generic_system.md b/docs/resources/datacenter_generic_system.md index ab8f31a7..90318524 100644 --- a/docs/resources/datacenter_generic_system.md +++ b/docs/resources/datacenter_generic_system.md @@ -109,3 +109,6 @@ Optional: - `group_label` (String) This field is used to collect multiple links into aggregation groups. For example, to create two LAG pairs from four physical links, you might use `group_label` value "bond0" on two links and "bond1" on the other two links. Apstra assigns a unique group ID to each link by default. - `lag_mode` (String) LAG negotiation mode of the Link. All links with the same `group_label` must use the value. - `tags` (Set of String) Names of Tag to be applied to this Link. If a Tag doesn't exist in the Blueprint it will be created automatically. + + + diff --git a/docs/resources/datacenter_property_set.md b/docs/resources/datacenter_property_set.md index 190c0a3d..9a1807e6 100644 --- a/docs/resources/datacenter_property_set.md +++ b/docs/resources/datacenter_property_set.md @@ -58,3 +58,6 @@ output "p" { - `data` (String) A map of values in the Property Set in JSON format. - `name` (String) Property Set name as shown in the Web UI. - `stale` (Boolean) Stale as reported in the Web UI. + + + diff --git a/docs/resources/datacenter_resource_pool_allocation.md b/docs/resources/datacenter_resource_pool_allocation.md index 342a4059..9438b45f 100644 --- a/docs/resources/datacenter_resource_pool_allocation.md +++ b/docs/resources/datacenter_resource_pool_allocation.md @@ -70,3 +70,6 @@ resource "apstra_datacenter_resource_pool_allocation" "ipv4" { ### Optional - `routing_zone_id` (String) Used to allocate a Resource Pool to a `role` associated with specific Routing Zone within a Blueprint, rather than to a fabric-wide `role`. `leaf_loopback_ips` and `virtual_network_svi_subnets` are examples of roles which can be allocaated to a specific Routing Zone. When omitted, the specified Resource Pools are allocated to a fabric-wide `role`. + + + diff --git a/docs/resources/datacenter_routing_policy.md b/docs/resources/datacenter_routing_policy.md index 20fa4f70..4806c243 100644 --- a/docs/resources/datacenter_routing_policy.md +++ b/docs/resources/datacenter_routing_policy.md @@ -187,3 +187,6 @@ Optional: - `action` (String) If the action is "permit", match the route. If the action is "deny", do not match the route. For composing complex policies, all prefix-list items will be processed in the order specified, top-down. This allows the user to deny a subset of a route that may otherwise be permitted. - `ge_mask` (Number) Match less-specific prefixes from a parent prefix, up from `ge_mask` to the prefix length of the route. Range is 0-32 for IPv4, 0-128 for IPv6. If not specified, implies the prefix-list entry should be an exact match. The option can be optionally be used in combination with `le_mask`. `ge_mask` must be longer than the subnet prefix length. If `le_mask` and `ge_mask` are both specified, then `le_mask` must be greater than `ge_mask`. - `le_mask` (Number) Match more-specific prefixes from a parent prefix, up until `le_mask` prefix len. Range is 0-32 for IPv4, 0-128 for IPv6. If not specified, implies the prefix-list entry should be an exact match. The option can be optionally be used in combination with `ge_mask`. `le_mask` must be longer than the subnet prefix length. If `le_mask` and `ge_mask` are both specified, then `le_mask` must be greater than `ge_mask`. + + + diff --git a/docs/resources/datacenter_routing_zone.md b/docs/resources/datacenter_routing_zone.md index 8c862f3b..948b503e 100644 --- a/docs/resources/datacenter_routing_zone.md +++ b/docs/resources/datacenter_routing_zone.md @@ -47,3 +47,6 @@ resource "apstra_datacenter_routing_zone" "blue" { - `had_prior_vlan_id_config` (Boolean) Used to trigger plan modification when `vlan_id` has been removed from the configuration, this attribute can be ignored. - `had_prior_vni_config` (Boolean) Used to trigger plan modification when `vni` has been removed from the configuration, this attribute can be ignored. - `id` (String) Apstra graph node ID. + + + diff --git a/docs/resources/datacenter_virtual_network.md b/docs/resources/datacenter_virtual_network.md index ed831299..ed4def58 100644 --- a/docs/resources/datacenter_virtual_network.md +++ b/docs/resources/datacenter_virtual_network.md @@ -82,3 +82,6 @@ Optional: - `access_ids` (Set of String) The graph db node ID of the access switch `system` node (nonredundant access switch) or `redundancy_group` node (ESI LAG access switches) beneath `leaf_id` to which this VN should be bound. - `vlan_id` (Number) When not specified, Apstra will choose the VLAN to be used on each switch. + + + diff --git a/docs/resources/integer_pool.md b/docs/resources/integer_pool.md index 7be7106a..92b7f1a6 100644 --- a/docs/resources/integer_pool.md +++ b/docs/resources/integer_pool.md @@ -96,3 +96,6 @@ Read-Only: - `total` (Number) Total number of Integers in the Integer Pool Range. - `used` (Number) Count of used IDs in the Integers Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. - `used_percentage` (Number) Percent of used IDs in the Integers Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. + + + diff --git a/docs/resources/interface_map.md b/docs/resources/interface_map.md index 52c19b2c..9e002ec4 100644 --- a/docs/resources/interface_map.md +++ b/docs/resources/interface_map.md @@ -145,3 +145,6 @@ Read-Only: - `logical_device_port` (String) Panel and Port number of logical device expressed in the form "/". Both numbers are 1-indexed, so the 2nd port on the 1st panel would be "1/2". - `physical_interface_name` (String) Interface name found in the Device Profile, e.g. "et-0/0/1:2" - `transformation_id` (Number) Transformation ID number identifying the desired port behavior, as found in the Device Profile. + + + diff --git a/docs/resources/ipv4_pool.md b/docs/resources/ipv4_pool.md index 306e4d5f..0612fd67 100644 --- a/docs/resources/ipv4_pool.md +++ b/docs/resources/ipv4_pool.md @@ -91,3 +91,6 @@ Read-Only: - `total` (Number) Total number of addresses in this IPv4 range. - `used` (Number) Count of used addresses in this IPv4 range. - `used_percentage` (Number) Percent of used addresses in this IPv4 range. + + + diff --git a/docs/resources/ipv6_pool.md b/docs/resources/ipv6_pool.md index d227afb3..6185e6bc 100644 --- a/docs/resources/ipv6_pool.md +++ b/docs/resources/ipv6_pool.md @@ -91,3 +91,6 @@ Read-Only: - `total` (Number) Total number of addresses in this IPv6 range. - `used` (Number) Count of used addresses in this IPv6 range. - `used_percentage` (Number) Percent of used addresses in this IPv6 range. + + + diff --git a/docs/resources/logical_device.md b/docs/resources/logical_device.md index 9af180fc..41aa2d0c 100644 --- a/docs/resources/logical_device.md +++ b/docs/resources/logical_device.md @@ -70,3 +70,6 @@ Required: Optional: - `port_roles` (Set of String) One or more of: 'spine', 'superspine', 'leaf', 'access', 'l3_server', 'peer', 'unused', 'generic', by default all values except 'unused' are selected + + + diff --git a/docs/resources/managed_device.md b/docs/resources/managed_device.md index babf53a1..827a6dbb 100644 --- a/docs/resources/managed_device.md +++ b/docs/resources/managed_device.md @@ -71,3 +71,6 @@ resource "apstra_managed_device" "example" { - `agent_id` (String) Apstra ID for the Managed Device Agent. - `system_id` (String) Apstra ID for the System onboarded by the Managed Device Agent. + + + diff --git a/docs/resources/managed_device_ack.md b/docs/resources/managed_device_ack.md index d20e56a2..b4508de9 100644 --- a/docs/resources/managed_device_ack.md +++ b/docs/resources/managed_device_ack.md @@ -35,3 +35,6 @@ resource "apstra_managed_device_ack" "spine1" { ### Read-Only - `system_id` (String) Apstra ID for the System discovered by the System Agent. + + + diff --git a/docs/resources/modular_device_profile.md b/docs/resources/modular_device_profile.md index 3e742df3..71be9a42 100644 --- a/docs/resources/modular_device_profile.md +++ b/docs/resources/modular_device_profile.md @@ -37,3 +37,6 @@ resource "apstra_modular_device_profile" "example" { ### Read-Only - `id` (String) Apstra Object ID. + + + diff --git a/docs/resources/property_set.md b/docs/resources/property_set.md index cc5c6308..e9c1a9fc 100644 --- a/docs/resources/property_set.md +++ b/docs/resources/property_set.md @@ -48,3 +48,6 @@ resource "apstra_property_set" "r" { - `blueprints` (Set of String) Set of blueprints that this Property Set might be associated with. - `id` (String) Populate this field to look up a Property Set by ID. Required when `name` is omitted. - `keys` (Set of String) Set of keys defined in the Property Set. + + + diff --git a/docs/resources/rack_type.md b/docs/resources/rack_type.md index d321036a..aafebf73 100644 --- a/docs/resources/rack_type.md +++ b/docs/resources/rack_type.md @@ -356,3 +356,6 @@ Read-Only: - `description` (String) Tag description field as seen in the web UI. - `id` (String) ID will always be `` in nested contexts. - `name` (String) Tag name field as seen in the web UI. + + + diff --git a/docs/resources/tag.md b/docs/resources/tag.md index fae4590b..7d775b6c 100644 --- a/docs/resources/tag.md +++ b/docs/resources/tag.md @@ -45,3 +45,6 @@ resource "apstra_tag" "example" { ### Read-Only - `id` (String) Apstra ID of the Tag. + + + diff --git a/docs/resources/template_rack_based.md b/docs/resources/template_rack_based.md index 3c9b53e3..abedf8c3 100644 --- a/docs/resources/template_rack_based.md +++ b/docs/resources/template_rack_based.md @@ -401,3 +401,6 @@ Read-Only: - `description` (String) Tag description field as seen in the web UI. - `id` (String) ID will always be `` in nested contexts. - `name` (String) Tag name field as seen in the web UI. + + + diff --git a/docs/resources/vni_pool.md b/docs/resources/vni_pool.md index d04f5373..4996421e 100644 --- a/docs/resources/vni_pool.md +++ b/docs/resources/vni_pool.md @@ -81,3 +81,6 @@ Read-Only: - `total` (Number) Total number of IDs in the VNI Pool Range. - `used` (Number) Count of used IDs in the VNI Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. - `used_percentage` (Number) Percent of used IDs in the VNI Pool Range.Note that this element is probably better read from a `data` source because it will be more up-to-date. + + + diff --git a/examples/resources/apstra_datacenter_external_gateway/import.sh b/examples/resources/apstra_datacenter_external_gateway/import.sh new file mode 100644 index 00000000..2a498384 --- /dev/null +++ b/examples/resources/apstra_datacenter_external_gateway/import.sh @@ -0,0 +1,23 @@ +# Importing a apstra_datacenter_external_gateway requires expressing both +# the blueprint ID and the external gateway ID in a JSON document: +# +# { +# "blueprint_id": "007723b7-a387-4bb3-8a5e-b5e9f265de0d", +# "external_gateway_id": "3zxDY0C8M0Y2m-xQFJQ" +# } + +# Legacy import: + +echo 'resource "apstra_datacenter_external_gateway" "legacy_import" {}' >> legacy_import.tf +terraform import 'apstra_datacenter_external_gateway.legacy_import' '{"blueprint_id":"007723b7-a387-4bb3-8a5e-b5e9f265de0d","external_gateway_id":"3zxDY0C8M0Y2m-xQFJQ"}' + +# Terraform 1.5+ block import: + +cat >> block_import.tf << EOF +import { + to = apstra_datacenter_external_gateway.imported + id = "{\"blueprint_id\":\"007723b7-a387-4bb3-8a5e-b5e9f265de0d\",\"external_gateway_id\":\"3zxDY0C8M0Y2m-xQFJQ\"}" +} +EOF +terraform plan -generate-config-out=generated.tf +terraform apply diff --git a/go.mod b/go.mod index cfc6c6d7..aa8e0781 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.20 require ( github.com/IBM/netaddr v1.5.0 - github.com/Juniper/apstra-go-sdk v0.0.0-20231104035306-7705f4b238ff + github.com/Juniper/apstra-go-sdk v0.0.0-20231106162301-77abd5f33085 github.com/chrismarget-j/go-licenses v0.0.0-20230424163011-d60082a506e0 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-version v1.6.0 diff --git a/go.sum b/go.sum index 043b45c8..910f673f 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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-20231104035306-7705f4b238ff h1:nn0MliXN2+J8YP1LzpL3duDxt4qDJeXcifgpLViMG4w= -github.com/Juniper/apstra-go-sdk v0.0.0-20231104035306-7705f4b238ff/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= +github.com/Juniper/apstra-go-sdk v0.0.0-20231106162301-77abd5f33085 h1:g1J8ViORnnZHXZoGLPRKQgXNlzRaVCGQ7bW1tq2D63s= +github.com/Juniper/apstra-go-sdk v0.0.0-20231106162301-77abd5f33085/go.mod h1:BB8X+PSov7CoCIAQ5P1z8bHc7DwtDN51fWCLJ93oeHY= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= diff --git a/templates/resources.md.tmpl b/templates/resources.md.tmpl index 7bed939b..bcc54f48 100644 --- a/templates/resources.md.tmpl +++ b/templates/resources.md.tmpl @@ -26,3 +26,10 @@ description: |- {{ tffile (printf "examples/resources/%s/example.tf" .Name)}} {{ .SchemaMarkdown | trimspace }} + + +{{ if .HasImport }} +## Import + +{{ codefile "shell" .ImportFile }} +{{- end }} From ceae18a5392d10cad1e89d683b75bb64ae356cd8 Mon Sep 17 00:00:00 2001 From: Chris Marget Date: Mon, 6 Nov 2023 11:30:00 -0500 Subject: [PATCH 9/9] eliminate unnecessary string formatter call --- apstra/resource_datacenter_external_gateway.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apstra/resource_datacenter_external_gateway.go b/apstra/resource_datacenter_external_gateway.go index 411876fc..7e713f4f 100644 --- a/apstra/resource_datacenter_external_gateway.go +++ b/apstra/resource_datacenter_external_gateway.go @@ -51,12 +51,12 @@ func (o *resourceDatacenterExternalGateway) ImportState(ctx context.Context, req } if importId.BlueprintId == "" { - resp.Diagnostics.AddError(errImportJsonMissingRequiredField, fmt.Sprintf("'blueprint_id element of import ID string cannot be empty")) + resp.Diagnostics.AddError(errImportJsonMissingRequiredField, "'blueprint_id element of import ID string cannot be empty") return } if importId.ExternalGatewayId == "" { - resp.Diagnostics.AddError(errImportJsonMissingRequiredField, fmt.Sprintf("'external_gateway_id' element of import ID string cannot be empty")) + resp.Diagnostics.AddError(errImportJsonMissingRequiredField, "'external_gateway_id' element of import ID string cannot be empty") return }