diff --git a/Third_Party_Code/NOTICES.md b/Third_Party_Code/NOTICES.md
index e8e33aa5..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)
@@ -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/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
new file mode 100644
index 00000000..2baa1ad1
--- /dev/null
+++ b/apstra/blueprint/datacenter_external_gateway.go
@@ -0,0 +1,478 @@
+package blueprint
+
+import (
+ "context"
+ "fmt"
+ "github.com/Juniper/apstra-go-sdk/apstra"
+ "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"
+ "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"
+ "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 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"`
+ Password types.String `tfsdk:"password"`
+}
+
+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: iptypes.IPv4AddressType{},
+ },
+ "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,
+ ElementType: types.StringType,
+ Validators: []validator.Set{
+ setvalidator.SizeAtLeast(1),
+ setvalidator.ValueStringsAre(stringvalidator.LengthAtLeast(1)),
+ },
+ },
+ "password": resourceSchema.StringAttribute{
+ MarkdownDescription: "BGP TCP authentication password",
+ Optional: true,
+ Validators: []validator.String{stringvalidator.LengthAtLeast(1)},
+ 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,
+ },
+ }
+}
+
+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
+
+ var localGwNodes []apstra.ObjectId
+ diags.Append(o.LocalGatewayNodes.ElementsAs(ctx, &localGwNodes, false)...)
+ if diags.HasError() {
+ return nil
+ }
+
+ 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
+ }
+
+ var password *string
+ if utils.Known(o.Password) {
+ t := o.Password.ValueString()
+ password = &t
+ }
+
+ 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,
+ Password: password,
+ }
+}
+
+func (o *DatacenterExternalGateway) Read(ctx context.Context, bp *apstra.TwoStageL3ClosClient, diags *diag.Diagnostics) {
+ 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 reading Remote Gateway", err.Error())
+ return
+ }
+
+ o.LoadApiData(ctx, api.Data, diags)
+ 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) {
+ 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 = iptypes.NewIPv4AddressValue(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)
+}
+
+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/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/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/data_source_datacenter_external_gateway.go b/apstra/data_source_datacenter_external_gateway.go
new file mode 100644
index 00000000..d4da7638
--- /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 DCI External Gateway 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/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 860a0f3e..a46b11b7 100644
--- a/apstra/provider.go
+++ b/apstra/provider.go
@@ -422,6 +422,8 @@ 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 &dataSourceDatacenterExternalGateways{} },
func() datasource.DataSource { return &dataSourceDatacenterPropertySet{} },
func() datasource.DataSource { return &dataSourceDatacenterPropertySets{} },
func() datasource.DataSource { return &dataSourceDatacenterRoutingPolicies{} },
@@ -474,6 +476,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.go b/apstra/resource_datacenter_external_gateway.go
new file mode 100644
index 00000000..7e713f4f
--- /dev/null
+++ b/apstra/resource_datacenter_external_gateway.go
@@ -0,0 +1,241 @@
+package tfapstra
+
+import (
+ "context"
+ "encoding/json"
+ "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 a DCI External Gateway within a Blueprint. " +
+ "Prior to Apstra 4.2 these were called \"Remote EVPN Gateways\"",
+ Attributes: blueprint.DatacenterExternalGateway{}.ResourceAttributes(),
+ }
+}
+
+func (o *resourceDatacenterExternalGateway) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ var importId struct {
+ BlueprintId string `json:"blueprint_id"`
+ ExternalGatewayId string `json:"external_gateway_id"`
+ }
+
+ // 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, "'blueprint_id element of import ID string cannot be empty")
+ return
+ }
+
+ if importId.ExternalGatewayId == "" {
+ resp.Diagnostics.AddError(errImportJsonMissingRequiredField, "'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(importId.BlueprintId),
+ Id: types.StringValue(importId.ExternalGatewayId),
+ }
+
+ // 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/apstra/resource_datacenter_external_gateway_test.go b/apstra/resource_datacenter_external_gateway_test.go
new file mode 100644
index 00000000..f73d978a
--- /dev/null
+++ b/apstra/resource_datacenter_external_gateway_test.go
@@ -0,0 +1,202 @@
+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
+ password = %s
+}
+`
+)
+
+type testCaseResourceExternalGateway struct {
+ name string
+ ipAddress net.IP
+ asn uint32
+ routeTypes apstra.RemoteGatewayRouteTypes
+ nodes string
+ ttl *uint8
+ keepaliveTime *uint16
+ holdTime *uint16
+ password string
+ 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),
+ stringOrNull(tc.password),
+ )
+}
+
+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,
+ 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()),
+ 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"),
+ 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"),
+ }...),
+ },
+ }
+
+ 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..005e957e
--- /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/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/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
new file mode 100644
index 00000000..bc592b16
--- /dev/null
+++ b/docs/resources/datacenter_external_gateway.md
@@ -0,0 +1,87 @@
+---
+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.
+
+
+
+## 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 499deb2f..6f2a361d 100644
--- a/docs/resources/datacenter_routing_zone.md
+++ b/docs/resources/datacenter_routing_zone.md
@@ -48,3 +48,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/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"
+ },
+ ]
+}
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
+ ]
+}
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 83e93d73..aa8e0781 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-20231101190318-596cb09ef6bb
+ 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
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 650f9963..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-20231101190318-596cb09ef6bb h1:wSFnmSeW5N+zevR2LVC5Lnp09dU7cd7eccV2MyRfRus=
-github.com/Juniper/apstra-go-sdk v0.0.0-20231101190318-596cb09ef6bb/go.mod h1:It+5cLgOj77K+s+7m5Nsnbms3Tu/4ObqDuBjsTR2Oh0=
+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=
@@ -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=
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 }}