From 9636d5b8e738dcb5c392a2af12adda86d2f8d97a Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:17:37 +0200 Subject: [PATCH 1/6] feat(azure): Network::VirtualWan, VirtualHub and VpnSite Adds the Virtual WAN container plus its two directly-attached children on Microsoft.Network API version 2023-05-01: - AZURE::Network::VirtualWan - Microsoft.Network/virtualWans - AZURE::Network::VirtualHub - Microsoft.Network/virtualHubs - AZURE::Network::VpnSite - Microsoft.Network/vpnSites All three are LRO on create, update and delete and follow the existing LocalNetworkGateway shape: re-PUT on update, idempotent delete, resume-token Status, and the pending-poller branch pinning the ARM path so an in-flight create cannot orphan the resource. VirtualHub and VpnSite take the WAN by resolvable ARM ID, so a single forma expresses the 1 -> 2 and 1 -> 4 orderings without an explicit dependency. Conformance fixtures: virtual-wan, virtual-hub, vpn-site (each with -update). --- pkg/resources/virtualhub.go | 477 +++++++++++++++ pkg/resources/virtualhub_integration_test.go | 284 +++++++++ pkg/resources/virtualwan.go | 457 ++++++++++++++ pkg/resources/virtualwan_integration_test.go | 264 ++++++++ pkg/resources/vpnsite.go | 608 +++++++++++++++++++ pkg/resources/vpnsite_integration_test.go | 353 +++++++++++ schema/pkl/network/virtualhub.pkl | 91 +++ schema/pkl/network/virtualwan.pkl | 85 +++ schema/pkl/network/vpnsite.pkl | 148 +++++ testdata/virtual-hub-update.pkl | 62 ++ testdata/virtual-hub.pkl | 62 ++ testdata/virtual-wan-update.pkl | 45 ++ testdata/virtual-wan.pkl | 45 ++ testdata/vpn-site-update.pkl | 77 +++ testdata/vpn-site.pkl | 76 +++ 15 files changed, 3134 insertions(+) create mode 100644 pkg/resources/virtualhub.go create mode 100644 pkg/resources/virtualhub_integration_test.go create mode 100644 pkg/resources/virtualwan.go create mode 100644 pkg/resources/virtualwan_integration_test.go create mode 100644 pkg/resources/vpnsite.go create mode 100644 pkg/resources/vpnsite_integration_test.go create mode 100644 schema/pkl/network/virtualhub.pkl create mode 100644 schema/pkl/network/virtualwan.pkl create mode 100644 schema/pkl/network/vpnsite.pkl create mode 100644 testdata/virtual-hub-update.pkl create mode 100644 testdata/virtual-hub.pkl create mode 100644 testdata/virtual-wan-update.pkl create mode 100644 testdata/virtual-wan.pkl create mode 100644 testdata/vpn-site-update.pkl create mode 100644 testdata/vpn-site.pkl diff --git a/pkg/resources/virtualhub.go b/pkg/resources/virtualhub.go new file mode 100644 index 00000000..cf133082 --- /dev/null +++ b/pkg/resources/virtualhub.go @@ -0,0 +1,477 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVirtualHub = "AZURE::Network::VirtualHub" + +// virtualHubsAPI is the armnetwork surface used here. UpdateTags is deliberately +// absent: it cannot change the routing preference or the branch-to-branch flag, so +// every update is a re-PUT. +type virtualHubsAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, virtualHubName string, virtualHubParameters armnetwork.VirtualHub, options *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, virtualHubName string, options *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, virtualHubName string, options *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) + NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.VirtualHubsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualHubsClientListByResourceGroupResponse] + NewListPager(options *armnetwork.VirtualHubsClientListOptions) *runtime.Pager[armnetwork.VirtualHubsClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVirtualHub, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VirtualHub{ + api: c.VirtualHubsClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VirtualHub is the provisioner for a regional hub inside a Virtual WAN +// (Microsoft.Network/virtualHubs). +type VirtualHub struct { + api virtualHubsAPI + pipeline runtime.Pipeline + config *config.Config +} + +// virtualHubProps mirrors schema/pkl/network/virtualhub.pkl. +type virtualHubProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + VirtualWanID string `json:"virtualWanId"` + AddressPrefix string `json:"addressPrefix"` + SKU *string `json:"sku"` + HubRoutingPreference *string `json:"hubRoutingPreference"` + AllowBranchToBranchTraffic *bool `json:"allowBranchToBranchTraffic"` +} + +var ( + // virtualHubSkus and virtualHubRoutingPreferences carry the canonical casing for + // the two enums, applied on the read path because ARM echoes them back + // inconsistently. + virtualHubSkus = []string{"Basic", "Standard"} + virtualHubRoutingPreferences = []string{"ExpressRoute", "VpnGateway", "ASPath"} +) + +func virtualHubIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "virtualhubs") + if err != nil { + return "", "", err + } + return rgName, names["virtualhubs"], nil +} + +func (r *VirtualHub) buildPropertiesFromResult(hub *armnetwork.VirtualHub, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if hub.ID != nil { + props["id"] = *hub.ID + } + if hub.Name != nil { + props["name"] = *hub.Name + } + if hub.Location != nil { + props["location"] = normalizeAzureLocation(*hub.Location) + } + if tags := azureTagsToFormaeTags(hub.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + if p := hub.Properties; p != nil { + if p.VirtualWan != nil && p.VirtualWan.ID != nil { + props["virtualWanId"] = *p.VirtualWan.ID + } + if p.AddressPrefix != nil && *p.AddressPrefix != "" { + props["addressPrefix"] = *p.AddressPrefix + } + if p.SKU != nil && *p.SKU != "" { + props["sku"] = canonicalizeEnum(*p.SKU, virtualHubSkus...) + } + if p.HubRoutingPreference != nil && *p.HubRoutingPreference != "" { + props["hubRoutingPreference"] = canonicalizeEnum(string(*p.HubRoutingPreference), virtualHubRoutingPreferences...) + } + if p.AllowBranchToBranchTraffic != nil { + props["allowBranchToBranchTraffic"] = *p.AllowBranchToBranchTraffic + } + // Everything else the hub reports is service state or a back-reference owned + // by another resource: provisioningState, routingState, virtualRouterAsn, + // virtualRouterIPs, the route tables the service seeds, and the + // azureFirewall / expressRouteGateway / p2sVpnGateway / vpnGateway / + // bgpConnections / ipConfigurations / routeMaps pointers, which the + // attaching resource owns. + } + + return props +} + +// virtualHubParams builds the request body shared by create and update. +func virtualHubParams(props virtualHubProps, payload json.RawMessage) armnetwork.VirtualHub { + params := armnetwork.VirtualHub{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VirtualHubProperties{ + VirtualWan: &armnetwork.SubResource{ID: to.Ptr(props.VirtualWanID)}, + AddressPrefix: to.Ptr(props.AddressPrefix), + SKU: props.SKU, + AllowBranchToBranchTraffic: props.AllowBranchToBranchTraffic, + }, + } + if props.HubRoutingPreference != nil { + params.Properties.HubRoutingPreference = to.Ptr(armnetwork.HubRoutingPreference(*props.HubRoutingPreference)) + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: UpdateTags cannot touch the routing +// preference, so an update is another CreateOrUpdate. +func (r *VirtualHub) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], virtualHubProps, string, error) { + var props virtualHubProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if props.VirtualWanID == "" { + return nil, props, "", fmt.Errorf("virtualWanId is required") + } + if props.AddressPrefix == "" { + return nil, props, "", fmt.Errorf("addressPrefix is required") + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + virtualHubParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VirtualHub) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualHubs/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromHub(&result.VirtualHub) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VirtualHub) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := virtualHubIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualHub, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVirtualHub, + Properties: string(propsJSON), + }, nil +} + +func (r *VirtualHub) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := virtualHubIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualHub, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualHub) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := virtualHubIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualHub) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VirtualHubsClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VirtualHubsClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromHub(&result.VirtualHub) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { + return resumePoller[armnetwork.VirtualHubsClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VirtualHub) completeFromHub(hub *armnetwork.VirtualHub) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if hub.ID != nil { + nativeID = *hub.ID + if rg, _, err := virtualHubIDParts(*hub.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(hub, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List narrows to a resource group when one is supplied and otherwise sweeps the +// whole subscription. +func (r *VirtualHub) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + + var nativeIDs []string + if rgName != "" { + pager := r.api.NewListByResourceGroupPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual hubs in resource group %s: %w", rgName, err) + } + for _, hub := range page.Value { + if hub.ID != nil { + nativeIDs = append(nativeIDs, *hub.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil + } + + pager := r.api.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual hubs: %w", err) + } + for _, hub := range page.Value { + if hub.ID != nil { + nativeIDs = append(nativeIDs, *hub.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/virtualhub_integration_test.go b/pkg/resources/virtualhub_integration_test.go new file mode 100644 index 00000000..aca6240c --- /dev/null +++ b/pkg/resources/virtualhub_integration_test.go @@ -0,0 +1,284 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testVirtualHubNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualHubs/hub1" + testVirtualHubWanID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualWans/vwan1" +) + +type fakeVirtualHubsAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VirtualHub, options *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) + newListByResourceGroupPagerFn func(rgName string, options *armnetwork.VirtualHubsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualHubsClientListByResourceGroupResponse] + newListPagerFn func(options *armnetwork.VirtualHubsClientListOptions) *runtime.Pager[armnetwork.VirtualHubsClientListResponse] +} + +func (f *fakeVirtualHubsAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VirtualHub, options *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVirtualHubsAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualHubsAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualHubsAPI) NewListByResourceGroupPager(rgName string, options *armnetwork.VirtualHubsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualHubsClientListByResourceGroupResponse] { + return f.newListByResourceGroupPagerFn(rgName, options) +} + +func (f *fakeVirtualHubsAPI) NewListPager(options *armnetwork.VirtualHubsClientListOptions) *runtime.Pager[armnetwork.VirtualHubsClientListResponse] { + return f.newListPagerFn(options) +} + +func newTestVirtualHub(api virtualHubsAPI) *VirtualHub { + return &VirtualHub{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func virtualHubDesired(routingPreference string) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "hub1", + "resourceGroupName": "rg-1", + "location": "eastus", + "virtualWanId": testVirtualHubWanID, + "addressPrefix": "10.100.0.0/23", + "sku": "Standard", + "hubRoutingPreference": routingPreference, + "allowBranchToBranchTraffic": true, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVirtualHub_CRUD(t *testing.T) { + hubResult := armnetwork.VirtualHub{ + ID: to.Ptr(testVirtualHubNativeID), + Name: to.Ptr("hub1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VirtualHubProperties{ + VirtualWan: &armnetwork.SubResource{ID: to.Ptr(testVirtualHubWanID)}, + AddressPrefix: to.Ptr("10.100.0.0/23"), + // ARM echoes the sku and routing preference back with its own casing. + SKU: to.Ptr("standard"), + HubRoutingPreference: to.Ptr(armnetwork.HubRoutingPreference("aspath")), + AllowBranchToBranchTraffic: to.Ptr(true), + // Service state and gateway back-references: the hub never owns these. + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + RoutingState: to.Ptr(armnetwork.RoutingStateProvisioned), + VirtualRouterAsn: to.Ptr(int64(65515)), + VirtualRouterIPs: []*string{to.Ptr("10.100.0.68")}, + VPNGateway: &armnetwork.SubResource{ID: to.Ptr("/subscriptions/sub-1/vpngw")}, + RouteTable: &armnetwork.VirtualHubRouteTable{}, + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + Kind: to.Ptr("VirtualHub"), + } + + var sent armnetwork.VirtualHub + createCalls := 0 + deleteCalls := 0 + fake := &fakeVirtualHubsAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VirtualHub, _ *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "hub1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualHubsClientCreateOrUpdateResponse{VirtualHub: hubResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return armnetwork.VirtualHubsClientGetResponse{VirtualHub: hubResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VirtualHubsClientDeleteResponse{}), nil + }, + newListByResourceGroupPagerFn: func(_ string, _ *armnetwork.VirtualHubsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualHubsClientListByResourceGroupResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualHubsClientListByResourceGroupResponse]{ + More: func(_ armnetwork.VirtualHubsClientListByResourceGroupResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualHubsClientListByResourceGroupResponse) (armnetwork.VirtualHubsClientListByResourceGroupResponse, error) { + return armnetwork.VirtualHubsClientListByResourceGroupResponse{ + ListVirtualHubsResult: armnetwork.ListVirtualHubsResult{ + Value: []*armnetwork.VirtualHub{{ID: to.Ptr(testVirtualHubNativeID)}}, + }, + }, nil + }, + }) + }, + newListPagerFn: func(_ *armnetwork.VirtualHubsClientListOptions) *runtime.Pager[armnetwork.VirtualHubsClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualHubsClientListResponse]{ + More: func(_ armnetwork.VirtualHubsClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualHubsClientListResponse) (armnetwork.VirtualHubsClientListResponse, error) { + return armnetwork.VirtualHubsClientListResponse{ + ListVirtualHubsResult: armnetwork.ListVirtualHubsResult{ + Value: []*armnetwork.VirtualHub{{ID: to.Ptr(testVirtualHubNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVirtualHub(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "hub1", Properties: virtualHubDesired("ASPath"), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testVirtualHubNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVirtualHubWanID, *sent.Properties.VirtualWan.ID) + require.Equal(t, "10.100.0.0/23", *sent.Properties.AddressPrefix) + require.Equal(t, "Standard", *sent.Properties.SKU) + require.Equal(t, armnetwork.HubRoutingPreferenceASPath, *sent.Properties.HubRoutingPreference) + require.True(t, *sent.Properties.AllowBranchToBranchTraffic) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_virtual_wan", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "hub1", "resourceGroupName": "rg-1", "location": "eastus", + "addressPrefix": "10.100.0.0/23", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "virtualWanId is required") + }) + + t.Run("Create_requires_address_prefix", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "hub1", "resourceGroupName": "rg-1", "location": "eastus", + "virtualWanId": testVirtualHubWanID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "addressPrefix is required") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or the resource is orphaned once it completes. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VirtualHub, _ *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VirtualHubsClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "hub1", Properties: virtualHubDesired("ASPath"), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVirtualHubNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "hub1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + require.Equal(t, testVirtualHubWanID, props["virtualWanId"]) + require.Equal(t, "10.100.0.0/23", props["addressPrefix"]) + // ARM returns "standard" / "aspath"; the schema unions are "Standard" / "ASPath". + require.Equal(t, "Standard", props["sku"]) + require.Equal(t, "ASPath", props["hubRoutingPreference"]) + require.Equal(t, true, props["allowBranchToBranchTraffic"]) + }) + + // Service state and the gateway back-references would read as drift forever. + t.Run("Read_drops_service_state", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + for _, key := range []string{ + "provisioningState", "routingState", "virtualRouterAsn", "virtualRouterIps", + "vpnGateway", "routeTable", "etag", "kind", + } { + require.NotContains(t, got.Properties, key) + } + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VirtualHub, _ *armnetwork.VirtualHubsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualHubsClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualHubsClientCreateOrUpdateResponse{VirtualHub: hubResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testVirtualHubNativeID, + DesiredProperties: virtualHubDesired("VpnGateway"), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Equal(t, armnetwork.HubRoutingPreferenceVPNGateway, *sent.Properties.HubRoutingPreference) + // Location and the WAN reference must ride along: a PUT without them is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVirtualHubWanID, *sent.Properties.VirtualWan.ID) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testVirtualHubNativeID}, got.NativeIDs) + }) + + t.Run("List_by_subscription", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Equal(t, []string{testVirtualHubNativeID}, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return armnetwork.VirtualHubsClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/pkg/resources/virtualwan.go b/pkg/resources/virtualwan.go new file mode 100644 index 00000000..1b2df952 --- /dev/null +++ b/pkg/resources/virtualwan.go @@ -0,0 +1,457 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVirtualWan = "AZURE::Network::VirtualWan" + +// virtualWansAPI is the armnetwork surface used here. UpdateTags is deliberately +// absent: it cannot change the tier or any of the traffic flags, so every update is +// a re-PUT. +type virtualWansAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, virtualWANName string, wanParameters armnetwork.VirtualWAN, options *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, virtualWANName string, options *armnetwork.VirtualWansClientGetOptions) (armnetwork.VirtualWansClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, virtualWANName string, options *armnetwork.VirtualWansClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) + NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.VirtualWansClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualWansClientListByResourceGroupResponse] + NewListPager(options *armnetwork.VirtualWansClientListOptions) *runtime.Pager[armnetwork.VirtualWansClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVirtualWan, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VirtualWan{ + api: c.VirtualWansClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VirtualWan is the provisioner for the root of a Virtual WAN topology +// (Microsoft.Network/virtualWans). +type VirtualWan struct { + api virtualWansAPI + pipeline runtime.Pipeline + config *config.Config +} + +// virtualWanProps mirrors schema/pkl/network/virtualwan.pkl. +type virtualWanProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + VirtualWanTier *string `json:"virtualWanTier"` + DisableVpnEncryption *bool `json:"disableVpnEncryption"` + AllowBranchToBranchTraffic *bool `json:"allowBranchToBranchTraffic"` + AllowVnetToVnetTraffic *bool `json:"allowVnetToVnetTraffic"` +} + +// virtualWanTiers is the canonical casing for the tier enum, applied on the read +// path because ARM echoes it back inconsistently. +var virtualWanTiers = []string{"Basic", "Standard"} + +func virtualWanIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "virtualwans") + if err != nil { + return "", "", err + } + return rgName, names["virtualwans"], nil +} + +func (r *VirtualWan) buildPropertiesFromResult(wan *armnetwork.VirtualWAN, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if wan.ID != nil { + props["id"] = *wan.ID + } + if wan.Name != nil { + props["name"] = *wan.Name + } + if wan.Location != nil { + props["location"] = normalizeAzureLocation(*wan.Location) + } + if tags := azureTagsToFormaeTags(wan.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + if p := wan.Properties; p != nil { + if p.Type != nil && *p.Type != "" { + props["virtualWanTier"] = canonicalizeEnum(*p.Type, virtualWanTiers...) + } + if p.DisableVPNEncryption != nil { + props["disableVpnEncryption"] = *p.DisableVPNEncryption + } + if p.AllowBranchToBranchTraffic != nil { + props["allowBranchToBranchTraffic"] = *p.AllowBranchToBranchTraffic + } + if p.AllowVnetToVnetTraffic != nil { + props["allowVnetToVnetTraffic"] = *p.AllowVnetToVnetTraffic + } + // provisioningState, office365LocalBreakoutCategory and the vpnSites / + // virtualHubs back-references are service state: the hubs and sites own + // their side of the reference, so echoing them here would read as drift. + } + + return props +} + +// virtualWanParams builds the request body shared by create and update. +func virtualWanParams(props virtualWanProps, payload json.RawMessage) armnetwork.VirtualWAN { + params := armnetwork.VirtualWAN{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VirtualWanProperties{ + Type: props.VirtualWanTier, + DisableVPNEncryption: props.DisableVpnEncryption, + AllowBranchToBranchTraffic: props.AllowBranchToBranchTraffic, + AllowVnetToVnetTraffic: props.AllowVnetToVnetTraffic, + }, + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: UpdateTags cannot touch the tier or the +// traffic flags, so an update is another CreateOrUpdate. +func (r *VirtualWan) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], virtualWanProps, string, error) { + var props virtualWanProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + virtualWanParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VirtualWan) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualWans/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromWan(&result.VirtualWAN) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VirtualWan) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := virtualWanIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualWAN, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVirtualWan, + Properties: string(propsJSON), + }, nil +} + +func (r *VirtualWan) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := virtualWanIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualWAN, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualWan) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := virtualWanIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualWan) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VirtualWansClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VirtualWansClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromWan(&result.VirtualWAN) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) { + return resumePoller[armnetwork.VirtualWansClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VirtualWan) completeFromWan(wan *armnetwork.VirtualWAN) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if wan.ID != nil { + nativeID = *wan.ID + if rg, _, err := virtualWanIDParts(*wan.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(wan, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List narrows to a resource group when one is supplied and otherwise sweeps the +// whole subscription. +func (r *VirtualWan) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + + var nativeIDs []string + if rgName != "" { + pager := r.api.NewListByResourceGroupPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual WANs in resource group %s: %w", rgName, err) + } + for _, wan := range page.Value { + if wan.ID != nil { + nativeIDs = append(nativeIDs, *wan.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil + } + + pager := r.api.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual WANs: %w", err) + } + for _, wan := range page.Value { + if wan.ID != nil { + nativeIDs = append(nativeIDs, *wan.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/virtualwan_integration_test.go b/pkg/resources/virtualwan_integration_test.go new file mode 100644 index 00000000..b5db7076 --- /dev/null +++ b/pkg/resources/virtualwan_integration_test.go @@ -0,0 +1,264 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const testVirtualWanNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualWans/vwan1" + +type fakeVirtualWansAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VirtualWAN, options *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualWansClientGetOptions) (armnetwork.VirtualWansClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualWansClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) + newListByResourceGroupPagerFn func(rgName string, options *armnetwork.VirtualWansClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualWansClientListByResourceGroupResponse] + newListPagerFn func(options *armnetwork.VirtualWansClientListOptions) *runtime.Pager[armnetwork.VirtualWansClientListResponse] +} + +func (f *fakeVirtualWansAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VirtualWAN, options *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVirtualWansAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VirtualWansClientGetOptions) (armnetwork.VirtualWansClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualWansAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VirtualWansClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualWansAPI) NewListByResourceGroupPager(rgName string, options *armnetwork.VirtualWansClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualWansClientListByResourceGroupResponse] { + return f.newListByResourceGroupPagerFn(rgName, options) +} + +func (f *fakeVirtualWansAPI) NewListPager(options *armnetwork.VirtualWansClientListOptions) *runtime.Pager[armnetwork.VirtualWansClientListResponse] { + return f.newListPagerFn(options) +} + +func newTestVirtualWan(api virtualWansAPI) *VirtualWan { + return &VirtualWan{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func virtualWanDesired(branchToBranch bool) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "vwan1", + "resourceGroupName": "rg-1", + "location": "eastus", + "virtualWanTier": "Standard", + "disableVpnEncryption": false, + "allowBranchToBranchTraffic": branchToBranch, + "allowVnetToVnetTraffic": false, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVirtualWan_CRUD(t *testing.T) { + wanResult := armnetwork.VirtualWAN{ + ID: to.Ptr(testVirtualWanNativeID), + Name: to.Ptr("vwan1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VirtualWanProperties{ + // ARM echoes the tier lower-cased on some API versions. + Type: to.Ptr("standard"), + DisableVPNEncryption: to.Ptr(false), + AllowBranchToBranchTraffic: to.Ptr(true), + AllowVnetToVnetTraffic: to.Ptr(false), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + // Back-references owned by the hub / site resources. + VirtualHubs: []*armnetwork.SubResource{{ID: to.Ptr("/subscriptions/sub-1/hub")}}, + VPNSites: []*armnetwork.SubResource{{ID: to.Ptr("/subscriptions/sub-1/site")}}, + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.VirtualWAN + createCalls := 0 + deleteCalls := 0 + fake := &fakeVirtualWansAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VirtualWAN, _ *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "vwan1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualWansClientCreateOrUpdateResponse{VirtualWAN: wanResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualWansClientGetOptions) (armnetwork.VirtualWansClientGetResponse, error) { + return armnetwork.VirtualWansClientGetResponse{VirtualWAN: wanResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualWansClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VirtualWansClientDeleteResponse{}), nil + }, + newListByResourceGroupPagerFn: func(_ string, _ *armnetwork.VirtualWansClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VirtualWansClientListByResourceGroupResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualWansClientListByResourceGroupResponse]{ + More: func(_ armnetwork.VirtualWansClientListByResourceGroupResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualWansClientListByResourceGroupResponse) (armnetwork.VirtualWansClientListByResourceGroupResponse, error) { + return armnetwork.VirtualWansClientListByResourceGroupResponse{ + ListVirtualWANsResult: armnetwork.ListVirtualWANsResult{ + Value: []*armnetwork.VirtualWAN{{ID: to.Ptr(testVirtualWanNativeID)}}, + }, + }, nil + }, + }) + }, + newListPagerFn: func(_ *armnetwork.VirtualWansClientListOptions) *runtime.Pager[armnetwork.VirtualWansClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualWansClientListResponse]{ + More: func(_ armnetwork.VirtualWansClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualWansClientListResponse) (armnetwork.VirtualWansClientListResponse, error) { + return armnetwork.VirtualWansClientListResponse{ + ListVirtualWANsResult: armnetwork.ListVirtualWANsResult{ + Value: []*armnetwork.VirtualWAN{{ID: to.Ptr(testVirtualWanNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVirtualWan(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vwan1", Properties: virtualWanDesired(true), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testVirtualWanNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, "Standard", *sent.Properties.Type) + require.False(t, *sent.Properties.DisableVPNEncryption) + require.True(t, *sent.Properties.AllowBranchToBranchTraffic) + require.False(t, *sent.Properties.AllowVnetToVnetTraffic) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_resource_group", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{"name": "vwan1", "location": "eastus"}) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "resourceGroupName is required") + }) + + t.Run("Create_requires_location", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{"name": "vwan1", "resourceGroupName": "rg-1"}) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "location is required") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or the resource is orphaned once it completes. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VirtualWAN, _ *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VirtualWansClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vwan1", Properties: virtualWanDesired(true), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVirtualWanNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualWanNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "vwan1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + // ARM returns "East US"; read must normalise or desired state drifts. + require.Equal(t, "eastus", props["location"]) + // ARM returns "standard"; the schema union is "Standard". + require.Equal(t, "Standard", props["virtualWanTier"]) + require.Equal(t, false, props["disableVpnEncryption"]) + require.Equal(t, true, props["allowBranchToBranchTraffic"]) + require.Equal(t, false, props["allowVnetToVnetTraffic"]) + }) + + // Service state and the hub/site back-references would read as drift forever. + t.Run("Read_drops_service_state", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualWanNativeID}) + require.NoError(t, err) + for _, key := range []string{"provisioningState", "etag", "virtualHubs", "vpnSites", "office365LocalBreakoutCategory"} { + require.NotContains(t, got.Properties, key) + } + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VirtualWAN, _ *armnetwork.VirtualWansClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualWansClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualWansClientCreateOrUpdateResponse{VirtualWAN: wanResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testVirtualWanNativeID, + DesiredProperties: virtualWanDesired(false), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.False(t, *sent.Properties.AllowBranchToBranchTraffic) + // Location must ride along: a PUT without it is rejected. + require.Equal(t, "eastus", *sent.Location) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualWanNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualWansClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualWansClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualWanNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testVirtualWanNativeID}, got.NativeIDs) + }) + + t.Run("List_by_subscription", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Equal(t, []string{testVirtualWanNativeID}, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualWansClientGetOptions) (armnetwork.VirtualWansClientGetResponse, error) { + return armnetwork.VirtualWansClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVirtualWanNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/pkg/resources/vpnsite.go b/pkg/resources/vpnsite.go new file mode 100644 index 00000000..5d59d02c --- /dev/null +++ b/pkg/resources/vpnsite.go @@ -0,0 +1,608 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVpnSite = "AZURE::Network::VpnSite" + +// vpnSitesAPI is the armnetwork surface used here. UpdateTags is deliberately +// absent: it cannot change the address space or the links, so every update is a +// re-PUT. +type vpnSitesAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, vpnSiteName string, vpnSiteParameters armnetwork.VPNSite, options *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, vpnSiteName string, options *armnetwork.VPNSitesClientGetOptions) (armnetwork.VPNSitesClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, vpnSiteName string, options *armnetwork.VPNSitesClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) + NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.VPNSitesClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNSitesClientListByResourceGroupResponse] + NewListPager(options *armnetwork.VPNSitesClientListOptions) *runtime.Pager[armnetwork.VPNSitesClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVpnSite, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VpnSite{ + api: c.VPNSitesClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VpnSite is the provisioner for the Virtual WAN description of a branch +// (Microsoft.Network/vpnSites). +type VpnSite struct { + api vpnSitesAPI + pipeline runtime.Pipeline + config *config.Config +} + +// vpnSiteProps mirrors schema/pkl/network/vpnsite.pkl. +type vpnSiteProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + VirtualWanID string `json:"virtualWanId"` + AddressSpace []string `json:"addressSpace"` + DeviceProperties *vpnSiteDevicePropertyProps `json:"deviceProperties"` + IPAddress *string `json:"ipAddress"` + VpnSiteLinks []vpnSiteLinkProps `json:"vpnSiteLinks"` +} + +type vpnSiteDevicePropertyProps struct { + DeviceVendor *string `json:"deviceVendor"` + DeviceModel *string `json:"deviceModel"` + LinkSpeedInMbps *int32 `json:"linkSpeedInMbps"` +} + +type vpnSiteLinkProps struct { + Name string `json:"name"` + IPAddress *string `json:"ipAddress"` + Fqdn *string `json:"fqdn"` + LinkProperties *vpnSiteLinkProviderProps `json:"linkProperties"` + BgpProperties *vpnSiteLinkBgpProps `json:"bgpProperties"` +} + +type vpnSiteLinkProviderProps struct { + LinkProviderName *string `json:"linkProviderName"` + LinkSpeedInMbps *int32 `json:"linkSpeedInMbps"` +} + +type vpnSiteLinkBgpProps struct { + Asn *int64 `json:"asn"` + BgpPeeringAddress string `json:"bgpPeeringAddress"` +} + +func vpnSiteIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "vpnsites") + if err != nil { + return "", "", err + } + return rgName, names["vpnsites"], nil +} + +func (r *VpnSite) buildPropertiesFromResult(site *armnetwork.VPNSite, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if site.ID != nil { + props["id"] = *site.ID + } + if site.Name != nil { + props["name"] = *site.Name + } + if site.Location != nil { + props["location"] = normalizeAzureLocation(*site.Location) + } + if tags := azureTagsToFormaeTags(site.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + if p := site.Properties; p != nil { + if p.VirtualWan != nil && p.VirtualWan.ID != nil { + props["virtualWanId"] = *p.VirtualWan.ID + } + if space := p.AddressSpace; space != nil { + if prefixes := stringsFromPointers(space.AddressPrefixes); prefixes != nil { + props["addressSpace"] = prefixes + } + } + if p.IPAddress != nil && *p.IPAddress != "" { + props["ipAddress"] = *p.IPAddress + } + if dev := p.DeviceProperties; dev != nil { + device := make(map[string]any) + if dev.DeviceVendor != nil && *dev.DeviceVendor != "" { + device["deviceVendor"] = *dev.DeviceVendor + } + if dev.DeviceModel != nil && *dev.DeviceModel != "" { + device["deviceModel"] = *dev.DeviceModel + } + if dev.LinkSpeedInMbps != nil { + device["linkSpeedInMbps"] = *dev.LinkSpeedInMbps + } + if len(device) > 0 { + props["deviceProperties"] = device + } + } + if links := vpnSiteLinksToProps(p.VPNSiteLinks); len(links) > 0 { + props["vpnSiteLinks"] = links + } + // provisioningState, siteKey, isSecuritySite and o365Policy are service state + // or not modelled, and the per-link id / etag / type / provisioningState are + // assigned by ARM. + } + + return props +} + +// vpnSiteLinksToProps is the read-path inverse of vpnSiteLinksFromProps. It emits +// only the modelled fields: the per-link ARM ID, etag, type and provisioningState +// are service-assigned and would read as drift. +func vpnSiteLinksToProps(links []*armnetwork.VPNSiteLink) []map[string]any { + if len(links) == 0 { + return nil + } + out := make([]map[string]any, 0, len(links)) + for _, link := range links { + if link == nil { + continue + } + entry := make(map[string]any) + if link.Name != nil { + entry["name"] = *link.Name + } + if lp := link.Properties; lp != nil { + if lp.IPAddress != nil && *lp.IPAddress != "" { + entry["ipAddress"] = *lp.IPAddress + } + if lp.Fqdn != nil && *lp.Fqdn != "" { + entry["fqdn"] = *lp.Fqdn + } + if provider := lp.LinkProperties; provider != nil { + linkProps := make(map[string]any) + if provider.LinkProviderName != nil && *provider.LinkProviderName != "" { + linkProps["linkProviderName"] = *provider.LinkProviderName + } + if provider.LinkSpeedInMbps != nil { + linkProps["linkSpeedInMbps"] = *provider.LinkSpeedInMbps + } + if len(linkProps) > 0 { + entry["linkProperties"] = linkProps + } + } + if bgp := lp.BgpProperties; bgp != nil { + bgpProps := make(map[string]any) + if bgp.Asn != nil { + bgpProps["asn"] = *bgp.Asn + } + if bgp.BgpPeeringAddress != nil { + bgpProps["bgpPeeringAddress"] = *bgp.BgpPeeringAddress + } + if len(bgpProps) > 0 { + entry["bgpProperties"] = bgpProps + } + } + } + out = append(out, entry) + } + return out +} + +// vpnSiteLinksFromProps builds the request-side link list. +func vpnSiteLinksFromProps(links []vpnSiteLinkProps) []*armnetwork.VPNSiteLink { + if len(links) == 0 { + return nil + } + out := make([]*armnetwork.VPNSiteLink, 0, len(links)) + for i := range links { + link := links[i] + armLink := &armnetwork.VPNSiteLink{ + Name: to.Ptr(link.Name), + Properties: &armnetwork.VPNSiteLinkProperties{ + IPAddress: link.IPAddress, + Fqdn: link.Fqdn, + }, + } + if provider := link.LinkProperties; provider != nil { + armLink.Properties.LinkProperties = &armnetwork.VPNLinkProviderProperties{ + LinkProviderName: provider.LinkProviderName, + LinkSpeedInMbps: provider.LinkSpeedInMbps, + } + } + if bgp := link.BgpProperties; bgp != nil { + armLink.Properties.BgpProperties = &armnetwork.VPNLinkBgpSettings{ + Asn: bgp.Asn, + BgpPeeringAddress: to.Ptr(bgp.BgpPeeringAddress), + } + } + out = append(out, armLink) + } + return out +} + +// vpnSiteParams builds the request body shared by create and update. +func vpnSiteParams(props vpnSiteProps, payload json.RawMessage) armnetwork.VPNSite { + params := armnetwork.VPNSite{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VPNSiteProperties{ + VirtualWan: &armnetwork.SubResource{ID: to.Ptr(props.VirtualWanID)}, + IPAddress: props.IPAddress, + VPNSiteLinks: vpnSiteLinksFromProps(props.VpnSiteLinks), + }, + } + if prefixes := stringPointers(props.AddressSpace); prefixes != nil { + params.Properties.AddressSpace = &armnetwork.AddressSpace{AddressPrefixes: prefixes} + } + if dev := props.DeviceProperties; dev != nil { + params.Properties.DeviceProperties = &armnetwork.DeviceProperties{ + DeviceVendor: dev.DeviceVendor, + DeviceModel: dev.DeviceModel, + LinkSpeedInMbps: dev.LinkSpeedInMbps, + } + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: UpdateTags cannot touch the address space or +// the links, so an update is another CreateOrUpdate. +func (r *VpnSite) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], vpnSiteProps, string, error) { + var props vpnSiteProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if props.VirtualWanID == "" { + return nil, props, "", fmt.Errorf("virtualWanId is required") + } + // ARM accepts a site addressed by a bare ipAddress or by links, but a site with + // neither has no endpoint and no connection can ever target it. + if props.IPAddress == nil && len(props.VpnSiteLinks) == 0 { + return nil, props, "", fmt.Errorf("one of ipAddress or vpnSiteLinks is required") + } + for _, link := range props.VpnSiteLinks { + if link.Name == "" { + return nil, props, "", fmt.Errorf("every vpnSiteLinks entry needs a name") + } + if link.IPAddress == nil && link.Fqdn == nil { + return nil, props, "", fmt.Errorf("vpnSiteLinks entry %q needs one of ipAddress or fqdn", link.Name) + } + if link.IPAddress != nil && link.Fqdn != nil { + return nil, props, "", fmt.Errorf("vpnSiteLinks entry %q sets both ipAddress and fqdn, which are mutually exclusive", link.Name) + } + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + vpnSiteParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VpnSite) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/vpnSites/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromSite(&result.VPNSite) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VpnSite) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := vpnSiteIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VPNSite, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVpnSite, + Properties: string(propsJSON), + }, nil +} + +func (r *VpnSite) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := vpnSiteIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VPNSite, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VpnSite) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := vpnSiteIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VpnSite) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VPNSitesClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VPNSitesClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromSite(&result.VPNSite) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) { + return resumePoller[armnetwork.VPNSitesClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VpnSite) completeFromSite(site *armnetwork.VPNSite) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if site.ID != nil { + nativeID = *site.ID + if rg, _, err := vpnSiteIDParts(*site.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(site, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List narrows to a resource group when one is supplied and otherwise sweeps the +// whole subscription. +func (r *VpnSite) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + + var nativeIDs []string + if rgName != "" { + pager := r.api.NewListByResourceGroupPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list VPN sites in resource group %s: %w", rgName, err) + } + for _, site := range page.Value { + if site.ID != nil { + nativeIDs = append(nativeIDs, *site.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil + } + + pager := r.api.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list VPN sites: %w", err) + } + for _, site := range page.Value { + if site.ID != nil { + nativeIDs = append(nativeIDs, *site.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/vpnsite_integration_test.go b/pkg/resources/vpnsite_integration_test.go new file mode 100644 index 00000000..532a7af5 --- /dev/null +++ b/pkg/resources/vpnsite_integration_test.go @@ -0,0 +1,353 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testVpnSiteNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/vpnSites/site1" + testVpnSiteWanID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualWans/vwan1" +) + +type fakeVpnSitesAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VPNSite, options *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VPNSitesClientGetOptions) (armnetwork.VPNSitesClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VPNSitesClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) + newListByResourceGroupPagerFn func(rgName string, options *armnetwork.VPNSitesClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNSitesClientListByResourceGroupResponse] + newListPagerFn func(options *armnetwork.VPNSitesClientListOptions) *runtime.Pager[armnetwork.VPNSitesClientListResponse] +} + +func (f *fakeVpnSitesAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VPNSite, options *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVpnSitesAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VPNSitesClientGetOptions) (armnetwork.VPNSitesClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVpnSitesAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VPNSitesClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVpnSitesAPI) NewListByResourceGroupPager(rgName string, options *armnetwork.VPNSitesClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNSitesClientListByResourceGroupResponse] { + return f.newListByResourceGroupPagerFn(rgName, options) +} + +func (f *fakeVpnSitesAPI) NewListPager(options *armnetwork.VPNSitesClientListOptions) *runtime.Pager[armnetwork.VPNSitesClientListResponse] { + return f.newListPagerFn(options) +} + +func newTestVpnSite(api vpnSitesAPI) *VpnSite { + return &VpnSite{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func vpnSiteDesired(prefixes []any, linkSpeed int) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "site1", + "resourceGroupName": "rg-1", + "location": "eastus", + "virtualWanId": testVpnSiteWanID, + "addressSpace": prefixes, + "deviceProperties": map[string]any{ + "deviceVendor": "Contoso", + "deviceModel": "CX-100", + "linkSpeedInMbps": linkSpeed, + }, + "vpnSiteLinks": []any{map[string]any{ + "name": "link0", + "ipAddress": "203.0.113.30", + "linkProperties": map[string]any{ + "linkProviderName": "Contoso Telecom", + "linkSpeedInMbps": linkSpeed, + }, + "bgpProperties": map[string]any{ + "asn": 65020, + "bgpPeeringAddress": "192.168.10.1", + }, + }}, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVpnSite_CRUD(t *testing.T) { + siteResult := armnetwork.VPNSite{ + ID: to.Ptr(testVpnSiteNativeID), + Name: to.Ptr("site1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VPNSiteProperties{ + VirtualWan: &armnetwork.SubResource{ID: to.Ptr(testVpnSiteWanID)}, + AddressSpace: &armnetwork.AddressSpace{ + AddressPrefixes: []*string{to.Ptr("192.168.10.0/24")}, + }, + DeviceProperties: &armnetwork.DeviceProperties{ + DeviceVendor: to.Ptr("Contoso"), + DeviceModel: to.Ptr("CX-100"), + LinkSpeedInMbps: to.Ptr(int32(100)), + }, + VPNSiteLinks: []*armnetwork.VPNSiteLink{{ + // ARM assigns the child ID, etag and type; none may reach state. + ID: to.Ptr(testVpnSiteNativeID + "/vpnSiteLinks/link0"), + Name: to.Ptr("link0"), + Etag: to.Ptr("W/\"link-etag\""), + Type: to.Ptr("Microsoft.Network/vpnSites/vpnSiteLinks"), + Properties: &armnetwork.VPNSiteLinkProperties{ + IPAddress: to.Ptr("203.0.113.30"), + LinkProperties: &armnetwork.VPNLinkProviderProperties{ + LinkProviderName: to.Ptr("Contoso Telecom"), + LinkSpeedInMbps: to.Ptr(int32(100)), + }, + BgpProperties: &armnetwork.VPNLinkBgpSettings{ + Asn: to.Ptr(int64(65020)), + BgpPeeringAddress: to.Ptr("192.168.10.1"), + }, + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + }}, + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + SiteKey: to.Ptr("service-generated-site-key"), + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.VPNSite + createCalls := 0 + deleteCalls := 0 + fake := &fakeVpnSitesAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VPNSite, _ *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "site1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VPNSitesClientCreateOrUpdateResponse{VPNSite: siteResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VPNSitesClientGetOptions) (armnetwork.VPNSitesClientGetResponse, error) { + return armnetwork.VPNSitesClientGetResponse{VPNSite: siteResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VPNSitesClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VPNSitesClientDeleteResponse{}), nil + }, + newListByResourceGroupPagerFn: func(_ string, _ *armnetwork.VPNSitesClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNSitesClientListByResourceGroupResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VPNSitesClientListByResourceGroupResponse]{ + More: func(_ armnetwork.VPNSitesClientListByResourceGroupResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VPNSitesClientListByResourceGroupResponse) (armnetwork.VPNSitesClientListByResourceGroupResponse, error) { + return armnetwork.VPNSitesClientListByResourceGroupResponse{ + ListVPNSitesResult: armnetwork.ListVPNSitesResult{ + Value: []*armnetwork.VPNSite{{ID: to.Ptr(testVpnSiteNativeID)}}, + }, + }, nil + }, + }) + }, + newListPagerFn: func(_ *armnetwork.VPNSitesClientListOptions) *runtime.Pager[armnetwork.VPNSitesClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VPNSitesClientListResponse]{ + More: func(_ armnetwork.VPNSitesClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VPNSitesClientListResponse) (armnetwork.VPNSitesClientListResponse, error) { + return armnetwork.VPNSitesClientListResponse{ + ListVPNSitesResult: armnetwork.ListVPNSitesResult{ + Value: []*armnetwork.VPNSite{{ID: to.Ptr(testVpnSiteNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVpnSite(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "site1", Properties: vpnSiteDesired([]any{"192.168.10.0/24"}, 100), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testVpnSiteNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVpnSiteWanID, *sent.Properties.VirtualWan.ID) + require.Equal(t, "192.168.10.0/24", *sent.Properties.AddressSpace.AddressPrefixes[0]) + require.Equal(t, "Contoso", *sent.Properties.DeviceProperties.DeviceVendor) + require.Equal(t, int32(100), *sent.Properties.DeviceProperties.LinkSpeedInMbps) + require.Len(t, sent.Properties.VPNSiteLinks, 1) + link := sent.Properties.VPNSiteLinks[0] + require.Equal(t, "link0", *link.Name) + require.Equal(t, "203.0.113.30", *link.Properties.IPAddress) + require.Nil(t, link.Properties.Fqdn) + require.Equal(t, "Contoso Telecom", *link.Properties.LinkProperties.LinkProviderName) + require.Equal(t, int64(65020), *link.Properties.BgpProperties.Asn) + require.Equal(t, "192.168.10.1", *link.Properties.BgpProperties.BgpPeeringAddress) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_virtual_wan", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "site1", "resourceGroupName": "rg-1", "location": "eastus", + "ipAddress": "203.0.113.30", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "virtualWanId is required") + }) + + // A site with no endpoint at all can never be the target of a connection. + t.Run("Create_requires_an_endpoint", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "site1", "resourceGroupName": "rg-1", "location": "eastus", + "virtualWanId": testVpnSiteWanID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "one of ipAddress or vpnSiteLinks is required") + }) + + t.Run("Create_rejects_link_with_both_addresses", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "site1", "resourceGroupName": "rg-1", "location": "eastus", + "virtualWanId": testVpnSiteWanID, + "vpnSiteLinks": []any{map[string]any{ + "name": "link0", "ipAddress": "203.0.113.30", "fqdn": "branch.example.com", + }}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "mutually exclusive") + }) + + t.Run("Create_rejects_link_without_an_address", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "site1", "resourceGroupName": "rg-1", "location": "eastus", + "virtualWanId": testVpnSiteWanID, + "vpnSiteLinks": []any{map[string]any{"name": "link0"}}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "needs one of ipAddress or fqdn") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or the resource is orphaned once it completes. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VPNSite, _ *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VPNSitesClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "site1", Properties: vpnSiteDesired([]any{"192.168.10.0/24"}, 100), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVpnSiteNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnSiteNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "site1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + require.Equal(t, testVpnSiteWanID, props["virtualWanId"]) + require.Equal(t, []any{"192.168.10.0/24"}, props["addressSpace"]) + + device := props["deviceProperties"].(map[string]any) + require.Equal(t, "Contoso", device["deviceVendor"]) + require.Equal(t, "CX-100", device["deviceModel"]) + require.EqualValues(t, 100, device["linkSpeedInMbps"]) + + links := props["vpnSiteLinks"].([]any) + require.Len(t, links, 1) + link := links[0].(map[string]any) + require.Equal(t, "link0", link["name"]) + require.Equal(t, "203.0.113.30", link["ipAddress"]) + require.Equal(t, "Contoso Telecom", link["linkProperties"].(map[string]any)["linkProviderName"]) + require.EqualValues(t, 65020, link["bgpProperties"].(map[string]any)["asn"]) + }) + + // Service state and the ARM-assigned per-link identity would read as drift forever. + t.Run("Read_drops_service_state", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnSiteNativeID}) + require.NoError(t, err) + for _, key := range []string{"provisioningState", "siteKey", "etag", "vpnSiteLinks/link0", "isSecuritySite"} { + require.NotContains(t, got.Properties, key) + } + // An absent fqdn must stay absent rather than appearing as "". + require.NotContains(t, got.Properties, "fqdn") + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VPNSite, _ *armnetwork.VPNSitesClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNSitesClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VPNSitesClientCreateOrUpdateResponse{VPNSite: siteResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testVpnSiteNativeID, + DesiredProperties: vpnSiteDesired([]any{"192.168.10.0/24", "192.168.11.0/24"}, 200), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Len(t, sent.Properties.AddressSpace.AddressPrefixes, 2) + require.Equal(t, int32(200), *sent.Properties.DeviceProperties.LinkSpeedInMbps) + // Location and the WAN reference must ride along: a PUT without them is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVpnSiteWanID, *sent.Properties.VirtualWan.ID) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVpnSiteNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VPNSitesClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNSitesClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVpnSiteNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testVpnSiteNativeID}, got.NativeIDs) + }) + + t.Run("List_by_subscription", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Equal(t, []string{testVpnSiteNativeID}, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VPNSitesClientGetOptions) (armnetwork.VPNSitesClientGetResponse, error) { + return armnetwork.VPNSitesClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnSiteNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/schema/pkl/network/virtualhub.pkl b/schema/pkl/network/virtualhub.pkl new file mode 100644 index 00000000..66cabdb8 --- /dev/null +++ b/schema/pkl/network/virtualhub.pkl @@ -0,0 +1,91 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.virtualhub + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VirtualHub" +const apiVersion = "2023-05-01" + +/// Hub tier. Must match the parent WAN: a Basic WAN accepts only a Basic hub. +typealias VirtualHubSku = "Basic"|"Standard" + +/// Which gateway the hub prefers when the same prefix is learned from several +/// sources. +typealias HubRoutingPreference = "ExpressRoute"|"VpnGateway"|"ASPath" + +open class VirtualHubResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VirtualHubResolvable = (this) { + property = "id" + } + hidden name: VirtualHubResolvable = (this) { + property = "name" + } +} + +/// A regional hub inside a Virtual WAN — the managed router that branch, vnet and +/// ExpressRoute connections terminate on. +/// +/// The hub is billed from the moment it exists and takes several minutes to +/// provision (and to delete), well before any gateway is attached. It must be +/// created after its `AZURE::Network::VirtualWan` and deleted before it, which +/// `virtualWanId` expresses for you when you point it at `wan.res.id`. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VirtualHub extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// ARM ID of the Virtual WAN this hub belongs to, e.g. `wan.res.id`. A hub + /// cannot be moved between WANs. + @azure.FieldHint { required = true; createOnly = true } + virtualWanId: String|formae.Resolvable + + /// Private CIDR the hub allocates its internal components from. Azure requires + /// /24 or larger and recommends /23; it must not overlap any connected + /// network, and it cannot be changed once the hub exists. + @azure.FieldHint { required = true; createOnly = true } + addressPrefix: String + + /// Hub tier. Azure defaults to Standard. + @azure.FieldHint { createOnly = true; hasProviderDefault = true } + sku: VirtualHubSku? + + /// Route-selection preference for prefixes learned from more than one source. + /// Azure defaults to ExpressRoute. + @azure.FieldHint { hasProviderDefault = true } + hubRoutingPreference: HubRoutingPreference? + + /// Allow traffic between two branches to transit this hub. + @azure.FieldHint { hasProviderDefault = true } + allowBranchToBranchTraffic: Boolean? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VirtualHubResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/schema/pkl/network/virtualwan.pkl b/schema/pkl/network/virtualwan.pkl new file mode 100644 index 00000000..f57837a4 --- /dev/null +++ b/schema/pkl/network/virtualwan.pkl @@ -0,0 +1,85 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.virtualwan + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VirtualWan" +const apiVersion = "2023-05-01" + +/// Virtual WAN tier. `Basic` allows exactly one hub with a Basic VPN gateway and no +/// ExpressRoute; `Standard` is required for everything else (hub-to-hub transit, +/// ExpressRoute, Azure Firewall in the hub). An in-place Basic -> Standard upgrade +/// is supported; the reverse is not. +typealias VirtualWanTier = "Basic"|"Standard" + +open class VirtualWanResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VirtualWanResolvable = (this) { + property = "id" + } + hidden name: VirtualWanResolvable = (this) { + property = "name" + } +} + +/// The root of a Virtual WAN topology. The WAN itself carries no data path and +/// nothing bills against it — it is the container that hubs +/// (`AZURE::Network::VirtualHub`) and branch definitions +/// (`AZURE::Network::VpnSite`) attach to, so create it first and reference it from +/// both. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VirtualWan extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// Virtual WAN tier. Named `virtualWanTier` rather than ARM's + /// `properties.type` because the flattened property map already carries the + /// resource's own `type`. + @azure.FieldHint { hasProviderDefault = true } + virtualWanTier: VirtualWanTier? + + /// Disable IPsec encryption on VPN connections into this WAN. Azure defaults + /// to false. + @azure.FieldHint { hasProviderDefault = true } + disableVpnEncryption: Boolean? + + /// Allow branch-to-branch (site-to-site via the hub) transit. Azure defaults + /// to false. + @azure.FieldHint { hasProviderDefault = true } + allowBranchToBranchTraffic: Boolean? + + /// Allow vnet-to-vnet transit through the WAN. Azure defaults to false and + /// rejects `true` on a Basic WAN. + @azure.FieldHint { hasProviderDefault = true } + allowVnetToVnetTraffic: Boolean? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VirtualWanResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/schema/pkl/network/vpnsite.pkl b/schema/pkl/network/vpnsite.pkl new file mode 100644 index 00000000..0e873c44 --- /dev/null +++ b/schema/pkl/network/vpnsite.pkl @@ -0,0 +1,148 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.vpnsite + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VpnSite" +const apiVersion = "2023-05-01" + +/// Descriptive metadata about the on-premises appliance. Azure stores it and uses +/// the link speed for load distribution across links; nothing here changes the +/// data path. +class VpnSiteDeviceProperties { + /// Vendor of the on-premises device, e.g. "Cisco". + @azure.FieldHint { } + deviceVendor: String? + + /// Model of the on-premises device. + @azure.FieldHint { } + deviceModel: String? + + /// Aggregate speed of the site's links, in Mbps. + @azure.FieldHint { } + linkSpeedInMbps: Int(this >= 0)? +} + +/// Provider-side description of one physical link into the site. +class VpnSiteLinkProperties { + /// Name of the ISP / carrier providing the link. + @azure.FieldHint { } + linkProviderName: String? + + /// Speed of this link, in Mbps. + @azure.FieldHint { } + linkSpeedInMbps: Int(this >= 0)? +} + +/// BGP speaker settings for one link. Both values are required together: Azure +/// rejects a link that declares only one of them. +class VpnSiteLinkBgpProperties { + /// The on-premises device's autonomous system number for this link. + @azure.FieldHint { required = true } + asn: Int + + /// Address the on-premises device peers from, inside the site's address space. + @azure.FieldHint { required = true } + bgpPeeringAddress: String +} + +/// One physical link into the branch. A site needs at least one link before a +/// VPN gateway connection can target it. +class VpnSiteLink { + /// Name of the link, unique within the site. + @azure.FieldHint { required = true } + name: String(length >= 1 && length <= 80) + + /// Public IP of this link's VPN endpoint. Mutually exclusive with `fqdn`. + @azure.FieldHint { } + ipAddress: String? + + /// DNS name of this link's VPN endpoint, for a branch on a dynamic address. + /// Mutually exclusive with `ipAddress`. + @azure.FieldHint { } + fqdn: String? + + /// Carrier / speed metadata for the link. + @azure.FieldHint { } + linkProperties: VpnSiteLinkProperties? + + /// BGP settings for the link. Omit for a policy-routed (static) branch. + @azure.FieldHint { } + bgpProperties: VpnSiteLinkBgpProperties? +} + +open class VpnSiteResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VpnSiteResolvable = (this) { + property = "id" + } + hidden name: VpnSiteResolvable = (this) { + property = "name" + } +} + +/// The Virtual WAN description of a branch: the on-premises address space, the +/// appliance's public endpoints, and optional BGP settings per link. +/// +/// The site is free and provisions in seconds — it is metadata attached to a WAN, +/// not a data path. It only becomes live once a VPN gateway connection in a hub +/// references it, and it cannot be moved between WANs. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VpnSite extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// ARM ID of the Virtual WAN this branch belongs to, e.g. `wan.res.id`. A site + /// cannot be moved between WANs. + @azure.FieldHint { required = true; createOnly = true } + virtualWanId: String|formae.Resolvable + + /// CIDR ranges reachable behind the branch. Required for a static (non-BGP) + /// site; still recommended when BGP is used. + @azure.FieldHint { } + addressSpace: Listing? + + /// Descriptive metadata about the on-premises appliance. + @azure.FieldHint { } + deviceProperties: VpnSiteDeviceProperties? + + /// Public IP of the appliance, for a single-link site declared without + /// `vpnSiteLinks`. Prefer `vpnSiteLinks` — it is the only form that supports + /// per-link BGP and multiple links. + @azure.FieldHint { } + ipAddress: String? + + /// Physical links into the branch. + @azure.FieldHint { } + vpnSiteLinks: Listing? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VpnSiteResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/testdata/virtual-hub-update.pkl b/testdata/virtual-hub-update.pkl new file mode 100644 index 00000000..66e0f603 --- /dev/null +++ b/testdata/virtual-hub-update.pkl @@ -0,0 +1,62 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/virtualhub.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vhub-test-rg" + name = "formae-plugin-sdk-test-vhub-rg-\(vars.testRunID)" + location = "eastus" +} + +// The hub belongs to a WAN and cannot outlive it. Declaring both in one forma and +// referencing the WAN through `wan.res.id` lets formae order create and destroy; +// there is no explicit dependency to write down. +local wan = new virtualwan.VirtualWan { + label = "vhub-test-wan" + name = "formae-plugin-sdk-test-vhub-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +forma { + vars.stack + + vars.target + + rg + wan + + // SLOW AND BILLED: a hub takes roughly 10 minutes to provision and about the + // same to delete, and it bills for the whole time it exists even with no gateway + // attached. `allowBranchToBranchTraffic` is deliberately omitted: Virtual WAN + // reports it only for Route Server hubs, so declaring it would compare a value + // the service does not honour here. + new virtualhub.VirtualHub { + label = "plugin-sdk-test-virtual-hub" + name = "formae-plugin-sdk-test-vhub-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + // /23 is Azure's recommended hub prefix; it must not overlap any connected + // network. RFC 1918 space that nothing else in the fixture uses. + addressPrefix = "10.100.0.0/23" + sku = "Standard" + hubRoutingPreference = "ASPath" + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-hub.pkl b/testdata/virtual-hub.pkl new file mode 100644 index 00000000..8f0c663d --- /dev/null +++ b/testdata/virtual-hub.pkl @@ -0,0 +1,62 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/virtualhub.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vhub-test-rg" + name = "formae-plugin-sdk-test-vhub-rg-\(vars.testRunID)" + location = "eastus" +} + +// The hub belongs to a WAN and cannot outlive it. Declaring both in one forma and +// referencing the WAN through `wan.res.id` lets formae order create and destroy; +// there is no explicit dependency to write down. +local wan = new virtualwan.VirtualWan { + label = "vhub-test-wan" + name = "formae-plugin-sdk-test-vhub-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +forma { + vars.stack + + vars.target + + rg + wan + + // SLOW AND BILLED: a hub takes roughly 10 minutes to provision and about the + // same to delete, and it bills for the whole time it exists even with no gateway + // attached. `allowBranchToBranchTraffic` is deliberately omitted: Virtual WAN + // reports it only for Route Server hubs, so declaring it would compare a value + // the service does not honour here. + new virtualhub.VirtualHub { + label = "plugin-sdk-test-virtual-hub" + name = "formae-plugin-sdk-test-vhub-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + // /23 is Azure's recommended hub prefix; it must not overlap any connected + // network. RFC 1918 space that nothing else in the fixture uses. + addressPrefix = "10.100.0.0/23" + sku = "Standard" + hubRoutingPreference = "ExpressRoute" + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-wan-update.pkl b/testdata/virtual-wan-update.pkl new file mode 100644 index 00000000..f0cbdf98 --- /dev/null +++ b/testdata/virtual-wan-update.pkl @@ -0,0 +1,45 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vwan-test-rg" + name = "formae-plugin-sdk-test-vwan-rg-\(vars.testRunID)" + location = "eastus" +} + +forma { + vars.stack + + vars.target + + rg + + // The WAN on its own is free and provisions in well under a minute: it is only a + // container for hubs and branch definitions, with no data path of its own. + // `allowVnetToVnetTraffic` is deliberately omitted — Virtual WAN ignores it, so + // declaring it would compare a value the service never honours. + new virtualwan.VirtualWan { + label = "plugin-sdk-test-virtual-wan" + name = "formae-plugin-sdk-test-vwan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" + disableVpnEncryption = false + allowBranchToBranchTraffic = true + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-wan.pkl b/testdata/virtual-wan.pkl new file mode 100644 index 00000000..09b1cd59 --- /dev/null +++ b/testdata/virtual-wan.pkl @@ -0,0 +1,45 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vwan-test-rg" + name = "formae-plugin-sdk-test-vwan-rg-\(vars.testRunID)" + location = "eastus" +} + +forma { + vars.stack + + vars.target + + rg + + // The WAN on its own is free and provisions in well under a minute: it is only a + // container for hubs and branch definitions, with no data path of its own. + // `allowVnetToVnetTraffic` is deliberately omitted — Virtual WAN ignores it, so + // declaring it would compare a value the service never honours. + new virtualwan.VirtualWan { + label = "plugin-sdk-test-virtual-wan" + name = "formae-plugin-sdk-test-vwan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" + disableVpnEncryption = false + allowBranchToBranchTraffic = false + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/vpn-site-update.pkl b/testdata/vpn-site-update.pkl new file mode 100644 index 00000000..69f11d36 --- /dev/null +++ b/testdata/vpn-site-update.pkl @@ -0,0 +1,77 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/vpnsite.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vpnsite-test-rg" + name = "formae-plugin-sdk-test-vpnsite-rg-\(vars.testRunID)" + location = "eastus" +} + +// A site is attached to a WAN and cannot outlive it. Referencing the WAN through +// `wan.res.id` is what orders create and destroy — no hub or gateway is needed. +local wan = new virtualwan.VirtualWan { + label = "vpnsite-test-wan" + name = "formae-plugin-sdk-test-vpnsite-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +forma { + vars.stack + + vars.target + + rg + wan + + // Free and fast: the site is only a description of a branch office. Addresses + // come from the documentation / private ranges (RFC 5737, RFC 1918) so nothing + // real is described and no tunnel is ever established. + new vpnsite.VpnSite { + label = "plugin-sdk-test-vpn-site" + name = "formae-plugin-sdk-test-vpnsite-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressSpace { + "192.168.10.0/24" + "192.168.11.0/24" + } + deviceProperties = new vpnsite.VpnSiteDeviceProperties { + deviceVendor = "Contoso" + deviceModel = "CX-200" + linkSpeedInMbps = 200 + } + vpnSiteLinks { + new vpnsite.VpnSiteLink { + name = "link0" + ipAddress = "203.0.113.30" + linkProperties = new vpnsite.VpnSiteLinkProperties { + linkProviderName = "Contoso Telecom" + linkSpeedInMbps = 200 + } + bgpProperties = new vpnsite.VpnSiteLinkBgpProperties { + asn = 65020 + bgpPeeringAddress = "192.168.10.1" + } + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/vpn-site.pkl b/testdata/vpn-site.pkl new file mode 100644 index 00000000..62251265 --- /dev/null +++ b/testdata/vpn-site.pkl @@ -0,0 +1,76 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/vpnsite.pkl" +import "vars.pkl" + +local rg = new resourcegroup.ResourceGroup { + label = "vpnsite-test-rg" + name = "formae-plugin-sdk-test-vpnsite-rg-\(vars.testRunID)" + location = "eastus" +} + +// A site is attached to a WAN and cannot outlive it. Referencing the WAN through +// `wan.res.id` is what orders create and destroy — no hub or gateway is needed. +local wan = new virtualwan.VirtualWan { + label = "vpnsite-test-wan" + name = "formae-plugin-sdk-test-vpnsite-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +forma { + vars.stack + + vars.target + + rg + wan + + // Free and fast: the site is only a description of a branch office. Addresses + // come from the documentation / private ranges (RFC 5737, RFC 1918) so nothing + // real is described and no tunnel is ever established. + new vpnsite.VpnSite { + label = "plugin-sdk-test-vpn-site" + name = "formae-plugin-sdk-test-vpnsite-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressSpace { + "192.168.10.0/24" + } + deviceProperties = new vpnsite.VpnSiteDeviceProperties { + deviceVendor = "Contoso" + deviceModel = "CX-100" + linkSpeedInMbps = 100 + } + vpnSiteLinks { + new vpnsite.VpnSiteLink { + name = "link0" + ipAddress = "203.0.113.30" + linkProperties = new vpnsite.VpnSiteLinkProperties { + linkProviderName = "Contoso Telecom" + linkSpeedInMbps = 100 + } + bgpProperties = new vpnsite.VpnSiteLinkBgpProperties { + asn = 65020 + bgpPeeringAddress = "192.168.10.1" + } + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} From a88f9b5cddae0654697893ebe967eddb299620d7 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:25:10 +0200 Subject: [PATCH 2/6] feat(azure): Network::VpnGateway and BastionHost - AZURE::Network::VpnGateway - Microsoft.Network/vpnGateways (2023-05-01) - AZURE::Network::BastionHost - Microsoft.Network/bastionHosts (2023-05-01) Both are LRO on create, update and delete and follow the existing LocalNetworkGateway shape, including the pending-poller branch that pins the ARM path while the operation runs - the gateway takes ~30 minutes to create and the Bastion host ~10, so a mis-pinned native ID would orphan a billed resource. VpnGateway is the Virtual-WAN-scoped gateway: it takes its hub by resolvable ARM ID, models bgpSettings as ASN plus peer weight only (Azure allocates the peering addresses out of the hub prefix and they are dropped on read), and models connections with the caller-owned fields only - Azure seeds one vpnLinkConnections entry per remote link and reports status and byte counters, all discarded. sharedKey is write-only and never surfaced. BastionHost validates up front that its subnet is named exactly AzureBastionSubnet, turning a ten-minute ARM rejection into an immediate error. Conformance fixtures: vpn-gateway (rg -> WAN -> hub -> gateway, plus a free VPN site for the connection) and bastion-host (rg -> vnet -> AzureBastionSubnet + Standard static public IP -> host), each with -update. --- pkg/resources/bastionhost.go | 601 ++++++++++++++++++ pkg/resources/bastionhost_integration_test.go | 343 ++++++++++ pkg/resources/vpngateway.go | 574 +++++++++++++++++ pkg/resources/vpngateway_integration_test.go | 340 ++++++++++ schema/pkl/network/bastionhost.pkl | 140 ++++ schema/pkl/network/vpngateway.pkl | 146 +++++ testdata/bastion-host-update.pkl | 106 +++ testdata/bastion-host.pkl | 106 +++ testdata/vpn-gateway-update.pkl | 114 ++++ testdata/vpn-gateway.pkl | 114 ++++ 10 files changed, 2584 insertions(+) create mode 100644 pkg/resources/bastionhost.go create mode 100644 pkg/resources/bastionhost_integration_test.go create mode 100644 pkg/resources/vpngateway.go create mode 100644 pkg/resources/vpngateway_integration_test.go create mode 100644 schema/pkl/network/bastionhost.pkl create mode 100644 schema/pkl/network/vpngateway.pkl create mode 100644 testdata/bastion-host-update.pkl create mode 100644 testdata/bastion-host.pkl create mode 100644 testdata/vpn-gateway-update.pkl create mode 100644 testdata/vpn-gateway.pkl diff --git a/pkg/resources/bastionhost.go b/pkg/resources/bastionhost.go new file mode 100644 index 00000000..bc02f67e --- /dev/null +++ b/pkg/resources/bastionhost.go @@ -0,0 +1,601 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeBastionHost = "AZURE::Network::BastionHost" + +// bastionHostsAPI is the armnetwork surface used here. BeginUpdateTags is +// deliberately absent: it cannot change the scale units or the feature toggles, so +// every update is a re-PUT. +type bastionHostsAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, bastionHostName string, parameters armnetwork.BastionHost, options *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, bastionHostName string, options *armnetwork.BastionHostsClientGetOptions) (armnetwork.BastionHostsClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, bastionHostName string, options *armnetwork.BastionHostsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) + NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.BastionHostsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.BastionHostsClientListByResourceGroupResponse] + NewListPager(options *armnetwork.BastionHostsClientListOptions) *runtime.Pager[armnetwork.BastionHostsClientListResponse] +} + +func init() { + registry.Register(ResourceTypeBastionHost, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &BastionHost{ + api: c.BastionHostsClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// BastionHost is the provisioner for the managed jump host +// (Microsoft.Network/bastionHosts). +type BastionHost struct { + api bastionHostsAPI + pipeline runtime.Pipeline + config *config.Config +} + +// bastionHostProps mirrors schema/pkl/network/bastionhost.pkl. +type bastionHostProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + SKU *bastionHostSkuProps `json:"sku"` + ScaleUnits *int32 `json:"scaleUnits"` + IPConfigurations []bastionHostIPConfigProps `json:"ipConfigurations"` + EnableTunneling *bool `json:"enableTunneling"` + EnableIPConnect *bool `json:"enableIpConnect"` + EnableFileCopy *bool `json:"enableFileCopy"` + EnableShareableLink *bool `json:"enableShareableLink"` + EnableKerberos *bool `json:"enableKerberos"` + DisableCopyPaste *bool `json:"disableCopyPaste"` +} + +type bastionHostSkuProps struct { + Name string `json:"name"` +} + +type bastionHostIPConfigProps struct { + Name string `json:"name"` + SubnetID string `json:"subnetId"` + PublicIPAddressID string `json:"publicIpAddressId"` + PrivateIPAllocationMethod *string `json:"privateIpAllocationMethod"` +} + +// bastionSubnetName is the only subnet name Azure accepts for a Bastion host. It is +// checked here so the failure names the real problem instead of surfacing ARM's +// generic rejection half an hour later. +const bastionSubnetName = "AzureBastionSubnet" + +var ( + // bastionHostSkus and bastionIPAllocationMethods carry the canonical casing for + // the two enums, applied on the read path because ARM echoes them back + // inconsistently. + bastionHostSkus = []string{"Basic", "Standard"} + bastionIPAllocationMethods = []string{"Dynamic", "Static"} +) + +// lastARMSegment returns the final path segment of an ARM ID. For a subnet ID that +// is the subnet's own name, which is what the two gateway families have to check: +// Azure only accepts "AzureBastionSubnet" for a Bastion host and "GatewaySubnet" +// for a VirtualNetworkGateway. Reported false for an empty or trailing-slash input +// so callers skip the check instead of comparing against "". +func lastARMSegment(resourceID string) (string, bool) { + trimmed := strings.TrimSpace(resourceID) + if trimmed == "" { + return "", false + } + if idx := strings.LastIndex(trimmed, "/"); idx >= 0 { + trimmed = trimmed[idx+1:] + } + if trimmed == "" { + return "", false + } + return trimmed, true +} + +func bastionHostIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "bastionhosts") + if err != nil { + return "", "", err + } + return rgName, names["bastionhosts"], nil +} + +func (r *BastionHost) buildPropertiesFromResult(host *armnetwork.BastionHost, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if host.ID != nil { + props["id"] = *host.ID + } + if host.Name != nil { + props["name"] = *host.Name + } + if host.Location != nil { + props["location"] = normalizeAzureLocation(*host.Location) + } + if tags := azureTagsToFormaeTags(host.Tags); len(tags) > 0 { + props["Tags"] = tags + } + if sku := host.SKU; sku != nil && sku.Name != nil && *sku.Name != "" { + props["sku"] = map[string]any{ + "name": canonicalizeEnum(string(*sku.Name), bastionHostSkus...), + } + } + + if p := host.Properties; p != nil { + if p.ScaleUnits != nil { + props["scaleUnits"] = *p.ScaleUnits + } + if p.EnableTunneling != nil { + props["enableTunneling"] = *p.EnableTunneling + } + if p.EnableIPConnect != nil { + props["enableIpConnect"] = *p.EnableIPConnect + } + if p.EnableFileCopy != nil { + props["enableFileCopy"] = *p.EnableFileCopy + } + if p.EnableShareableLink != nil { + props["enableShareableLink"] = *p.EnableShareableLink + } + if p.EnableKerberos != nil { + props["enableKerberos"] = *p.EnableKerberos + } + if p.DisableCopyPaste != nil { + props["disableCopyPaste"] = *p.DisableCopyPaste + } + if configs := bastionIPConfigsToProps(p.IPConfigurations); len(configs) > 0 { + props["ipConfigurations"] = configs + } + // dnsName is the FQDN Azure mints for the host, provisioningState is service + // state, and networkAcls / virtualNetwork are Developer-SKU only and not + // modelled. + } + + return props +} + +// bastionIPConfigsToProps is the read-path inverse of bastionIPConfigsFromProps. It +// emits only the modelled fields: the per-config ARM ID, etag, type and +// provisioningState are service-assigned. +func bastionIPConfigsToProps(configs []*armnetwork.BastionHostIPConfiguration) []map[string]any { + if len(configs) == 0 { + return nil + } + out := make([]map[string]any, 0, len(configs)) + for _, cfg := range configs { + if cfg == nil { + continue + } + entry := make(map[string]any) + if cfg.Name != nil { + entry["name"] = *cfg.Name + } + if cp := cfg.Properties; cp != nil { + if cp.Subnet != nil && cp.Subnet.ID != nil { + entry["subnetId"] = *cp.Subnet.ID + } + if cp.PublicIPAddress != nil && cp.PublicIPAddress.ID != nil { + entry["publicIpAddressId"] = *cp.PublicIPAddress.ID + } + if cp.PrivateIPAllocationMethod != nil && *cp.PrivateIPAllocationMethod != "" { + entry["privateIpAllocationMethod"] = canonicalizeEnum(string(*cp.PrivateIPAllocationMethod), bastionIPAllocationMethods...) + } + } + out = append(out, entry) + } + return out +} + +// bastionIPConfigsFromProps builds the request-side IP configuration list. +func bastionIPConfigsFromProps(configs []bastionHostIPConfigProps) []*armnetwork.BastionHostIPConfiguration { + if len(configs) == 0 { + return nil + } + out := make([]*armnetwork.BastionHostIPConfiguration, 0, len(configs)) + for i := range configs { + cfg := configs[i] + armCfg := &armnetwork.BastionHostIPConfiguration{ + Name: to.Ptr(cfg.Name), + Properties: &armnetwork.BastionHostIPConfigurationPropertiesFormat{ + Subnet: &armnetwork.SubResource{ID: to.Ptr(cfg.SubnetID)}, + PublicIPAddress: &armnetwork.SubResource{ID: to.Ptr(cfg.PublicIPAddressID)}, + }, + } + if cfg.PrivateIPAllocationMethod != nil { + armCfg.Properties.PrivateIPAllocationMethod = to.Ptr(armnetwork.IPAllocationMethod(*cfg.PrivateIPAllocationMethod)) + } + out = append(out, armCfg) + } + return out +} + +// bastionHostParams builds the request body shared by create and update. +func bastionHostParams(props bastionHostProps, payload json.RawMessage) armnetwork.BastionHost { + params := armnetwork.BastionHost{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.BastionHostPropertiesFormat{ + ScaleUnits: props.ScaleUnits, + IPConfigurations: bastionIPConfigsFromProps(props.IPConfigurations), + EnableTunneling: props.EnableTunneling, + EnableIPConnect: props.EnableIPConnect, + EnableFileCopy: props.EnableFileCopy, + EnableShareableLink: props.EnableShareableLink, + EnableKerberos: props.EnableKerberos, + DisableCopyPaste: props.DisableCopyPaste, + }, + } + if sku := props.SKU; sku != nil && sku.Name != "" { + params.SKU = &armnetwork.SKU{Name: to.Ptr(armnetwork.BastionHostSKUName(sku.Name))} + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: BeginUpdateTags cannot touch the scale units +// or the feature toggles, so an update is another CreateOrUpdate. +func (r *BastionHost) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], bastionHostProps, string, error) { + var props bastionHostProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if len(props.IPConfigurations) == 0 { + return nil, props, "", fmt.Errorf("ipConfigurations is required") + } + for _, cfg := range props.IPConfigurations { + if cfg.Name == "" { + return nil, props, "", fmt.Errorf("every ipConfigurations entry needs a name") + } + if cfg.SubnetID == "" { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q needs a subnetId", cfg.Name) + } + if cfg.PublicIPAddressID == "" { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q needs a publicIpAddressId", cfg.Name) + } + // Azure only accepts a subnet literally named AzureBastionSubnet. Catching it + // here turns a ten-minute ARM rejection into an immediate, specific error. + if subnet, ok := lastARMSegment(cfg.SubnetID); ok && subnet != bastionSubnetName { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q references subnet %q: a Bastion host requires a subnet named exactly %s", + cfg.Name, subnet, bastionSubnetName) + } + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + bastionHostParams(props, payload), nil) + return poller, props, name, err +} + +func (r *BastionHost) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/bastionHosts/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromHost(&result.BastionHost) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *BastionHost) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := bastionHostIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.BastionHost, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeBastionHost, + Properties: string(propsJSON), + }, nil +} + +func (r *BastionHost) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := bastionHostIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.BastionHost, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *BastionHost) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := bastionHostIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *BastionHost) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.BastionHostsClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.BastionHostsClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromHost(&result.BastionHost) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) { + return resumePoller[armnetwork.BastionHostsClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *BastionHost) completeFromHost(host *armnetwork.BastionHost) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if host.ID != nil { + nativeID = *host.ID + if rg, _, err := bastionHostIDParts(*host.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(host, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List narrows to a resource group when one is supplied and otherwise sweeps the +// whole subscription. +func (r *BastionHost) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + + var nativeIDs []string + if rgName != "" { + pager := r.api.NewListByResourceGroupPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list bastion hosts in resource group %s: %w", rgName, err) + } + for _, host := range page.Value { + if host.ID != nil { + nativeIDs = append(nativeIDs, *host.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil + } + + pager := r.api.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list bastion hosts: %w", err) + } + for _, host := range page.Value { + if host.ID != nil { + nativeIDs = append(nativeIDs, *host.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/bastionhost_integration_test.go b/pkg/resources/bastionhost_integration_test.go new file mode 100644 index 00000000..98f3d38c --- /dev/null +++ b/pkg/resources/bastionhost_integration_test.go @@ -0,0 +1,343 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testBastionHostNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/bastionHosts/bastion1" + testBastionSubnetID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/AzureBastionSubnet" + testBastionPipID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/publicIPAddresses/pip1" +) + +type fakeBastionHostsAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.BastionHost, options *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.BastionHostsClientGetOptions) (armnetwork.BastionHostsClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.BastionHostsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) + newListByResourceGroupPagerFn func(rgName string, options *armnetwork.BastionHostsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.BastionHostsClientListByResourceGroupResponse] + newListPagerFn func(options *armnetwork.BastionHostsClientListOptions) *runtime.Pager[armnetwork.BastionHostsClientListResponse] +} + +func (f *fakeBastionHostsAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.BastionHost, options *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeBastionHostsAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.BastionHostsClientGetOptions) (armnetwork.BastionHostsClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeBastionHostsAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.BastionHostsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeBastionHostsAPI) NewListByResourceGroupPager(rgName string, options *armnetwork.BastionHostsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.BastionHostsClientListByResourceGroupResponse] { + return f.newListByResourceGroupPagerFn(rgName, options) +} + +func (f *fakeBastionHostsAPI) NewListPager(options *armnetwork.BastionHostsClientListOptions) *runtime.Pager[armnetwork.BastionHostsClientListResponse] { + return f.newListPagerFn(options) +} + +func newTestBastionHost(api bastionHostsAPI) *BastionHost { + return &BastionHost{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func bastionHostDesired(scaleUnits int, fileCopy bool) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "bastion1", + "resourceGroupName": "rg-1", + "location": "eastus", + "sku": map[string]any{"name": "Standard"}, + "scaleUnits": scaleUnits, + "ipConfigurations": []any{map[string]any{ + "name": "IpConf", + "subnetId": testBastionSubnetID, + "publicIpAddressId": testBastionPipID, + "privateIpAllocationMethod": "Dynamic", + }}, + "enableTunneling": true, + "enableIpConnect": true, + "enableFileCopy": fileCopy, + "enableShareableLink": false, + "enableKerberos": false, + "disableCopyPaste": false, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestBastionHost_CRUD(t *testing.T) { + hostResult := armnetwork.BastionHost{ + ID: to.Ptr(testBastionHostNativeID), + Name: to.Ptr("bastion1"), + Location: to.Ptr("East US"), + // ARM echoes the SKU name back with its own casing. + SKU: &armnetwork.SKU{Name: to.Ptr(armnetwork.BastionHostSKUName("standard"))}, + Properties: &armnetwork.BastionHostPropertiesFormat{ + ScaleUnits: to.Ptr(int32(2)), + IPConfigurations: []*armnetwork.BastionHostIPConfiguration{{ + // ARM assigns the child ID, etag and type; none may reach state. + ID: to.Ptr(testBastionHostNativeID + "/bastionHostIpConfigurations/IpConf"), + Name: to.Ptr("IpConf"), + Etag: to.Ptr("W/\"ipconf-etag\""), + Type: to.Ptr("Microsoft.Network/bastionHosts/bastionHostIpConfigurations"), + Properties: &armnetwork.BastionHostIPConfigurationPropertiesFormat{ + Subnet: &armnetwork.SubResource{ID: to.Ptr(testBastionSubnetID)}, + PublicIPAddress: &armnetwork.SubResource{ID: to.Ptr(testBastionPipID)}, + PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethod("dynamic")), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + }}, + EnableTunneling: to.Ptr(true), + EnableIPConnect: to.Ptr(true), + EnableFileCopy: to.Ptr(false), + EnableShareableLink: to.Ptr(false), + EnableKerberos: to.Ptr(false), + DisableCopyPaste: to.Ptr(false), + // Service state: the FQDN Azure mints and the provisioning state. + DNSName: to.Ptr("bst-11112222-3333-4444-5555-666677778888.bastion.azure.com"), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.BastionHost + createCalls := 0 + deleteCalls := 0 + fake := &fakeBastionHostsAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.BastionHost, _ *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "bastion1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.BastionHostsClientCreateOrUpdateResponse{BastionHost: hostResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.BastionHostsClientGetOptions) (armnetwork.BastionHostsClientGetResponse, error) { + return armnetwork.BastionHostsClientGetResponse{BastionHost: hostResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.BastionHostsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.BastionHostsClientDeleteResponse{}), nil + }, + newListByResourceGroupPagerFn: func(_ string, _ *armnetwork.BastionHostsClientListByResourceGroupOptions) *runtime.Pager[armnetwork.BastionHostsClientListByResourceGroupResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.BastionHostsClientListByResourceGroupResponse]{ + More: func(_ armnetwork.BastionHostsClientListByResourceGroupResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.BastionHostsClientListByResourceGroupResponse) (armnetwork.BastionHostsClientListByResourceGroupResponse, error) { + return armnetwork.BastionHostsClientListByResourceGroupResponse{ + BastionHostListResult: armnetwork.BastionHostListResult{ + Value: []*armnetwork.BastionHost{{ID: to.Ptr(testBastionHostNativeID)}}, + }, + }, nil + }, + }) + }, + newListPagerFn: func(_ *armnetwork.BastionHostsClientListOptions) *runtime.Pager[armnetwork.BastionHostsClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.BastionHostsClientListResponse]{ + More: func(_ armnetwork.BastionHostsClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.BastionHostsClientListResponse) (armnetwork.BastionHostsClientListResponse, error) { + return armnetwork.BastionHostsClientListResponse{ + BastionHostListResult: armnetwork.BastionHostListResult{ + Value: []*armnetwork.BastionHost{{ID: to.Ptr(testBastionHostNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestBastionHost(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "bastion1", Properties: bastionHostDesired(2, false), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testBastionHostNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.BastionHostSKUNameStandard, *sent.SKU.Name) + require.Equal(t, int32(2), *sent.Properties.ScaleUnits) + require.Len(t, sent.Properties.IPConfigurations, 1) + cfg := sent.Properties.IPConfigurations[0] + require.Equal(t, "IpConf", *cfg.Name) + require.Equal(t, testBastionSubnetID, *cfg.Properties.Subnet.ID) + require.Equal(t, testBastionPipID, *cfg.Properties.PublicIPAddress.ID) + require.Equal(t, armnetwork.IPAllocationMethodDynamic, *cfg.Properties.PrivateIPAllocationMethod) + require.True(t, *sent.Properties.EnableTunneling) + require.True(t, *sent.Properties.EnableIPConnect) + require.False(t, *sent.Properties.EnableFileCopy) + require.False(t, *sent.Properties.EnableShareableLink) + require.False(t, *sent.Properties.DisableCopyPaste) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_ip_configurations", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "bastion1", "resourceGroupName": "rg-1", "location": "eastus", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "ipConfigurations is required") + }) + + t.Run("Create_requires_public_ip", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "bastion1", "resourceGroupName": "rg-1", "location": "eastus", + "ipConfigurations": []any{map[string]any{ + "name": "IpConf", "subnetId": testBastionSubnetID, + }}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "needs a publicIpAddressId") + }) + + // Azure accepts a Bastion host only in a subnet literally named + // AzureBastionSubnet, and rejects anything else after minutes of provisioning. + t.Run("Create_rejects_wrong_subnet_name", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "bastion1", "resourceGroupName": "rg-1", "location": "eastus", + "ipConfigurations": []any{map[string]any{ + "name": "IpConf", + "subnetId": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/default", + "publicIpAddressId": testBastionPipID, + }}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "requires a subnet named exactly AzureBastionSubnet") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or a ten-minute create orphans a billed host. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.BastionHost, _ *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.BastionHostsClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "bastion1", Properties: bastionHostDesired(2, false), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testBastionHostNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testBastionHostNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "bastion1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + // ARM returns "standard"; the schema union is "Standard". + require.Equal(t, "Standard", props["sku"].(map[string]any)["name"]) + require.EqualValues(t, 2, props["scaleUnits"]) + require.Equal(t, true, props["enableTunneling"]) + require.Equal(t, true, props["enableIpConnect"]) + require.Equal(t, false, props["enableFileCopy"]) + require.Equal(t, false, props["enableShareableLink"]) + require.Equal(t, false, props["disableCopyPaste"]) + + configs := props["ipConfigurations"].([]any) + require.Len(t, configs, 1) + cfg := configs[0].(map[string]any) + require.Equal(t, "IpConf", cfg["name"]) + require.Equal(t, testBastionSubnetID, cfg["subnetId"]) + require.Equal(t, testBastionPipID, cfg["publicIpAddressId"]) + // ARM returns "dynamic"; the schema union is "Dynamic". + require.Equal(t, "Dynamic", cfg["privateIpAllocationMethod"]) + }) + + // Service state and the ARM-assigned per-config identity would read as drift. + t.Run("Read_drops_service_state", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testBastionHostNativeID}) + require.NoError(t, err) + for _, key := range []string{ + "provisioningState", "dnsName", "bastion.azure.com", "etag", + "bastionHostIpConfigurations", "networkAcls", + } { + require.NotContains(t, got.Properties, key) + } + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.BastionHost, _ *armnetwork.BastionHostsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.BastionHostsClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.BastionHostsClientCreateOrUpdateResponse{BastionHost: hostResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testBastionHostNativeID, + DesiredProperties: bastionHostDesired(3, true), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Equal(t, int32(3), *sent.Properties.ScaleUnits) + require.True(t, *sent.Properties.EnableFileCopy) + // Location, SKU and the IP configuration must ride along: a PUT without them + // is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.BastionHostSKUNameStandard, *sent.SKU.Name) + require.Len(t, sent.Properties.IPConfigurations, 1) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testBastionHostNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.BastionHostsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.BastionHostsClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testBastionHostNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testBastionHostNativeID}, got.NativeIDs) + }) + + t.Run("List_by_subscription", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Equal(t, []string{testBastionHostNativeID}, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.BastionHostsClientGetOptions) (armnetwork.BastionHostsClientGetResponse, error) { + return armnetwork.BastionHostsClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testBastionHostNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/pkg/resources/vpngateway.go b/pkg/resources/vpngateway.go new file mode 100644 index 00000000..064344ca --- /dev/null +++ b/pkg/resources/vpngateway.go @@ -0,0 +1,574 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVpnGateway = "AZURE::Network::VpnGateway" + +// vpnGatewaysAPI is the armnetwork surface used here. BeginUpdateTags is +// deliberately absent: it cannot change the scale unit or the connections, so every +// update is a re-PUT. +type vpnGatewaysAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, gatewayName string, vpnGatewayParameters armnetwork.VPNGateway, options *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, gatewayName string, options *armnetwork.VPNGatewaysClientGetOptions) (armnetwork.VPNGatewaysClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, gatewayName string, options *armnetwork.VPNGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) + NewListByResourceGroupPager(resourceGroupName string, options *armnetwork.VPNGatewaysClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListByResourceGroupResponse] + NewListPager(options *armnetwork.VPNGatewaysClientListOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVpnGateway, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VpnGateway{ + api: c.VPNGatewaysClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VpnGateway is the provisioner for the site-to-site VPN gateway inside a Virtual +// WAN hub (Microsoft.Network/vpnGateways). +type VpnGateway struct { + api vpnGatewaysAPI + pipeline runtime.Pipeline + config *config.Config +} + +// vpnGatewayProps mirrors schema/pkl/network/vpngateway.pkl. +type vpnGatewayProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + VirtualHubID string `json:"virtualHubId"` + VpnGatewayScaleUnit *int32 `json:"vpnGatewayScaleUnit"` + BgpSettings *vpnGatewayBgpProps `json:"bgpSettings"` + Connections []vpnGatewayConnectionProps `json:"connections"` + EnableBgpRouteTranslationForNat *bool `json:"enableBgpRouteTranslationForNat"` + IsRoutingPreferenceInternet *bool `json:"isRoutingPreferenceInternet"` +} + +type vpnGatewayBgpProps struct { + Asn *int64 `json:"asn"` + PeerWeight *int32 `json:"peerWeight"` +} + +type vpnGatewayConnectionProps struct { + Name string `json:"name"` + RemoteVpnSiteID string `json:"remoteVpnSiteId"` + ConnectionBandwidth *int32 `json:"connectionBandwidth"` + EnableBgp *bool `json:"enableBgp"` + RoutingWeight *int32 `json:"routingWeight"` + VpnConnectionProtocolType *string `json:"vpnConnectionProtocolType"` + SharedKey *string `json:"sharedKey"` +} + +// vpnConnectionProtocols carries the canonical casing for the IKE version enum, +// applied on the read path because ARM echoes it back inconsistently. +var vpnConnectionProtocols = []string{"IKEv1", "IKEv2"} + +func vpnGatewayIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "vpngateways") + if err != nil { + return "", "", err + } + return rgName, names["vpngateways"], nil +} + +func (r *VpnGateway) buildPropertiesFromResult(gateway *armnetwork.VPNGateway, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if gateway.ID != nil { + props["id"] = *gateway.ID + } + if gateway.Name != nil { + props["name"] = *gateway.Name + } + if gateway.Location != nil { + props["location"] = normalizeAzureLocation(*gateway.Location) + } + if tags := azureTagsToFormaeTags(gateway.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + if p := gateway.Properties; p != nil { + if p.VirtualHub != nil && p.VirtualHub.ID != nil { + props["virtualHubId"] = *p.VirtualHub.ID + } + if p.VPNGatewayScaleUnit != nil { + props["vpnGatewayScaleUnit"] = *p.VPNGatewayScaleUnit + } + if p.EnableBgpRouteTranslationForNat != nil { + props["enableBgpRouteTranslationForNat"] = *p.EnableBgpRouteTranslationForNat + } + if p.IsRoutingPreferenceInternet != nil { + props["isRoutingPreferenceInternet"] = *p.IsRoutingPreferenceInternet + } + if bgp := p.BgpSettings; bgp != nil { + settings := make(map[string]any) + if bgp.Asn != nil { + settings["asn"] = *bgp.Asn + } + if bgp.PeerWeight != nil { + settings["peerWeight"] = *bgp.PeerWeight + } + if len(settings) > 0 { + props["bgpSettings"] = settings + } + // bgpPeeringAddress and bgpPeeringAddresses are allocated by Azure out of + // the hub's address prefix, so they are dropped rather than compared. + } + if conns := vpnGatewayConnectionsToProps(p.Connections); len(conns) > 0 { + props["connections"] = conns + } + // provisioningState, ipConfigurations and natRules are service state or not + // modelled. + } + + return props +} + +// vpnGatewayConnectionsToProps is the read-path inverse of +// vpnGatewayConnectionsFromProps. It emits only the modelled fields: the child ARM +// ID and etag, the vpnLinkConnections Azure seeds per remote link, the connection +// status and the byte counters are all service-owned. sharedKey is write-only and +// never surfaced. +func vpnGatewayConnectionsToProps(conns []*armnetwork.VPNConnection) []map[string]any { + if len(conns) == 0 { + return nil + } + out := make([]map[string]any, 0, len(conns)) + for _, conn := range conns { + if conn == nil { + continue + } + entry := make(map[string]any) + if conn.Name != nil { + entry["name"] = *conn.Name + } + if cp := conn.Properties; cp != nil { + if cp.RemoteVPNSite != nil && cp.RemoteVPNSite.ID != nil { + entry["remoteVpnSiteId"] = *cp.RemoteVPNSite.ID + } + if cp.ConnectionBandwidth != nil { + entry["connectionBandwidth"] = *cp.ConnectionBandwidth + } + if cp.EnableBgp != nil { + entry["enableBgp"] = *cp.EnableBgp + } + if cp.RoutingWeight != nil { + entry["routingWeight"] = *cp.RoutingWeight + } + if cp.VPNConnectionProtocolType != nil && *cp.VPNConnectionProtocolType != "" { + entry["vpnConnectionProtocolType"] = canonicalizeEnum(string(*cp.VPNConnectionProtocolType), vpnConnectionProtocols...) + } + } + out = append(out, entry) + } + return out +} + +// vpnGatewayConnectionsFromProps builds the request-side connection list. +func vpnGatewayConnectionsFromProps(conns []vpnGatewayConnectionProps) []*armnetwork.VPNConnection { + if len(conns) == 0 { + return nil + } + out := make([]*armnetwork.VPNConnection, 0, len(conns)) + for i := range conns { + conn := conns[i] + armConn := &armnetwork.VPNConnection{ + Name: to.Ptr(conn.Name), + Properties: &armnetwork.VPNConnectionProperties{ + RemoteVPNSite: &armnetwork.SubResource{ID: to.Ptr(conn.RemoteVpnSiteID)}, + ConnectionBandwidth: conn.ConnectionBandwidth, + EnableBgp: conn.EnableBgp, + RoutingWeight: conn.RoutingWeight, + SharedKey: conn.SharedKey, + }, + } + if conn.VpnConnectionProtocolType != nil { + armConn.Properties.VPNConnectionProtocolType = to.Ptr(armnetwork.VirtualNetworkGatewayConnectionProtocol(*conn.VpnConnectionProtocolType)) + } + out = append(out, armConn) + } + return out +} + +// vpnGatewayParams builds the request body shared by create and update. +func vpnGatewayParams(props vpnGatewayProps, payload json.RawMessage) armnetwork.VPNGateway { + params := armnetwork.VPNGateway{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VPNGatewayProperties{ + VirtualHub: &armnetwork.SubResource{ID: to.Ptr(props.VirtualHubID)}, + VPNGatewayScaleUnit: props.VpnGatewayScaleUnit, + EnableBgpRouteTranslationForNat: props.EnableBgpRouteTranslationForNat, + IsRoutingPreferenceInternet: props.IsRoutingPreferenceInternet, + Connections: vpnGatewayConnectionsFromProps(props.Connections), + }, + } + if bgp := props.BgpSettings; bgp != nil { + params.Properties.BgpSettings = &armnetwork.BgpSettings{ + Asn: bgp.Asn, + PeerWeight: bgp.PeerWeight, + } + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: BeginUpdateTags cannot touch the scale unit +// or the connections, so an update is another CreateOrUpdate. +func (r *VpnGateway) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], vpnGatewayProps, string, error) { + var props vpnGatewayProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if props.VirtualHubID == "" { + return nil, props, "", fmt.Errorf("virtualHubId is required") + } + for _, conn := range props.Connections { + if conn.Name == "" { + return nil, props, "", fmt.Errorf("every connections entry needs a name") + } + if conn.RemoteVpnSiteID == "" { + return nil, props, "", fmt.Errorf("connections entry %q needs a remoteVpnSiteId", conn.Name) + } + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + vpnGatewayParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VpnGateway) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/vpnGateways/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromGateway(&result.VPNGateway) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VpnGateway) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := vpnGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VPNGateway, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVpnGateway, + Properties: string(propsJSON), + }, nil +} + +func (r *VpnGateway) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := vpnGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VPNGateway, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VpnGateway) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := vpnGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VpnGateway) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VPNGatewaysClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromGateway(&result.VPNGateway) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) { + return resumePoller[armnetwork.VPNGatewaysClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VpnGateway) completeFromGateway(gateway *armnetwork.VPNGateway) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if gateway.ID != nil { + nativeID = *gateway.ID + if rg, _, err := vpnGatewayIDParts(*gateway.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(gateway, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List narrows to a resource group when one is supplied and otherwise sweeps the +// whole subscription. +func (r *VpnGateway) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + + var nativeIDs []string + if rgName != "" { + pager := r.api.NewListByResourceGroupPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list VPN gateways in resource group %s: %w", rgName, err) + } + for _, gateway := range page.Value { + if gateway.ID != nil { + nativeIDs = append(nativeIDs, *gateway.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil + } + + pager := r.api.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list VPN gateways: %w", err) + } + for _, gateway := range page.Value { + if gateway.ID != nil { + nativeIDs = append(nativeIDs, *gateway.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/vpngateway_integration_test.go b/pkg/resources/vpngateway_integration_test.go new file mode 100644 index 00000000..a80ced52 --- /dev/null +++ b/pkg/resources/vpngateway_integration_test.go @@ -0,0 +1,340 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testVpnGatewayNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/vpnGateways/vpngw1" + testVpnGatewayHubID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualHubs/hub1" + testVpnGatewaySiteID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/vpnSites/site1" +) + +type fakeVpnGatewaysAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VPNGateway, options *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VPNGatewaysClientGetOptions) (armnetwork.VPNGatewaysClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VPNGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) + newListByResourceGroupPagerFn func(rgName string, options *armnetwork.VPNGatewaysClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListByResourceGroupResponse] + newListPagerFn func(options *armnetwork.VPNGatewaysClientListOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListResponse] +} + +func (f *fakeVpnGatewaysAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VPNGateway, options *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVpnGatewaysAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VPNGatewaysClientGetOptions) (armnetwork.VPNGatewaysClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVpnGatewaysAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VPNGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVpnGatewaysAPI) NewListByResourceGroupPager(rgName string, options *armnetwork.VPNGatewaysClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListByResourceGroupResponse] { + return f.newListByResourceGroupPagerFn(rgName, options) +} + +func (f *fakeVpnGatewaysAPI) NewListPager(options *armnetwork.VPNGatewaysClientListOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListResponse] { + return f.newListPagerFn(options) +} + +func newTestVpnGateway(api vpnGatewaysAPI) *VpnGateway { + return &VpnGateway{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func vpnGatewayDesired(scaleUnit, routingWeight int) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "vpngw1", + "resourceGroupName": "rg-1", + "location": "eastus", + "virtualHubId": testVpnGatewayHubID, + "vpnGatewayScaleUnit": scaleUnit, + "bgpSettings": map[string]any{ + "asn": 65515, + "peerWeight": 0, + }, + "connections": []any{map[string]any{ + "name": "conn0", + "remoteVpnSiteId": testVpnGatewaySiteID, + "connectionBandwidth": 10, + "enableBgp": false, + "routingWeight": routingWeight, + "vpnConnectionProtocolType": "IKEv2", + "sharedKey": "not-a-real-psk", + }}, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVpnGateway_CRUD(t *testing.T) { + gatewayResult := armnetwork.VPNGateway{ + ID: to.Ptr(testVpnGatewayNativeID), + Name: to.Ptr("vpngw1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VPNGatewayProperties{ + VirtualHub: &armnetwork.SubResource{ID: to.Ptr(testVpnGatewayHubID)}, + VPNGatewayScaleUnit: to.Ptr(int32(1)), + BgpSettings: &armnetwork.BgpSettings{ + Asn: to.Ptr(int64(65515)), + PeerWeight: to.Ptr(int32(0)), + // Allocated by Azure out of the hub prefix; must not reach state. + BgpPeeringAddress: to.Ptr("10.100.0.12"), + BgpPeeringAddresses: []*armnetwork.IPConfigurationBgpPeeringAddress{{ + IPConfigurationID: to.Ptr("Instance0"), + }}, + }, + Connections: []*armnetwork.VPNConnection{{ + // ARM assigns the child ID and etag; neither may reach state. + ID: to.Ptr(testVpnGatewayNativeID + "/vpnConnections/conn0"), + Name: to.Ptr("conn0"), + Etag: to.Ptr("W/\"conn-etag\""), + Properties: &armnetwork.VPNConnectionProperties{ + RemoteVPNSite: &armnetwork.SubResource{ID: to.Ptr(testVpnGatewaySiteID)}, + ConnectionBandwidth: to.Ptr(int32(10)), + EnableBgp: to.Ptr(false), + RoutingWeight: to.Ptr(int32(0)), + // ARM echoes the IKE version back with its own casing. + VPNConnectionProtocolType: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionProtocol("ikev2")), + // Azure seeds one link connection per remote link, and returns the + // shared key. Neither is modelled. + SharedKey: to.Ptr("not-a-real-psk"), + VPNLinkConnections: []*armnetwork.VPNSiteLinkConnection{{ + Name: to.Ptr("conn0-link0"), + }}, + ConnectionStatus: to.Ptr(armnetwork.VPNConnectionStatusNotConnected), + EgressBytesTransferred: to.Ptr(int64(0)), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + }}, + // Service state: the gateway's instance IPs and provisioning state. + IPConfigurations: []*armnetwork.VPNGatewayIPConfiguration{{ + ID: to.Ptr("Instance0"), + }}, + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.VPNGateway + createCalls := 0 + deleteCalls := 0 + fake := &fakeVpnGatewaysAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VPNGateway, _ *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "vpngw1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VPNGatewaysClientCreateOrUpdateResponse{VPNGateway: gatewayResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VPNGatewaysClientGetOptions) (armnetwork.VPNGatewaysClientGetResponse, error) { + return armnetwork.VPNGatewaysClientGetResponse{VPNGateway: gatewayResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VPNGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VPNGatewaysClientDeleteResponse{}), nil + }, + newListByResourceGroupPagerFn: func(_ string, _ *armnetwork.VPNGatewaysClientListByResourceGroupOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListByResourceGroupResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VPNGatewaysClientListByResourceGroupResponse]{ + More: func(_ armnetwork.VPNGatewaysClientListByResourceGroupResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VPNGatewaysClientListByResourceGroupResponse) (armnetwork.VPNGatewaysClientListByResourceGroupResponse, error) { + return armnetwork.VPNGatewaysClientListByResourceGroupResponse{ + ListVPNGatewaysResult: armnetwork.ListVPNGatewaysResult{ + Value: []*armnetwork.VPNGateway{{ID: to.Ptr(testVpnGatewayNativeID)}}, + }, + }, nil + }, + }) + }, + newListPagerFn: func(_ *armnetwork.VPNGatewaysClientListOptions) *runtime.Pager[armnetwork.VPNGatewaysClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VPNGatewaysClientListResponse]{ + More: func(_ armnetwork.VPNGatewaysClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VPNGatewaysClientListResponse) (armnetwork.VPNGatewaysClientListResponse, error) { + return armnetwork.VPNGatewaysClientListResponse{ + ListVPNGatewaysResult: armnetwork.ListVPNGatewaysResult{ + Value: []*armnetwork.VPNGateway{{ID: to.Ptr(testVpnGatewayNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVpnGateway(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vpngw1", Properties: vpnGatewayDesired(1, 0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testVpnGatewayNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVpnGatewayHubID, *sent.Properties.VirtualHub.ID) + require.Equal(t, int32(1), *sent.Properties.VPNGatewayScaleUnit) + require.Equal(t, int64(65515), *sent.Properties.BgpSettings.Asn) + require.Equal(t, int32(0), *sent.Properties.BgpSettings.PeerWeight) + require.Len(t, sent.Properties.Connections, 1) + conn := sent.Properties.Connections[0] + require.Equal(t, "conn0", *conn.Name) + require.Equal(t, testVpnGatewaySiteID, *conn.Properties.RemoteVPNSite.ID) + require.Equal(t, int32(10), *conn.Properties.ConnectionBandwidth) + require.Equal(t, armnetwork.VirtualNetworkGatewayConnectionProtocolIKEv2, *conn.Properties.VPNConnectionProtocolType) + // The shared key must reach ARM even though it is never read back. + require.Equal(t, "not-a-real-psk", *conn.Properties.SharedKey) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_virtual_hub", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vpngw1", "resourceGroupName": "rg-1", "location": "eastus", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "virtualHubId is required") + }) + + t.Run("Create_requires_remote_site_on_every_connection", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vpngw1", "resourceGroupName": "rg-1", "location": "eastus", + "virtualHubId": testVpnGatewayHubID, + "connections": []any{map[string]any{"name": "conn0"}}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "needs a remoteVpnSiteId") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or a 30-minute create orphans a billed gateway. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VPNGateway, _ *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vpngw1", Properties: vpnGatewayDesired(1, 0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVpnGatewayNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnGatewayNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "vpngw1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + require.Equal(t, testVpnGatewayHubID, props["virtualHubId"]) + require.EqualValues(t, 1, props["vpnGatewayScaleUnit"]) + + bgp := props["bgpSettings"].(map[string]any) + require.EqualValues(t, 65515, bgp["asn"]) + require.EqualValues(t, 0, bgp["peerWeight"]) + require.NotContains(t, bgp, "bgpPeeringAddress") + + conns := props["connections"].([]any) + require.Len(t, conns, 1) + conn := conns[0].(map[string]any) + require.Equal(t, "conn0", conn["name"]) + require.Equal(t, testVpnGatewaySiteID, conn["remoteVpnSiteId"]) + require.EqualValues(t, 10, conn["connectionBandwidth"]) + // ARM returns "ikev2"; the schema union is "IKEv2". + require.Equal(t, "IKEv2", conn["vpnConnectionProtocolType"]) + }) + + // Service state, the Azure-allocated peering addresses and the write-only shared + // key must never reach stored state. + t.Run("Read_drops_service_state_and_secrets", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnGatewayNativeID}) + require.NoError(t, err) + for _, key := range []string{ + "provisioningState", "ipConfigurations", "etag", "bgpPeeringAddress", + "bgpPeeringAddresses", "vpnLinkConnections", "connectionStatus", + "egressBytesTransferred", "sharedKey", "not-a-real-psk", + } { + require.NotContains(t, got.Properties, key) + } + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VPNGateway, _ *armnetwork.VPNGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VPNGatewaysClientCreateOrUpdateResponse{VPNGateway: gatewayResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testVpnGatewayNativeID, + DesiredProperties: vpnGatewayDesired(2, 10), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Equal(t, int32(2), *sent.Properties.VPNGatewayScaleUnit) + require.Equal(t, int32(10), *sent.Properties.Connections[0].Properties.RoutingWeight) + // Location and the hub reference must ride along: a PUT without them is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, testVpnGatewayHubID, *sent.Properties.VirtualHub.ID) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVpnGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VPNGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VPNGatewaysClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVpnGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testVpnGatewayNativeID}, got.NativeIDs) + }) + + t.Run("List_by_subscription", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Equal(t, []string{testVpnGatewayNativeID}, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VPNGatewaysClientGetOptions) (armnetwork.VPNGatewaysClientGetResponse, error) { + return armnetwork.VPNGatewaysClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVpnGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/schema/pkl/network/bastionhost.pkl b/schema/pkl/network/bastionhost.pkl new file mode 100644 index 00000000..986a1d5d --- /dev/null +++ b/schema/pkl/network/bastionhost.pkl @@ -0,0 +1,140 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.bastionhost + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::BastionHost" +const apiVersion = "2023-05-01" + +/// Bastion tier. `Basic` is RDP/SSH through the portal only; `Standard` is what +/// the tunneling, IP-connect, file-copy and shareable-link features and +/// `scaleUnits` require. +typealias BastionHostSkuName = "Basic"|"Standard" + +/// Private IP allocation for the bastion's own interface. +typealias BastionIPAllocationMethod = "Dynamic"|"Static" + +class BastionHostSku { + @azure.FieldHint { required = true } + name: BastionHostSkuName +} + +/// The bastion's front end: the dedicated subnet it lives in plus the public IP +/// it answers on. +class BastionHostIPConfiguration { + /// Name of the IP configuration. + @azure.FieldHint { required = true } + name: String(length >= 1 && length <= 80) + + /// ARM ID of the subnet the bastion lives in, e.g. `bastionSubnet.res.id`. + /// + /// The subnet MUST be named exactly `AzureBastionSubnet` and be /26 or larger + /// (/27 works only for Basic without scale-out). Azure rejects any other name + /// outright. + @azure.FieldHint { required = true } + subnetId: String|formae.Resolvable + + /// ARM ID of the public IP the bastion answers on, e.g. `pip.res.id`. It must + /// be a Standard-SKU, statically allocated IP in the same region. + @azure.FieldHint { required = true } + publicIpAddressId: String|formae.Resolvable + + /// Allocation method for the bastion's private address. Azure defaults to + /// Dynamic. + @azure.FieldHint { } + privateIpAllocationMethod: BastionIPAllocationMethod? +} + +open class BastionHostResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: BastionHostResolvable = (this) { + property = "id" + } + hidden name: BastionHostResolvable = (this) { + property = "name" + } +} + +/// Managed jump host for RDP/SSH into a virtual network without exposing the VMs. +/// +/// SLOW AND BILLED: a Bastion host takes roughly 10 minutes to create and about +/// the same to delete, and it bills hourly (plus per scale unit on Standard) for +/// the whole time it exists. +/// +/// It needs two things in place first: a subnet named exactly +/// `AzureBastionSubnet` (/26 or larger) in the target virtual network, and a +/// Standard-SKU static public IP in the same region. Both are expressed as +/// resolvable references inside `ipConfigurations`. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class BastionHost extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// Bastion tier. A Basic host cannot be upgraded to Standard in place. + @azure.FieldHint { createOnly = true; hasProviderDefault = true } + sku: BastionHostSku? + + /// Instance count, Standard only. Each unit is billed hourly; Azure defaults + /// to 2. + @azure.FieldHint { hasProviderDefault = true } + scaleUnits: Int(this >= 2 && this <= 50)? + + /// The bastion's front end. Azure accepts exactly one entry and it cannot be + /// changed after creation. + @azure.FieldHint { requiredOnCreate = true; createOnly = true } + ipConfigurations: Listing + + /// Allow native-client tunneling (`az network bastion tunnel`). Standard only. + @azure.FieldHint { hasProviderDefault = true } + enableTunneling: Boolean? + + /// Allow connecting to a VM by IP address rather than by resource. Standard only. + @azure.FieldHint { hasProviderDefault = true } + enableIpConnect: Boolean? + + /// Allow file upload/download over the session. Standard only. + @azure.FieldHint { hasProviderDefault = true } + enableFileCopy: Boolean? + + /// Allow issuing shareable links to a VM. Standard only. + @azure.FieldHint { hasProviderDefault = true } + enableShareableLink: Boolean? + + /// Allow Kerberos authentication. Standard only. + @azure.FieldHint { hasProviderDefault = true } + enableKerberos: Boolean? + + /// Block clipboard sharing with the session. + @azure.FieldHint { hasProviderDefault = true } + disableCopyPaste: Boolean? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: BastionHostResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/schema/pkl/network/vpngateway.pkl b/schema/pkl/network/vpngateway.pkl new file mode 100644 index 00000000..fbdba300 --- /dev/null +++ b/schema/pkl/network/vpngateway.pkl @@ -0,0 +1,146 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.vpngateway + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VpnGateway" +const apiVersion = "2023-05-01" + +/// IKE version used by a site-to-site connection. +typealias VpnConnectionProtocol = "IKEv1"|"IKEv2" + +/// BGP speaker settings for the gateway. +/// +/// Only the two values a caller owns are modelled: the gateway's peering +/// addresses are allocated by Azure out of the hub's address prefix, so they are +/// read back and discarded rather than compared. +class VpnGatewayBgpSettings { + /// The gateway's autonomous system number. Azure assigns 65515 when the + /// gateway is created without one; it cannot be changed afterwards. + @azure.FieldHint { required = true } + asn: Int + + /// Weight added to routes learned over BGP by this gateway. + @azure.FieldHint { required = true } + peerWeight: Int +} + +/// A site-to-site connection from this gateway to one `AZURE::Network::VpnSite`. +/// +/// Only the fields a caller owns are modelled. Azure fills in one +/// `vpnLinkConnections` entry per link on the remote site, plus the connection +/// status and byte counters; those are read back and discarded. +class VpnGatewayConnection { + /// Name of the connection, unique within the gateway. + @azure.FieldHint { required = true } + name: String(length >= 1 && length <= 80) + + /// ARM ID of the VpnSite this connection terminates on, e.g. `site.res.id`. + @azure.FieldHint { required = true } + remoteVpnSiteId: String|formae.Resolvable + + /// Expected aggregate bandwidth of the connection, in Mbps. + @azure.FieldHint { required = true } + connectionBandwidth: Int(this >= 1) + + /// Run BGP over the tunnel. Requires `bgpProperties` on the remote site's links. + @azure.FieldHint { required = true } + enableBgp: Boolean + + /// Route weight applied to prefixes learned over this connection. + @azure.FieldHint { required = true } + routingWeight: Int(this >= 0) + + /// IKE version. Azure defaults to IKEv2. + @azure.FieldHint { required = true } + vpnConnectionProtocolType: VpnConnectionProtocol + + /// Pre-shared key for the tunnel. Write-only — Azure returns it on read, but + /// it must never be stored in cleartext, so wrap it with + /// `formae.value(...).opaque`. + @azure.FieldHint { writeOnly = true } + sharedKey: (formae.Value|String)? +} + +open class VpnGatewayResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VpnGatewayResolvable = (this) { + property = "id" + } + hidden name: VpnGatewayResolvable = (this) { + property = "name" + } +} + +/// The site-to-site VPN gateway inside a Virtual WAN hub. This is the Virtual WAN +/// gateway, not the classic vnet-scoped one — that is +/// `AZURE::Network::VirtualNetworkGateway`. +/// +/// VERY SLOW AND BILLED: a VPN gateway in a hub takes roughly 30 minutes to +/// create and about the same to delete, and it bills per scale unit for the whole +/// time it exists. It requires an `AZURE::Network::VirtualHub`, which in turn +/// requires an `AZURE::Network::VirtualWan`; referencing the hub through +/// `hub.res.id` is what orders the whole chain. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VpnGateway extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// ARM ID of the hub this gateway lives in, e.g. `hub.res.id`. A gateway + /// cannot be moved between hubs. + @azure.FieldHint { required = true; createOnly = true } + virtualHubId: String|formae.Resolvable + + /// Scale units. Each unit is 500 Mbps of aggregate throughput and is billed + /// hourly; Azure defaults to 1. + @azure.FieldHint { hasProviderDefault = true } + vpnGatewayScaleUnit: Int(this >= 1 && this <= 25)? + + /// BGP speaker settings. Azure assigns ASN 65515 and peer weight 0 when this + /// is omitted, and the ASN cannot be changed after creation. + @azure.FieldHint { createOnly = true; hasProviderDefault = true } + bgpSettings: VpnGatewayBgpSettings? + + /// Site-to-site connections from this gateway. + @azure.FieldHint { } + connections: Listing? + + /// Translate BGP-learned routes for NAT rules on this gateway. + @azure.FieldHint { hasProviderDefault = true } + enableBgpRouteTranslationForNat: Boolean? + + /// Use the Internet routing preference for the gateway's public interface + /// instead of the Microsoft global network. Immutable. + @azure.FieldHint { createOnly = true; hasProviderDefault = true } + isRoutingPreferenceInternet: Boolean? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VpnGatewayResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/testdata/bastion-host-update.pkl b/testdata/bastion-host-update.pkl new file mode 100644 index 00000000..e1f3b5fb --- /dev/null +++ b/testdata/bastion-host-update.pkl @@ -0,0 +1,106 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/bastionhost.pkl" +import "vars.pkl" + +// SLOW AND BILLED: a Bastion host takes roughly 10 minutes to create and about the +// same to delete, and it bills hourly plus per scale unit for the whole time it +// exists. Raise FORMAE_TEST_TIMEOUT well past its 5-minute default before running +// this fixture. +// +// The two hard prerequisites are both expressed as resolvable references, so the +// chain rg -> vnet -> AzureBastionSubnet + public IP -> bastion is ordered by +// formae with no explicit dependency: +// +// * a subnet named EXACTLY "AzureBastionSubnet", /26 or larger +// * a Standard-SKU, statically allocated public IP in the same region + +local rg = new resourcegroup.ResourceGroup { + label = "bastion-test-rg" + name = "formae-plugin-sdk-test-bastion-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "bastion-test-vnet" + name = "formae-plugin-sdk-test-bastion-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.10.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a Bastion host whose subnet is called +// anything other than "AzureBastionSubnet", and it must be /26 or larger. +local bastionSubnet = new subnet.Subnet { + label = "bastion-test-subnet" + name = "AzureBastionSubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.10.1.0/26" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "bastion-test-pip" + name = "formae-plugin-sdk-test-bastion-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +forma { + vars.stack + + vars.target + + rg + vnet + bastionSubnet + pip + + new bastionhost.BastionHost { + label = "plugin-sdk-test-bastion-host" + name = "formae-plugin-sdk-test-bastion-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + // Standard is required for every toggle below and for scaleUnits. + sku = new bastionhost.BastionHostSku { name = "Standard" } + scaleUnits = 2 + ipConfigurations { + new bastionhost.BastionHostIPConfiguration { + name = "IpConf" + subnetId = bastionSubnet.res.id + publicIpAddressId = pip.res.id + } + } + enableTunneling = true + enableIpConnect = true + enableFileCopy = true + enableShareableLink = false + enableKerberos = false + disableCopyPaste = true + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/bastion-host.pkl b/testdata/bastion-host.pkl new file mode 100644 index 00000000..1fb4d195 --- /dev/null +++ b/testdata/bastion-host.pkl @@ -0,0 +1,106 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/bastionhost.pkl" +import "vars.pkl" + +// SLOW AND BILLED: a Bastion host takes roughly 10 minutes to create and about the +// same to delete, and it bills hourly plus per scale unit for the whole time it +// exists. Raise FORMAE_TEST_TIMEOUT well past its 5-minute default before running +// this fixture. +// +// The two hard prerequisites are both expressed as resolvable references, so the +// chain rg -> vnet -> AzureBastionSubnet + public IP -> bastion is ordered by +// formae with no explicit dependency: +// +// * a subnet named EXACTLY "AzureBastionSubnet", /26 or larger +// * a Standard-SKU, statically allocated public IP in the same region + +local rg = new resourcegroup.ResourceGroup { + label = "bastion-test-rg" + name = "formae-plugin-sdk-test-bastion-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "bastion-test-vnet" + name = "formae-plugin-sdk-test-bastion-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.10.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a Bastion host whose subnet is called +// anything other than "AzureBastionSubnet", and it must be /26 or larger. +local bastionSubnet = new subnet.Subnet { + label = "bastion-test-subnet" + name = "AzureBastionSubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.10.1.0/26" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "bastion-test-pip" + name = "formae-plugin-sdk-test-bastion-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +forma { + vars.stack + + vars.target + + rg + vnet + bastionSubnet + pip + + new bastionhost.BastionHost { + label = "plugin-sdk-test-bastion-host" + name = "formae-plugin-sdk-test-bastion-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + // Standard is required for every toggle below and for scaleUnits. + sku = new bastionhost.BastionHostSku { name = "Standard" } + scaleUnits = 2 + ipConfigurations { + new bastionhost.BastionHostIPConfiguration { + name = "IpConf" + subnetId = bastionSubnet.res.id + publicIpAddressId = pip.res.id + } + } + enableTunneling = true + enableIpConnect = true + enableFileCopy = false + enableShareableLink = false + enableKerberos = false + disableCopyPaste = false + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/vpn-gateway-update.pkl b/testdata/vpn-gateway-update.pkl new file mode 100644 index 00000000..a7e7a6a8 --- /dev/null +++ b/testdata/vpn-gateway-update.pkl @@ -0,0 +1,114 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/virtualhub.pkl" +import "@azure/network/vpnsite.pkl" +import "@azure/network/vpngateway.pkl" +import "vars.pkl" + +// VERY SLOW AND BILLED. This fixture stands up the whole Virtual WAN chain: +// +// resource group -> virtual WAN -> virtual hub -> VPN gateway +// \-> VPN site (free, referenced by the connection) +// +// The hub alone takes ~10 min to create and ~10 min to delete; the gateway adds +// ~30 min each way. Budget an hour for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT well past its 5-minute default before running it. The WAN +// and the site are free; the hub and the gateway bill for every minute they +// exist, the gateway per scale unit. +// +// The chain is declared in one forma and ordered entirely through resolvable +// references (`wan.res.id`, `hub.res.id`, `site.res.id`), so formae creates it +// bottom-up and destroys it top-down with no explicit dependency. + +local rg = new resourcegroup.ResourceGroup { + label = "vpngw-test-rg" + name = "formae-plugin-sdk-test-vpngw-rg-\(vars.testRunID)" + location = "eastus" +} + +local wan = new virtualwan.VirtualWan { + label = "vpngw-test-wan" + name = "formae-plugin-sdk-test-vpngw-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +local hub = new virtualhub.VirtualHub { + label = "vpngw-test-hub" + name = "formae-plugin-sdk-test-vpngw-hub-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressPrefix = "10.101.0.0/23" + sku = "Standard" +} + +local site = new vpnsite.VpnSite { + label = "vpngw-test-site" + name = "formae-plugin-sdk-test-vpngw-site-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressSpace { + "192.168.20.0/24" + } + vpnSiteLinks { + new vpnsite.VpnSiteLink { + name = "link0" + ipAddress = "203.0.113.40" + } + } +} + +forma { + vars.stack + + vars.target + + rg + wan + hub + site + + // `bgpSettings` is omitted on purpose: Azure assigns ASN 65515 and peer weight 0, + // and the ASN is immutable, so letting the provider default stand avoids pinning + // a value we would then have to keep in sync. + // + // `connections[].sharedKey` is omitted too: it is write-only and Azure generates + // one. The conformance harness cannot strip a write-only field nested inside an + // array, so declaring it would read back as permanent drift (same reason the + // application-gateway fixture uses an HTTP listener). + new vpngateway.VpnGateway { + label = "plugin-sdk-test-vpn-gateway" + name = "formae-plugin-sdk-test-vpngw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualHubId = hub.res.id + vpnGatewayScaleUnit = 1 + connections { + new vpngateway.VpnGatewayConnection { + name = "conn0" + remoteVpnSiteId = site.res.id + connectionBandwidth = 20 + enableBgp = false + routingWeight = 10 + vpnConnectionProtocolType = "IKEv2" + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/vpn-gateway.pkl b/testdata/vpn-gateway.pkl new file mode 100644 index 00000000..86f65e2a --- /dev/null +++ b/testdata/vpn-gateway.pkl @@ -0,0 +1,114 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/network/virtualwan.pkl" +import "@azure/network/virtualhub.pkl" +import "@azure/network/vpnsite.pkl" +import "@azure/network/vpngateway.pkl" +import "vars.pkl" + +// VERY SLOW AND BILLED. This fixture stands up the whole Virtual WAN chain: +// +// resource group -> virtual WAN -> virtual hub -> VPN gateway +// \-> VPN site (free, referenced by the connection) +// +// The hub alone takes ~10 min to create and ~10 min to delete; the gateway adds +// ~30 min each way. Budget an hour for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT well past its 5-minute default before running it. The WAN +// and the site are free; the hub and the gateway bill for every minute they +// exist, the gateway per scale unit. +// +// The chain is declared in one forma and ordered entirely through resolvable +// references (`wan.res.id`, `hub.res.id`, `site.res.id`), so formae creates it +// bottom-up and destroys it top-down with no explicit dependency. + +local rg = new resourcegroup.ResourceGroup { + label = "vpngw-test-rg" + name = "formae-plugin-sdk-test-vpngw-rg-\(vars.testRunID)" + location = "eastus" +} + +local wan = new virtualwan.VirtualWan { + label = "vpngw-test-wan" + name = "formae-plugin-sdk-test-vpngw-wan-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanTier = "Standard" +} + +local hub = new virtualhub.VirtualHub { + label = "vpngw-test-hub" + name = "formae-plugin-sdk-test-vpngw-hub-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressPrefix = "10.101.0.0/23" + sku = "Standard" +} + +local site = new vpnsite.VpnSite { + label = "vpngw-test-site" + name = "formae-plugin-sdk-test-vpngw-site-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualWanId = wan.res.id + addressSpace { + "192.168.20.0/24" + } + vpnSiteLinks { + new vpnsite.VpnSiteLink { + name = "link0" + ipAddress = "203.0.113.40" + } + } +} + +forma { + vars.stack + + vars.target + + rg + wan + hub + site + + // `bgpSettings` is omitted on purpose: Azure assigns ASN 65515 and peer weight 0, + // and the ASN is immutable, so letting the provider default stand avoids pinning + // a value we would then have to keep in sync. + // + // `connections[].sharedKey` is omitted too: it is write-only and Azure generates + // one. The conformance harness cannot strip a write-only field nested inside an + // array, so declaring it would read back as permanent drift (same reason the + // application-gateway fixture uses an HTTP listener). + new vpngateway.VpnGateway { + label = "plugin-sdk-test-vpn-gateway" + name = "formae-plugin-sdk-test-vpngw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + virtualHubId = hub.res.id + vpnGatewayScaleUnit = 1 + connections { + new vpngateway.VpnGatewayConnection { + name = "conn0" + remoteVpnSiteId = site.res.id + connectionBandwidth = 10 + enableBgp = false + routingWeight = 0 + vpnConnectionProtocolType = "IKEv2" + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} From 00002b9a19a703ae3f0668a336cf7bc59ef3b236 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:35:54 +0200 Subject: [PATCH 3/6] feat(azure): Network::VirtualNetworkGateway and VirtualNetworkGatewayConnection - AZURE::Network::VirtualNetworkGateway - Microsoft.Network/virtualNetworkGateways (2023-05-01) - AZURE::Network::VirtualNetworkGatewayConnection - Microsoft.Network/connections (2023-05-01) The classic vnet-scoped gateway family, completing hybrid connectivity alongside the existing LocalNetworkGateway. Both are LRO on create, update and delete and copy the LocalNetworkGateway shape verbatim, including the pending-poller branch that pins the ARM path while the operation runs - a gateway create takes 30-45 minutes, so a mis-pinned native ID would orphan a billed resource for that whole window. VirtualNetworkGateway validates up front that its subnet is named exactly GatewaySubnet and that activeActive comes with two ipConfigurations. bgpSettings models ASN plus peer weight only: Azure allocates the peering addresses out of the GatewaySubnet and they are dropped on read, as is sku.capacity. vpnClientConfiguration covers the point-to-site surface, with radiusServerSecret write-only. VirtualNetworkGatewayConnection sends the two gateway peers as ARM models carrying only their id (ARM types them as whole gateway objects) and reads back only the id, so the inflated bodies ARM returns never reach state. sharedKey and authorizationKey are write-only - ARM does return the shared key on Get and the plugin never surfaces it. connectionType is checked against the peer field it requires, and ipsecPolicies is capped at Azure's one-entry limit. Conformance fixtures: virtual-network-gateway (rg -> vnet -> GatewaySubnet + public IP -> gateway) and virtual-network-gateway-connection (the full site-to-site chain, gateway + LocalNetworkGateway + connection with a custom IPsec/IKE policy), each with -update. --- pkg/resources/virtualnetworkgateway.go | 787 ++++++++++++++++++ .../virtualnetworkgateway_integration_test.go | 417 ++++++++++ .../virtualnetworkgatewayconnection.go | 631 ++++++++++++++ ...tworkgatewayconnection_integration_test.go | 400 +++++++++ schema/pkl/network/virtualnetworkgateway.pkl | 267 ++++++ .../virtualnetworkgatewayconnection.pkl | 195 +++++ ...tual-network-gateway-connection-update.pkl | 157 ++++ .../virtual-network-gateway-connection.pkl | 157 ++++ testdata/virtual-network-gateway-update.pkl | 116 +++ testdata/virtual-network-gateway.pkl | 116 +++ 10 files changed, 3243 insertions(+) create mode 100644 pkg/resources/virtualnetworkgateway.go create mode 100644 pkg/resources/virtualnetworkgateway_integration_test.go create mode 100644 pkg/resources/virtualnetworkgatewayconnection.go create mode 100644 pkg/resources/virtualnetworkgatewayconnection_integration_test.go create mode 100644 schema/pkl/network/virtualnetworkgateway.pkl create mode 100644 schema/pkl/network/virtualnetworkgatewayconnection.pkl create mode 100644 testdata/virtual-network-gateway-connection-update.pkl create mode 100644 testdata/virtual-network-gateway-connection.pkl create mode 100644 testdata/virtual-network-gateway-update.pkl create mode 100644 testdata/virtual-network-gateway.pkl diff --git a/pkg/resources/virtualnetworkgateway.go b/pkg/resources/virtualnetworkgateway.go new file mode 100644 index 00000000..33c604a7 --- /dev/null +++ b/pkg/resources/virtualnetworkgateway.go @@ -0,0 +1,787 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVirtualNetworkGateway = "AZURE::Network::VirtualNetworkGateway" + +// gatewaySubnetName is the only subnet name Azure accepts for a virtual network +// gateway. It is checked before the request goes out so the failure names the real +// problem instead of surfacing ARM's generic rejection. +const gatewaySubnetName = "GatewaySubnet" + +// virtualNetworkGatewaysAPI is the armnetwork surface used here. BeginUpdateTags is +// deliberately absent: it cannot resize the SKU or change the BGP settings, so every +// update is a re-PUT. +type virtualNetworkGatewaysAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, virtualNetworkGatewayName string, parameters armnetwork.VirtualNetworkGateway, options *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayName string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, virtualNetworkGatewayName string, options *armnetwork.VirtualNetworkGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) + NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewaysClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVirtualNetworkGateway, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VirtualNetworkGateway{ + api: c.VirtualNetworkGatewaysClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VirtualNetworkGateway is the provisioner for the classic vnet-scoped gateway +// (Microsoft.Network/virtualNetworkGateways). +type VirtualNetworkGateway struct { + api virtualNetworkGatewaysAPI + pipeline runtime.Pipeline + config *config.Config +} + +// virtualNetworkGatewayProps mirrors +// schema/pkl/network/virtualnetworkgateway.pkl. +type virtualNetworkGatewayProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + GatewayType string `json:"gatewayType"` + VpnType string `json:"vpnType"` + SKU *virtualNetworkGatewaySkuProps `json:"sku"` + IPConfigurations []virtualNetworkGatewayIPConfigProps `json:"ipConfigurations"` + ActiveActive *bool `json:"activeActive"` + EnableBgp *bool `json:"enableBgp"` + BgpSettings *virtualNetworkGatewayBgpProps `json:"bgpSettings"` + VpnGatewayGeneration *string `json:"vpnGatewayGeneration"` + VpnClientConfiguration *virtualNetworkGatewayVpnClientProps `json:"vpnClientConfiguration"` + EnablePrivateIPAddress *bool `json:"enablePrivateIpAddress"` + AllowVirtualWanTraffic *bool `json:"allowVirtualWanTraffic"` + AllowRemoteVnetTraffic *bool `json:"allowRemoteVnetTraffic"` + GatewayDefaultSiteID *string `json:"gatewayDefaultSiteId"` + CustomRoutes []string `json:"customRoutes"` +} + +type virtualNetworkGatewaySkuProps struct { + Name string `json:"name"` + Tier string `json:"tier"` +} + +type virtualNetworkGatewayIPConfigProps struct { + Name string `json:"name"` + SubnetID string `json:"subnetId"` + PublicIPAddressID string `json:"publicIpAddressId"` + PrivateIPAllocationMethod *string `json:"privateIpAllocationMethod"` +} + +type virtualNetworkGatewayBgpProps struct { + Asn *int64 `json:"asn"` + PeerWeight *int32 `json:"peerWeight"` +} + +type virtualNetworkGatewayVpnClientProps struct { + VpnClientAddressPool []string `json:"vpnClientAddressPool"` + VpnClientProtocols []string `json:"vpnClientProtocols"` + VpnAuthenticationTypes []string `json:"vpnAuthenticationTypes"` + VpnClientRootCertificates []virtualNetworkGatewayRootCertProps `json:"vpnClientRootCertificates"` + RadiusServerAddress *string `json:"radiusServerAddress"` + RadiusServerSecret *string `json:"radiusServerSecret"` + AadTenant *string `json:"aadTenant"` + AadAudience *string `json:"aadAudience"` + AadIssuer *string `json:"aadIssuer"` +} + +type virtualNetworkGatewayRootCertProps struct { + Name string `json:"name"` + PublicCertData string `json:"publicCertData"` +} + +var ( + // The canonical casing for every enum the gateway echoes back, applied on the + // read path because ARM is inconsistent about it. + virtualNetworkGatewayTypes = []string{"Vpn", "ExpressRoute", "LocalGateway"} + vpnTypes = []string{"RouteBased", "PolicyBased"} + virtualNetworkGatewaySkus = []string{ + "Basic", "Standard", "HighPerformance", "UltraPerformance", + "VpnGw1", "VpnGw2", "VpnGw3", "VpnGw4", "VpnGw5", + "VpnGw1AZ", "VpnGw2AZ", "VpnGw3AZ", "VpnGw4AZ", "VpnGw5AZ", + "ErGw1AZ", "ErGw2AZ", "ErGw3AZ", + } + vpnGatewayGenerations = []string{"None", "Generation1", "Generation2"} + vpnClientProtocols = []string{"IkeV2", "OpenVPN", "SSTP"} + vpnAuthenticationTypes = []string{"Certificate", "Radius", "AAD"} + gatewayIPAllocations = []string{"Dynamic", "Static"} +) + +func virtualNetworkGatewayIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "virtualnetworkgateways") + if err != nil { + return "", "", err + } + return rgName, names["virtualnetworkgateways"], nil +} + +func (r *VirtualNetworkGateway) buildPropertiesFromResult(gateway *armnetwork.VirtualNetworkGateway, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if gateway.ID != nil { + props["id"] = *gateway.ID + } + if gateway.Name != nil { + props["name"] = *gateway.Name + } + if gateway.Location != nil { + props["location"] = normalizeAzureLocation(*gateway.Location) + } + if tags := azureTagsToFormaeTags(gateway.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + p := gateway.Properties + if p == nil { + return props + } + + if p.GatewayType != nil && *p.GatewayType != "" { + props["gatewayType"] = canonicalizeEnum(string(*p.GatewayType), virtualNetworkGatewayTypes...) + } + if p.VPNType != nil && *p.VPNType != "" { + props["vpnType"] = canonicalizeEnum(string(*p.VPNType), vpnTypes...) + } + if sku := p.SKU; sku != nil { + entry := make(map[string]any) + if sku.Name != nil && *sku.Name != "" { + entry["name"] = canonicalizeEnum(string(*sku.Name), virtualNetworkGatewaySkus...) + } + if sku.Tier != nil && *sku.Tier != "" { + entry["tier"] = canonicalizeEnum(string(*sku.Tier), virtualNetworkGatewaySkus...) + } + if len(entry) > 0 { + // sku.capacity is the instance count Azure derives from the SKU. + props["sku"] = entry + } + } + if p.Active != nil { + props["activeActive"] = *p.Active + } + if p.EnableBgp != nil { + props["enableBgp"] = *p.EnableBgp + } + if p.VPNGatewayGeneration != nil && *p.VPNGatewayGeneration != "" { + props["vpnGatewayGeneration"] = canonicalizeEnum(string(*p.VPNGatewayGeneration), vpnGatewayGenerations...) + } + if p.EnablePrivateIPAddress != nil { + props["enablePrivateIpAddress"] = *p.EnablePrivateIPAddress + } + if p.AllowVirtualWanTraffic != nil { + props["allowVirtualWanTraffic"] = *p.AllowVirtualWanTraffic + } + if p.AllowRemoteVnetTraffic != nil { + props["allowRemoteVnetTraffic"] = *p.AllowRemoteVnetTraffic + } + if p.GatewayDefaultSite != nil && p.GatewayDefaultSite.ID != nil { + props["gatewayDefaultSiteId"] = *p.GatewayDefaultSite.ID + } + if routes := p.CustomRoutes; routes != nil { + if prefixes := stringsFromPointers(routes.AddressPrefixes); prefixes != nil { + props["customRoutes"] = prefixes + } + } + if bgp := p.BgpSettings; bgp != nil { + settings := make(map[string]any) + if bgp.Asn != nil { + settings["asn"] = *bgp.Asn + } + if bgp.PeerWeight != nil { + settings["peerWeight"] = *bgp.PeerWeight + } + if len(settings) > 0 { + props["bgpSettings"] = settings + } + // bgpPeeringAddress and bgpPeeringAddresses are allocated by Azure out of the + // GatewaySubnet, so they are dropped rather than compared. + } + if configs := virtualNetworkGatewayIPConfigsToProps(p.IPConfigurations); len(configs) > 0 { + props["ipConfigurations"] = configs + } + if vpnClient := virtualNetworkGatewayVpnClientToProps(p.VPNClientConfiguration); len(vpnClient) > 0 { + props["vpnClientConfiguration"] = vpnClient + } + // provisioningState, resourceGuid, inboundDnsForwardingEndpoint and natRules are + // service state or not modelled. + + return props +} + +// virtualNetworkGatewayIPConfigsToProps is the read-path inverse of +// virtualNetworkGatewayIPConfigsFromProps. The per-config ARM ID, etag, +// provisioningState and the private IP Azure picks are all service-assigned. +func virtualNetworkGatewayIPConfigsToProps(configs []*armnetwork.VirtualNetworkGatewayIPConfiguration) []map[string]any { + if len(configs) == 0 { + return nil + } + out := make([]map[string]any, 0, len(configs)) + for _, cfg := range configs { + if cfg == nil { + continue + } + entry := make(map[string]any) + if cfg.Name != nil { + entry["name"] = *cfg.Name + } + if cp := cfg.Properties; cp != nil { + if cp.Subnet != nil && cp.Subnet.ID != nil { + entry["subnetId"] = *cp.Subnet.ID + } + if cp.PublicIPAddress != nil && cp.PublicIPAddress.ID != nil { + entry["publicIpAddressId"] = *cp.PublicIPAddress.ID + } + if cp.PrivateIPAllocationMethod != nil && *cp.PrivateIPAllocationMethod != "" { + entry["privateIpAllocationMethod"] = canonicalizeEnum(string(*cp.PrivateIPAllocationMethod), gatewayIPAllocations...) + } + } + out = append(out, entry) + } + return out +} + +// virtualNetworkGatewayIPConfigsFromProps builds the request-side IP configuration +// list. +func virtualNetworkGatewayIPConfigsFromProps(configs []virtualNetworkGatewayIPConfigProps) []*armnetwork.VirtualNetworkGatewayIPConfiguration { + if len(configs) == 0 { + return nil + } + out := make([]*armnetwork.VirtualNetworkGatewayIPConfiguration, 0, len(configs)) + for i := range configs { + cfg := configs[i] + armCfg := &armnetwork.VirtualNetworkGatewayIPConfiguration{ + Name: to.Ptr(cfg.Name), + Properties: &armnetwork.VirtualNetworkGatewayIPConfigurationPropertiesFormat{ + Subnet: &armnetwork.SubResource{ID: to.Ptr(cfg.SubnetID)}, + PublicIPAddress: &armnetwork.SubResource{ID: to.Ptr(cfg.PublicIPAddressID)}, + }, + } + if cfg.PrivateIPAllocationMethod != nil { + armCfg.Properties.PrivateIPAllocationMethod = to.Ptr(armnetwork.IPAllocationMethod(*cfg.PrivateIPAllocationMethod)) + } + out = append(out, armCfg) + } + return out +} + +// virtualNetworkGatewayVpnClientToProps is the read-path inverse of +// virtualNetworkGatewayVpnClientFromProps. radiusServerSecret is write-only and +// never surfaced, and the per-certificate ARM ID / etag / provisioningState are +// service-assigned. +func virtualNetworkGatewayVpnClientToProps(cfg *armnetwork.VPNClientConfiguration) map[string]any { + if cfg == nil { + return nil + } + out := make(map[string]any) + if pool := cfg.VPNClientAddressPool; pool != nil { + if prefixes := stringsFromPointers(pool.AddressPrefixes); prefixes != nil { + out["vpnClientAddressPool"] = prefixes + } + } + if len(cfg.VPNClientProtocols) > 0 { + protocols := make([]string, 0, len(cfg.VPNClientProtocols)) + for _, protocol := range cfg.VPNClientProtocols { + if protocol == nil || *protocol == "" { + continue + } + protocols = append(protocols, canonicalizeEnum(string(*protocol), vpnClientProtocols...)) + } + if len(protocols) > 0 { + out["vpnClientProtocols"] = protocols + } + } + if len(cfg.VPNAuthenticationTypes) > 0 { + types := make([]string, 0, len(cfg.VPNAuthenticationTypes)) + for _, authType := range cfg.VPNAuthenticationTypes { + if authType == nil || *authType == "" { + continue + } + types = append(types, canonicalizeEnum(string(*authType), vpnAuthenticationTypes...)) + } + if len(types) > 0 { + out["vpnAuthenticationTypes"] = types + } + } + if len(cfg.VPNClientRootCertificates) > 0 { + certs := make([]map[string]any, 0, len(cfg.VPNClientRootCertificates)) + for _, cert := range cfg.VPNClientRootCertificates { + if cert == nil { + continue + } + entry := make(map[string]any) + if cert.Name != nil { + entry["name"] = *cert.Name + } + if cert.Properties != nil && cert.Properties.PublicCertData != nil { + entry["publicCertData"] = *cert.Properties.PublicCertData + } + certs = append(certs, entry) + } + if len(certs) > 0 { + out["vpnClientRootCertificates"] = certs + } + } + if cfg.RadiusServerAddress != nil && *cfg.RadiusServerAddress != "" { + out["radiusServerAddress"] = *cfg.RadiusServerAddress + } + if cfg.AADTenant != nil && *cfg.AADTenant != "" { + out["aadTenant"] = *cfg.AADTenant + } + if cfg.AADAudience != nil && *cfg.AADAudience != "" { + out["aadAudience"] = *cfg.AADAudience + } + if cfg.AADIssuer != nil && *cfg.AADIssuer != "" { + out["aadIssuer"] = *cfg.AADIssuer + } + if len(out) == 0 { + return nil + } + return out +} + +// virtualNetworkGatewayVpnClientFromProps builds the request-side point-to-site +// configuration. +func virtualNetworkGatewayVpnClientFromProps(cfg *virtualNetworkGatewayVpnClientProps) *armnetwork.VPNClientConfiguration { + if cfg == nil { + return nil + } + out := &armnetwork.VPNClientConfiguration{ + RadiusServerAddress: cfg.RadiusServerAddress, + RadiusServerSecret: cfg.RadiusServerSecret, + AADTenant: cfg.AadTenant, + AADAudience: cfg.AadAudience, + AADIssuer: cfg.AadIssuer, + } + if prefixes := stringPointers(cfg.VpnClientAddressPool); prefixes != nil { + out.VPNClientAddressPool = &armnetwork.AddressSpace{AddressPrefixes: prefixes} + } + for _, protocol := range cfg.VpnClientProtocols { + out.VPNClientProtocols = append(out.VPNClientProtocols, to.Ptr(armnetwork.VPNClientProtocol(protocol))) + } + for _, authType := range cfg.VpnAuthenticationTypes { + out.VPNAuthenticationTypes = append(out.VPNAuthenticationTypes, to.Ptr(armnetwork.VPNAuthenticationType(authType))) + } + for i := range cfg.VpnClientRootCertificates { + cert := cfg.VpnClientRootCertificates[i] + out.VPNClientRootCertificates = append(out.VPNClientRootCertificates, &armnetwork.VPNClientRootCertificate{ + Name: to.Ptr(cert.Name), + Properties: &armnetwork.VPNClientRootCertificatePropertiesFormat{ + PublicCertData: to.Ptr(cert.PublicCertData), + }, + }) + } + return out +} + +// virtualNetworkGatewayParams builds the request body shared by create and update. +func virtualNetworkGatewayParams(props virtualNetworkGatewayProps, payload json.RawMessage) armnetwork.VirtualNetworkGateway { + params := armnetwork.VirtualNetworkGateway{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VirtualNetworkGatewayPropertiesFormat{ + GatewayType: to.Ptr(armnetwork.VirtualNetworkGatewayType(props.GatewayType)), + VPNType: to.Ptr(armnetwork.VPNType(props.VpnType)), + IPConfigurations: virtualNetworkGatewayIPConfigsFromProps(props.IPConfigurations), + Active: props.ActiveActive, + EnableBgp: props.EnableBgp, + EnablePrivateIPAddress: props.EnablePrivateIPAddress, + AllowVirtualWanTraffic: props.AllowVirtualWanTraffic, + AllowRemoteVnetTraffic: props.AllowRemoteVnetTraffic, + VPNClientConfiguration: virtualNetworkGatewayVpnClientFromProps(props.VpnClientConfiguration), + }, + } + if sku := props.SKU; sku != nil { + params.Properties.SKU = &armnetwork.VirtualNetworkGatewaySKU{ + Name: to.Ptr(armnetwork.VirtualNetworkGatewaySKUName(sku.Name)), + Tier: to.Ptr(armnetwork.VirtualNetworkGatewaySKUTier(sku.Tier)), + } + } + if props.VpnGatewayGeneration != nil { + params.Properties.VPNGatewayGeneration = to.Ptr(armnetwork.VPNGatewayGeneration(*props.VpnGatewayGeneration)) + } + if bgp := props.BgpSettings; bgp != nil { + params.Properties.BgpSettings = &armnetwork.BgpSettings{ + Asn: bgp.Asn, + PeerWeight: bgp.PeerWeight, + } + } + if props.GatewayDefaultSiteID != nil && *props.GatewayDefaultSiteID != "" { + params.Properties.GatewayDefaultSite = &armnetwork.SubResource{ID: props.GatewayDefaultSiteID} + } + if routes := stringPointers(props.CustomRoutes); routes != nil { + params.Properties.CustomRoutes = &armnetwork.AddressSpace{AddressPrefixes: routes} + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: BeginUpdateTags cannot resize the SKU or +// change the BGP settings, so an update is another CreateOrUpdate. +func (r *VirtualNetworkGateway) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], virtualNetworkGatewayProps, string, error) { + var props virtualNetworkGatewayProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if props.GatewayType == "" { + return nil, props, "", fmt.Errorf("gatewayType is required") + } + if props.VpnType == "" { + return nil, props, "", fmt.Errorf("vpnType is required") + } + if props.SKU == nil || props.SKU.Name == "" { + return nil, props, "", fmt.Errorf("sku is required") + } + if len(props.IPConfigurations) == 0 { + return nil, props, "", fmt.Errorf("ipConfigurations is required") + } + for _, cfg := range props.IPConfigurations { + if cfg.Name == "" { + return nil, props, "", fmt.Errorf("every ipConfigurations entry needs a name") + } + if cfg.SubnetID == "" { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q needs a subnetId", cfg.Name) + } + if cfg.PublicIPAddressID == "" { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q needs a publicIpAddressId", cfg.Name) + } + // Azure only accepts a gateway in a subnet literally named GatewaySubnet. + // Catching it here turns a slow ARM rejection into an immediate, specific + // error. + if subnet, ok := lastARMSegment(cfg.SubnetID); ok && subnet != gatewaySubnetName { + return nil, props, "", fmt.Errorf("ipConfigurations entry %q references subnet %q: a virtual network gateway requires a subnet named exactly %s", + cfg.Name, subnet, gatewaySubnetName) + } + } + // Active-active needs a second front end, and Azure rejects the combination + // rather than silently ignoring it. + if props.ActiveActive != nil && *props.ActiveActive && len(props.IPConfigurations) < 2 { + return nil, props, "", fmt.Errorf("activeActive requires two ipConfigurations entries, got %d", len(props.IPConfigurations)) + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + virtualNetworkGatewayParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VirtualNetworkGateway) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworkGateways/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromGateway(&result.VirtualNetworkGateway) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VirtualNetworkGateway) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := virtualNetworkGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualNetworkGateway, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVirtualNetworkGateway, + Properties: string(propsJSON), + }, nil +} + +func (r *VirtualNetworkGateway) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := virtualNetworkGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualNetworkGateway, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualNetworkGateway) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := virtualNetworkGatewayIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualNetworkGateway) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromGateway(&result.VirtualNetworkGateway) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) { + return resumePoller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VirtualNetworkGateway) completeFromGateway(gateway *armnetwork.VirtualNetworkGateway) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if gateway.ID != nil { + nativeID = *gateway.ID + if rg, _, err := virtualNetworkGatewayIDParts(*gateway.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(gateway, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List is scoped to a resource group: ARM offers no subscription-wide listing for +// virtual network gateways. +func (r *VirtualNetworkGateway) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + if rgName == "" { + return &resource.ListResult{}, nil + } + + var nativeIDs []string + pager := r.api.NewListPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual network gateways in resource group %s: %w", rgName, err) + } + for _, gateway := range page.Value { + if gateway.ID != nil { + nativeIDs = append(nativeIDs, *gateway.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/virtualnetworkgateway_integration_test.go b/pkg/resources/virtualnetworkgateway_integration_test.go new file mode 100644 index 00000000..168a192b --- /dev/null +++ b/pkg/resources/virtualnetworkgateway_integration_test.go @@ -0,0 +1,417 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testVnetGatewayNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworkGateways/vngw1" + testGatewaySubnetID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/GatewaySubnet" + testVnetGatewayPipID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/publicIPAddresses/pip1" + testVnetGatewayPipID2 = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/publicIPAddresses/pip2" +) + +type fakeVirtualNetworkGatewaysAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VirtualNetworkGateway, options *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) + newListPagerFn func(rgName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewaysClientListResponse] +} + +func (f *fakeVirtualNetworkGatewaysAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VirtualNetworkGateway, options *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVirtualNetworkGatewaysAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualNetworkGatewaysAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualNetworkGatewaysAPI) NewListPager(rgName string, options *armnetwork.VirtualNetworkGatewaysClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewaysClientListResponse] { + return f.newListPagerFn(rgName, options) +} + +func newTestVirtualNetworkGateway(api virtualNetworkGatewaysAPI) *VirtualNetworkGateway { + return &VirtualNetworkGateway{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func vnetGatewayDesired(skuName string, peerWeight int) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "vngw1", + "resourceGroupName": "rg-1", + "location": "eastus", + "gatewayType": "Vpn", + "vpnType": "RouteBased", + "sku": map[string]any{"name": skuName, "tier": skuName}, + "ipConfigurations": []any{map[string]any{ + "name": "default", + "subnetId": testGatewaySubnetID, + "publicIpAddressId": testVnetGatewayPipID, + }}, + "activeActive": false, + "enableBgp": true, + "bgpSettings": map[string]any{"asn": 65515, "peerWeight": peerWeight}, + "vpnGatewayGeneration": "Generation1", + "vpnClientConfiguration": map[string]any{ + "vpnClientAddressPool": []any{"172.16.201.0/24"}, + "vpnClientProtocols": []any{"OpenVPN"}, + "vpnAuthenticationTypes": []any{"Certificate"}, + "vpnClientRootCertificates": []any{map[string]any{ + "name": "root", + "publicCertData": "MIIBase64CertBody", + }}, + }, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVirtualNetworkGateway_CRUD(t *testing.T) { + gatewayResult := armnetwork.VirtualNetworkGateway{ + ID: to.Ptr(testVnetGatewayNativeID), + Name: to.Ptr("vngw1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VirtualNetworkGatewayPropertiesFormat{ + // ARM echoes every enum back with its own casing. + GatewayType: to.Ptr(armnetwork.VirtualNetworkGatewayType("vpn")), + VPNType: to.Ptr(armnetwork.VPNType("routebased")), + SKU: &armnetwork.VirtualNetworkGatewaySKU{ + Name: to.Ptr(armnetwork.VirtualNetworkGatewaySKUName("vpngw1")), + Tier: to.Ptr(armnetwork.VirtualNetworkGatewaySKUTier("vpngw1")), + // Instance count Azure derives from the SKU; not modelled. + Capacity: to.Ptr(int32(2)), + }, + IPConfigurations: []*armnetwork.VirtualNetworkGatewayIPConfiguration{{ + // ARM assigns the child ID and etag; neither may reach state. + ID: to.Ptr(testVnetGatewayNativeID + "/ipConfigurations/default"), + Name: to.Ptr("default"), + Etag: to.Ptr("W/\"ipconf-etag\""), + Properties: &armnetwork.VirtualNetworkGatewayIPConfigurationPropertiesFormat{ + Subnet: &armnetwork.SubResource{ID: to.Ptr(testGatewaySubnetID)}, + PublicIPAddress: &armnetwork.SubResource{ID: to.Ptr(testVnetGatewayPipID)}, + PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethod("dynamic")), + // Azure picks the private address out of the GatewaySubnet. + PrivateIPAddress: to.Ptr("10.20.0.6"), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + }}, + Active: to.Ptr(false), + EnableBgp: to.Ptr(true), + BgpSettings: &armnetwork.BgpSettings{ + Asn: to.Ptr(int64(65515)), + PeerWeight: to.Ptr(int32(0)), + // Allocated by Azure out of the GatewaySubnet; must not reach state. + BgpPeeringAddress: to.Ptr("10.20.0.254"), + BgpPeeringAddresses: []*armnetwork.IPConfigurationBgpPeeringAddress{{ + IPConfigurationID: to.Ptr(testVnetGatewayNativeID + "/ipConfigurations/default"), + }}, + }, + VPNGatewayGeneration: to.Ptr(armnetwork.VPNGatewayGeneration("generation1")), + VPNClientConfiguration: &armnetwork.VPNClientConfiguration{ + VPNClientAddressPool: &armnetwork.AddressSpace{ + AddressPrefixes: []*string{to.Ptr("172.16.201.0/24")}, + }, + VPNClientProtocols: []*armnetwork.VPNClientProtocol{to.Ptr(armnetwork.VPNClientProtocol("openvpn"))}, + VPNAuthenticationTypes: []*armnetwork.VPNAuthenticationType{to.Ptr(armnetwork.VPNAuthenticationType("certificate"))}, + VPNClientRootCertificates: []*armnetwork.VPNClientRootCertificate{{ + ID: to.Ptr(testVnetGatewayNativeID + "/vpnClientRootCertificates/root"), + Name: to.Ptr("root"), + Etag: to.Ptr("W/\"cert-etag\""), + Properties: &armnetwork.VPNClientRootCertificatePropertiesFormat{ + PublicCertData: to.Ptr("MIIBase64CertBody"), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + }, + }}, + }, + // Service state. + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + ResourceGUID: to.Ptr("aa11bb22-cc33-dd44-ee55-ff6677889900"), + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.VirtualNetworkGateway + createCalls := 0 + deleteCalls := 0 + fake := &fakeVirtualNetworkGatewaysAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VirtualNetworkGateway, _ *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "vngw1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse{VirtualNetworkGateway: gatewayResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) { + return armnetwork.VirtualNetworkGatewaysClientGetResponse{VirtualNetworkGateway: gatewayResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewaysClientDeleteResponse{}), nil + }, + newListPagerFn: func(_ string, _ *armnetwork.VirtualNetworkGatewaysClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewaysClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualNetworkGatewaysClientListResponse]{ + More: func(_ armnetwork.VirtualNetworkGatewaysClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualNetworkGatewaysClientListResponse) (armnetwork.VirtualNetworkGatewaysClientListResponse, error) { + return armnetwork.VirtualNetworkGatewaysClientListResponse{ + VirtualNetworkGatewayListResult: armnetwork.VirtualNetworkGatewayListResult{ + Value: []*armnetwork.VirtualNetworkGateway{{ID: to.Ptr(testVnetGatewayNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVirtualNetworkGateway(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vngw1", Properties: vnetGatewayDesired("VpnGw1", 0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testVnetGatewayNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.VirtualNetworkGatewayTypeVPN, *sent.Properties.GatewayType) + require.Equal(t, armnetwork.VPNTypeRouteBased, *sent.Properties.VPNType) + require.Equal(t, armnetwork.VirtualNetworkGatewaySKUNameVPNGw1, *sent.Properties.SKU.Name) + require.Equal(t, armnetwork.VirtualNetworkGatewaySKUTierVPNGw1, *sent.Properties.SKU.Tier) + require.Len(t, sent.Properties.IPConfigurations, 1) + cfg := sent.Properties.IPConfigurations[0] + require.Equal(t, testGatewaySubnetID, *cfg.Properties.Subnet.ID) + require.Equal(t, testVnetGatewayPipID, *cfg.Properties.PublicIPAddress.ID) + require.False(t, *sent.Properties.Active) + require.True(t, *sent.Properties.EnableBgp) + require.Equal(t, int64(65515), *sent.Properties.BgpSettings.Asn) + require.Equal(t, armnetwork.VPNGatewayGenerationGeneration1, *sent.Properties.VPNGatewayGeneration) + + vpnClient := sent.Properties.VPNClientConfiguration + require.Equal(t, "172.16.201.0/24", *vpnClient.VPNClientAddressPool.AddressPrefixes[0]) + require.Equal(t, armnetwork.VPNClientProtocolOpenVPN, *vpnClient.VPNClientProtocols[0]) + require.Equal(t, armnetwork.VPNAuthenticationTypeCertificate, *vpnClient.VPNAuthenticationTypes[0]) + require.Equal(t, "root", *vpnClient.VPNClientRootCertificates[0].Name) + require.Equal(t, "MIIBase64CertBody", *vpnClient.VPNClientRootCertificates[0].Properties.PublicCertData) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_gateway_type", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vngw1", "resourceGroupName": "rg-1", "location": "eastus", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "gatewayType is required") + }) + + t.Run("Create_requires_sku", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vngw1", "resourceGroupName": "rg-1", "location": "eastus", + "gatewayType": "Vpn", "vpnType": "RouteBased", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "sku is required") + }) + + // Azure accepts a gateway only in a subnet literally named GatewaySubnet, and + // rejects anything else after tens of minutes of provisioning. + t.Run("Create_rejects_wrong_subnet_name", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vngw1", "resourceGroupName": "rg-1", "location": "eastus", + "gatewayType": "Vpn", "vpnType": "RouteBased", + "sku": map[string]any{"name": "VpnGw1", "tier": "VpnGw1"}, + "ipConfigurations": []any{map[string]any{ + "name": "default", + "subnetId": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworks/vnet1/subnets/default", + "publicIpAddressId": testVnetGatewayPipID, + }}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "requires a subnet named exactly GatewaySubnet") + }) + + t.Run("Create_active_active_requires_two_front_ends", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vngw1", "resourceGroupName": "rg-1", "location": "eastus", + "gatewayType": "Vpn", "vpnType": "RouteBased", + "sku": map[string]any{"name": "VpnGw1", "tier": "VpnGw1"}, + "ipConfigurations": []any{map[string]any{ + "name": "default", "subnetId": testGatewaySubnetID, + "publicIpAddressId": testVnetGatewayPipID, + }}, + "activeActive": true, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "activeActive requires two ipConfigurations entries") + }) + + t.Run("Create_active_active_accepts_two_front_ends", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "vngw1", "resourceGroupName": "rg-1", "location": "eastus", + "gatewayType": "Vpn", "vpnType": "RouteBased", + "sku": map[string]any{"name": "VpnGw1", "tier": "VpnGw1"}, + "ipConfigurations": []any{ + map[string]any{"name": "default", "subnetId": testGatewaySubnetID, "publicIpAddressId": testVnetGatewayPipID}, + map[string]any{"name": "activeActive", "subnetId": testGatewaySubnetID, "publicIpAddressId": testVnetGatewayPipID2}, + }, + "activeActive": true, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.NoError(t, err) + require.Len(t, sent.Properties.IPConfigurations, 2) + require.True(t, *sent.Properties.Active) + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns: a create here takes 30-45 minutes, and a mismatch orphans a + // billed gateway for that entire window. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VirtualNetworkGateway, _ *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "vngw1", Properties: vnetGatewayDesired("VpnGw1", 0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVnetGatewayNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVnetGatewayNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "vngw1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + // Every enum comes back from ARM lower-cased and must be canonicalised. + require.Equal(t, "Vpn", props["gatewayType"]) + require.Equal(t, "RouteBased", props["vpnType"]) + require.Equal(t, "VpnGw1", props["sku"].(map[string]any)["name"]) + require.Equal(t, "VpnGw1", props["sku"].(map[string]any)["tier"]) + require.Equal(t, "Generation1", props["vpnGatewayGeneration"]) + require.Equal(t, false, props["activeActive"]) + require.Equal(t, true, props["enableBgp"]) + + bgp := props["bgpSettings"].(map[string]any) + require.EqualValues(t, 65515, bgp["asn"]) + require.EqualValues(t, 0, bgp["peerWeight"]) + require.NotContains(t, bgp, "bgpPeeringAddress") + + configs := props["ipConfigurations"].([]any) + require.Len(t, configs, 1) + cfg := configs[0].(map[string]any) + require.Equal(t, "default", cfg["name"]) + require.Equal(t, testGatewaySubnetID, cfg["subnetId"]) + require.Equal(t, testVnetGatewayPipID, cfg["publicIpAddressId"]) + require.Equal(t, "Dynamic", cfg["privateIpAllocationMethod"]) + + vpnClient := props["vpnClientConfiguration"].(map[string]any) + require.Equal(t, []any{"172.16.201.0/24"}, vpnClient["vpnClientAddressPool"]) + require.Equal(t, []any{"OpenVPN"}, vpnClient["vpnClientProtocols"]) + require.Equal(t, []any{"Certificate"}, vpnClient["vpnAuthenticationTypes"]) + certs := vpnClient["vpnClientRootCertificates"].([]any) + require.Equal(t, "root", certs[0].(map[string]any)["name"]) + require.Equal(t, "MIIBase64CertBody", certs[0].(map[string]any)["publicCertData"]) + }) + + // Service state, the Azure-picked addresses and the SKU capacity would read as + // drift forever. + t.Run("Read_drops_service_state", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVnetGatewayNativeID}) + require.NoError(t, err) + for _, key := range []string{ + "provisioningState", "resourceGuid", "etag", "capacity", + "bgpPeeringAddress", "bgpPeeringAddresses", "privateIPAddress", + "inboundDnsForwardingEndpoint", "radiusServerSecret", + } { + require.NotContains(t, got.Properties, key) + } + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VirtualNetworkGateway, _ *armnetwork.VirtualNetworkGatewaysClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewaysClientCreateOrUpdateResponse{VirtualNetworkGateway: gatewayResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testVnetGatewayNativeID, + DesiredProperties: vnetGatewayDesired("VpnGw2", 10), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Equal(t, armnetwork.VirtualNetworkGatewaySKUNameVPNGw2, *sent.Properties.SKU.Name) + require.Equal(t, int32(10), *sent.Properties.BgpSettings.PeerWeight) + // Location, gateway type and the front end must ride along: a PUT without + // them is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.VirtualNetworkGatewayTypeVPN, *sent.Properties.GatewayType) + require.Len(t, sent.Properties.IPConfigurations, 1) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVnetGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewaysClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewaysClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVnetGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testVnetGatewayNativeID}, got.NativeIDs) + }) + + // ARM offers no subscription-wide listing for this type. + t.Run("List_without_group_is_empty", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Empty(t, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewaysClientGetOptions) (armnetwork.VirtualNetworkGatewaysClientGetResponse, error) { + return armnetwork.VirtualNetworkGatewaysClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testVnetGatewayNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/pkg/resources/virtualnetworkgatewayconnection.go b/pkg/resources/virtualnetworkgatewayconnection.go new file mode 100644 index 00000000..0798ce41 --- /dev/null +++ b/pkg/resources/virtualnetworkgatewayconnection.go @@ -0,0 +1,631 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/client" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/prov" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/registry" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" +) + +const ResourceTypeVirtualNetworkGatewayConnection = "AZURE::Network::VirtualNetworkGatewayConnection" + +// virtualNetworkGatewayConnectionsAPI is the armnetwork surface used here. +// BeginUpdateTags and BeginSetSharedKey are deliberately absent: neither can change +// the IPsec policies or the routing weight, so every update is a re-PUT. +type virtualNetworkGatewayConnectionsAPI interface { + BeginCreateOrUpdate(ctx context.Context, resourceGroupName string, virtualNetworkGatewayConnectionName string, parameters armnetwork.VirtualNetworkGatewayConnection, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) + Get(ctx context.Context, resourceGroupName string, virtualNetworkGatewayConnectionName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) + BeginDelete(ctx context.Context, resourceGroupName string, virtualNetworkGatewayConnectionName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) + NewListPager(resourceGroupName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse] +} + +func init() { + registry.Register(ResourceTypeVirtualNetworkGatewayConnection, func(c *client.Client, cfg *config.Config) prov.Provisioner { + return &VirtualNetworkGatewayConnection{ + api: c.VirtualNetworkGatewayConnectionsClient, + pipeline: c.Pipeline(), + config: cfg, + } + }) +} + +// VirtualNetworkGatewayConnection is the provisioner for the tunnel joining a +// virtual network gateway to its peer (Microsoft.Network/connections). +type VirtualNetworkGatewayConnection struct { + api virtualNetworkGatewayConnectionsAPI + pipeline runtime.Pipeline + config *config.Config +} + +// virtualNetworkGatewayConnectionProps mirrors +// schema/pkl/network/virtualnetworkgatewayconnection.pkl. +type virtualNetworkGatewayConnectionProps struct { + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + Location string `json:"location"` + ConnectionType string `json:"connectionType"` + VirtualNetworkGateway1ID string `json:"virtualNetworkGateway1Id"` + LocalNetworkGateway2ID *string `json:"localNetworkGateway2Id"` + VirtualNetworkGateway2ID *string `json:"virtualNetworkGateway2Id"` + PeerID *string `json:"peerId"` + SharedKey *string `json:"sharedKey"` + AuthorizationKey *string `json:"authorizationKey"` + EnableBgp *bool `json:"enableBgp"` + ConnectionProtocol *string `json:"connectionProtocol"` + ConnectionMode *string `json:"connectionMode"` + RoutingWeight *int32 `json:"routingWeight"` + DpdTimeoutSeconds *int32 `json:"dpdTimeoutSeconds"` + UsePolicyBasedTrafficSelectors *bool `json:"usePolicyBasedTrafficSelectors"` + UseLocalAzureIPAddress *bool `json:"useLocalAzureIpAddress"` + ExpressRouteGatewayBypass *bool `json:"expressRouteGatewayBypass"` + IpsecPolicies []virtualNetworkGatewayConnectionIpsecProps `json:"ipsecPolicies"` +} + +type virtualNetworkGatewayConnectionIpsecProps struct { + SaLifeTimeSeconds *int32 `json:"saLifeTimeSeconds"` + SaDataSizeKilobytes *int32 `json:"saDataSizeKilobytes"` + IpsecEncryption string `json:"ipsecEncryption"` + IpsecIntegrity string `json:"ipsecIntegrity"` + IkeEncryption string `json:"ikeEncryption"` + IkeIntegrity string `json:"ikeIntegrity"` + DhGroup string `json:"dhGroup"` + PfsGroup string `json:"pfsGroup"` +} + +var ( + // The canonical casing for every enum the connection echoes back, applied on the + // read path because ARM is inconsistent about it. + connectionTypes = []string{"IPsec", "Vnet2Vnet", "ExpressRoute", "VPNClient"} + connectionProtocols = []string{"IKEv1", "IKEv2"} + connectionModes = []string{"Default", "InitiatorOnly", "ResponderOnly"} + dhGroups = []string{"None", "DHGroup1", "DHGroup2", "DHGroup14", "DHGroup24", "DHGroup2048", "ECP256", "ECP384"} + pfsGroups = []string{"None", "PFS1", "PFS2", "PFS14", "PFS24", "PFS2048", "PFSMM", "ECP256", "ECP384"} + ipsecEncryptions = []string{"None", "DES", "DES3", "AES128", "AES192", "AES256", "GCMAES128", "GCMAES192", "GCMAES256"} + ipsecIntegrities = []string{"MD5", "SHA1", "SHA256", "GCMAES128", "GCMAES192", "GCMAES256"} + ikeEncryptions = []string{"DES", "DES3", "AES128", "AES192", "AES256", "GCMAES128", "GCMAES256"} + ikeIntegrities = []string{"MD5", "SHA1", "SHA256", "SHA384", "GCMAES128", "GCMAES256"} +) + +func virtualNetworkGatewayConnectionIDParts(resourceID string) (rgName, name string, err error) { + rgName, names, err := armIDParts(resourceID, "connections") + if err != nil { + return "", "", err + } + return rgName, names["connections"], nil +} + +func (r *VirtualNetworkGatewayConnection) buildPropertiesFromResult(conn *armnetwork.VirtualNetworkGatewayConnection, rgName string) map[string]any { + props := make(map[string]any) + + props["resourceGroupName"] = rgName + + if conn.ID != nil { + props["id"] = *conn.ID + } + if conn.Name != nil { + props["name"] = *conn.Name + } + if conn.Location != nil { + props["location"] = normalizeAzureLocation(*conn.Location) + } + if tags := azureTagsToFormaeTags(conn.Tags); len(tags) > 0 { + props["Tags"] = tags + } + + p := conn.Properties + if p == nil { + return props + } + + if p.ConnectionType != nil && *p.ConnectionType != "" { + props["connectionType"] = canonicalizeEnum(string(*p.ConnectionType), connectionTypes...) + } + // ARM echoes the peers back as full gateway objects with their own properties + // inflated; only the ARM ID is ours. + if p.VirtualNetworkGateway1 != nil && p.VirtualNetworkGateway1.ID != nil { + props["virtualNetworkGateway1Id"] = *p.VirtualNetworkGateway1.ID + } + if p.LocalNetworkGateway2 != nil && p.LocalNetworkGateway2.ID != nil { + props["localNetworkGateway2Id"] = *p.LocalNetworkGateway2.ID + } + if p.VirtualNetworkGateway2 != nil && p.VirtualNetworkGateway2.ID != nil { + props["virtualNetworkGateway2Id"] = *p.VirtualNetworkGateway2.ID + } + if p.Peer != nil && p.Peer.ID != nil { + props["peerId"] = *p.Peer.ID + } + if p.EnableBgp != nil { + props["enableBgp"] = *p.EnableBgp + } + if p.ConnectionProtocol != nil && *p.ConnectionProtocol != "" { + props["connectionProtocol"] = canonicalizeEnum(string(*p.ConnectionProtocol), connectionProtocols...) + } + if p.ConnectionMode != nil && *p.ConnectionMode != "" { + props["connectionMode"] = canonicalizeEnum(string(*p.ConnectionMode), connectionModes...) + } + if p.RoutingWeight != nil { + props["routingWeight"] = *p.RoutingWeight + } + if p.DpdTimeoutSeconds != nil { + props["dpdTimeoutSeconds"] = *p.DpdTimeoutSeconds + } + if p.UsePolicyBasedTrafficSelectors != nil { + props["usePolicyBasedTrafficSelectors"] = *p.UsePolicyBasedTrafficSelectors + } + if p.UseLocalAzureIPAddress != nil { + props["useLocalAzureIpAddress"] = *p.UseLocalAzureIPAddress + } + if p.ExpressRouteGatewayBypass != nil { + props["expressRouteGatewayBypass"] = *p.ExpressRouteGatewayBypass + } + if policies := ipsecPoliciesToProps(p.IPSecPolicies); len(policies) > 0 { + props["ipsecPolicies"] = policies + } + // sharedKey and authorizationKey are write-only: ARM does hand the shared key + // back, and surfacing it would put a pre-shared key into stored state. + // provisioningState, resourceGuid, connectionStatus, the byte counters and + // tunnelConnectionStatus are service state. + + return props +} + +// ipsecPoliciesToProps is the read-path inverse of ipsecPoliciesFromProps. +func ipsecPoliciesToProps(policies []*armnetwork.IPSecPolicy) []map[string]any { + if len(policies) == 0 { + return nil + } + out := make([]map[string]any, 0, len(policies)) + for _, policy := range policies { + if policy == nil { + continue + } + entry := make(map[string]any) + if policy.SaLifeTimeSeconds != nil { + entry["saLifeTimeSeconds"] = *policy.SaLifeTimeSeconds + } + if policy.SaDataSizeKilobytes != nil { + entry["saDataSizeKilobytes"] = *policy.SaDataSizeKilobytes + } + if policy.IPSecEncryption != nil && *policy.IPSecEncryption != "" { + entry["ipsecEncryption"] = canonicalizeEnum(string(*policy.IPSecEncryption), ipsecEncryptions...) + } + if policy.IPSecIntegrity != nil && *policy.IPSecIntegrity != "" { + entry["ipsecIntegrity"] = canonicalizeEnum(string(*policy.IPSecIntegrity), ipsecIntegrities...) + } + if policy.IkeEncryption != nil && *policy.IkeEncryption != "" { + entry["ikeEncryption"] = canonicalizeEnum(string(*policy.IkeEncryption), ikeEncryptions...) + } + if policy.IkeIntegrity != nil && *policy.IkeIntegrity != "" { + entry["ikeIntegrity"] = canonicalizeEnum(string(*policy.IkeIntegrity), ikeIntegrities...) + } + if policy.DhGroup != nil && *policy.DhGroup != "" { + entry["dhGroup"] = canonicalizeEnum(string(*policy.DhGroup), dhGroups...) + } + if policy.PfsGroup != nil && *policy.PfsGroup != "" { + entry["pfsGroup"] = canonicalizeEnum(string(*policy.PfsGroup), pfsGroups...) + } + out = append(out, entry) + } + return out +} + +// ipsecPoliciesFromProps builds the request-side policy list. +func ipsecPoliciesFromProps(policies []virtualNetworkGatewayConnectionIpsecProps) []*armnetwork.IPSecPolicy { + if len(policies) == 0 { + return nil + } + out := make([]*armnetwork.IPSecPolicy, 0, len(policies)) + for i := range policies { + policy := policies[i] + out = append(out, &armnetwork.IPSecPolicy{ + SaLifeTimeSeconds: policy.SaLifeTimeSeconds, + SaDataSizeKilobytes: policy.SaDataSizeKilobytes, + IPSecEncryption: to.Ptr(armnetwork.IPSecEncryption(policy.IpsecEncryption)), + IPSecIntegrity: to.Ptr(armnetwork.IPSecIntegrity(policy.IpsecIntegrity)), + IkeEncryption: to.Ptr(armnetwork.IkeEncryption(policy.IkeEncryption)), + IkeIntegrity: to.Ptr(armnetwork.IkeIntegrity(policy.IkeIntegrity)), + DhGroup: to.Ptr(armnetwork.DhGroup(policy.DhGroup)), + PfsGroup: to.Ptr(armnetwork.PfsGroup(policy.PfsGroup)), + }) + } + return out +} + +// virtualNetworkGatewayConnectionParams builds the request body shared by create and +// update. +// +// ARM models the two gateway peers as whole VirtualNetworkGateway / +// LocalNetworkGateway objects rather than SubResources, but accepts a body carrying +// only their `id`, which is what is sent here. +func virtualNetworkGatewayConnectionParams(props virtualNetworkGatewayConnectionProps, payload json.RawMessage) armnetwork.VirtualNetworkGatewayConnection { + params := armnetwork.VirtualNetworkGatewayConnection{ + Location: to.Ptr(props.Location), + Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ + ConnectionType: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionType(props.ConnectionType)), + VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ + ID: to.Ptr(props.VirtualNetworkGateway1ID), + }, + SharedKey: props.SharedKey, + AuthorizationKey: props.AuthorizationKey, + EnableBgp: props.EnableBgp, + RoutingWeight: props.RoutingWeight, + DpdTimeoutSeconds: props.DpdTimeoutSeconds, + UsePolicyBasedTrafficSelectors: props.UsePolicyBasedTrafficSelectors, + UseLocalAzureIPAddress: props.UseLocalAzureIPAddress, + ExpressRouteGatewayBypass: props.ExpressRouteGatewayBypass, + IPSecPolicies: ipsecPoliciesFromProps(props.IpsecPolicies), + }, + } + if props.LocalNetworkGateway2ID != nil && *props.LocalNetworkGateway2ID != "" { + params.Properties.LocalNetworkGateway2 = &armnetwork.LocalNetworkGateway{ID: props.LocalNetworkGateway2ID} + } + if props.VirtualNetworkGateway2ID != nil && *props.VirtualNetworkGateway2ID != "" { + params.Properties.VirtualNetworkGateway2 = &armnetwork.VirtualNetworkGateway{ID: props.VirtualNetworkGateway2ID} + } + if props.PeerID != nil && *props.PeerID != "" { + params.Properties.Peer = &armnetwork.SubResource{ID: props.PeerID} + } + if props.ConnectionProtocol != nil { + params.Properties.ConnectionProtocol = to.Ptr(armnetwork.VirtualNetworkGatewayConnectionProtocol(*props.ConnectionProtocol)) + } + if props.ConnectionMode != nil { + params.Properties.ConnectionMode = to.Ptr(armnetwork.VirtualNetworkGatewayConnectionMode(*props.ConnectionMode)) + } + + if tags := formaeTagsToAzureTags(payload); len(tags) > 0 { + params.Tags = tags + } + + return params +} + +// upsert backs both Create and Update: neither BeginUpdateTags nor +// BeginSetSharedKey can change the IPsec policies, so an update is another +// CreateOrUpdate. +func (r *VirtualNetworkGatewayConnection) upsert(ctx context.Context, payload json.RawMessage, label string) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], virtualNetworkGatewayConnectionProps, string, error) { + var props virtualNetworkGatewayConnectionProps + if err := json.Unmarshal(payload, &props); err != nil { + return nil, props, "", fmt.Errorf("failed to parse resource properties: %w", err) + } + if props.ResourceGroupName == "" { + return nil, props, "", fmt.Errorf("resourceGroupName is required") + } + if props.Location == "" { + return nil, props, "", fmt.Errorf("location is required") + } + if props.ConnectionType == "" { + return nil, props, "", fmt.Errorf("connectionType is required") + } + if props.VirtualNetworkGateway1ID == "" { + return nil, props, "", fmt.Errorf("virtualNetworkGateway1Id is required") + } + // Each connection type has exactly one peer field, and ARM's rejection for the + // wrong one is opaque. + switch props.ConnectionType { + case "IPsec": + if props.LocalNetworkGateway2ID == nil || *props.LocalNetworkGateway2ID == "" { + return nil, props, "", fmt.Errorf("localNetworkGateway2Id is required for an IPsec connection") + } + case "Vnet2Vnet": + if props.VirtualNetworkGateway2ID == nil || *props.VirtualNetworkGateway2ID == "" { + return nil, props, "", fmt.Errorf("virtualNetworkGateway2Id is required for a Vnet2Vnet connection") + } + case "ExpressRoute": + if props.PeerID == nil || *props.PeerID == "" { + return nil, props, "", fmt.Errorf("peerId is required for an ExpressRoute connection") + } + } + // Azure accepts at most one custom policy per connection. + if len(props.IpsecPolicies) > 1 { + return nil, props, "", fmt.Errorf("ipsecPolicies accepts at most one entry, got %d", len(props.IpsecPolicies)) + } + name := props.Name + if name == "" { + name = label + } + if name == "" { + return nil, props, "", fmt.Errorf("name is required") + } + + poller, err := r.api.BeginCreateOrUpdate(ctx, props.ResourceGroupName, name, + virtualNetworkGatewayConnectionParams(props, payload), nil) + return poller, props, name, err +} + +func (r *VirtualNetworkGatewayConnection) Create(ctx context.Context, request *resource.CreateRequest) (*resource.CreateResult, error) { + poller, props, name, err := r.upsert(ctx, request.Properties, request.Label) + if err != nil { + if name == "" { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + expectedNativeID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/connections/%s", + r.config.SubscriptionId, props.ResourceGroupName, name) + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusFailure, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + nativeID, propsJSON, err := r.completeFromConnection(&result.VirtualNetworkGatewayConnection) + if err != nil { + return nil, err + } + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpCreate, resumeToken, expectedNativeID) + if err != nil { + return nil, err + } + + return &resource.CreateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationCreate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: expectedNativeID, + }, + }, nil +} + +func (r *VirtualNetworkGatewayConnection) Read(ctx context.Context, request *resource.ReadRequest) (*resource.ReadResult, error) { + rgName, name, err := virtualNetworkGatewayConnectionIDParts(request.NativeID) + if err != nil { + return nil, err + } + + result, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + return &resource.ReadResult{ErrorCode: operationErrorCode(err)}, nil + } + + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualNetworkGatewayConnection, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.ReadResult{ + ResourceType: ResourceTypeVirtualNetworkGatewayConnection, + Properties: string(propsJSON), + }, nil +} + +func (r *VirtualNetworkGatewayConnection) Update(ctx context.Context, request *resource.UpdateRequest) (*resource.UpdateResult, error) { + rgName, _, err := virtualNetworkGatewayConnectionIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, _, name, err := r.upsert(ctx, request.DesiredProperties, "") + if err != nil { + if name == "" { + return nil, err + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + result, err := poller.Result(ctx) + if err != nil { + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(&result.VirtualNetworkGatewayConnection, rgName)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + ResourceProperties: propsJSON, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpUpdate, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.UpdateResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationUpdate, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualNetworkGatewayConnection) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { + rgName, name, err := virtualNetworkGatewayConnectionIDParts(request.NativeID) + if err != nil { + return nil, err + } + + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: request.NativeID, + ErrorCode: operationErrorCode(err), + }, + }, nil + } + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: request.NativeID, + }, + }, nil + } + + resumeToken, err := poller.ResumeToken() + if err != nil { + return nil, fmt.Errorf("failed to get resume token: %w", err) + } + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + if err != nil { + return nil, err + } + + return &resource.DeleteResult{ + ProgressResult: &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: request.NativeID, + }, + }, nil +} + +func (r *VirtualNetworkGatewayConnection) Status(ctx context.Context, request *resource.StatusRequest) (*resource.StatusResult, error) { + reqID, err := decodeLROStatus(request.RequestID) + if err != nil { + return nil, err + } + + switch reqID.OperationType { + case lroOpCreate, lroOpUpdate: + // Both resume as CreateOrUpdate responses: Update re-PUTs, so the poller that + // issued the token has the same response type in either case. + operation := resource.OperationCreate + if reqID.OperationType == lroOpUpdate { + operation = resource.OperationUpdate + } + return statusLRO(ctx, request, &reqID, operation, + func(token string) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) { + return resumePoller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse](r.pipeline, token) + }, + func(_ context.Context, result armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse, _ resource.Operation) (string, json.RawMessage, error) { + return r.completeFromConnection(&result.VirtualNetworkGatewayConnection) + }) + case lroOpDelete: + return statusDeleteLRO(ctx, request, &reqID, + func(token string) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) { + return resumePoller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse](r.pipeline, token) + }, nil) + default: + return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) + } +} + +func (r *VirtualNetworkGatewayConnection) completeFromConnection(conn *armnetwork.VirtualNetworkGatewayConnection) (string, json.RawMessage, error) { + nativeID := "" + rgName := "" + if conn.ID != nil { + nativeID = *conn.ID + if rg, _, err := virtualNetworkGatewayConnectionIDParts(*conn.ID); err == nil { + rgName = rg + } + } + propsJSON, err := json.Marshal(r.buildPropertiesFromResult(conn, rgName)) + if err != nil { + return "", nil, fmt.Errorf("failed to marshal response properties: %w", err) + } + return nativeID, propsJSON, nil +} + +// List is scoped to a resource group: ARM offers no subscription-wide listing for +// connections. +func (r *VirtualNetworkGatewayConnection) List(ctx context.Context, request *resource.ListRequest) (*resource.ListResult, error) { + rgName := request.AdditionalProperties["resourceGroupName"] + if rgName == "" { + return &resource.ListResult{}, nil + } + + var nativeIDs []string + pager := r.api.NewListPager(rgName, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list virtual network gateway connections in resource group %s: %w", rgName, err) + } + for _, conn := range page.Value { + if conn.ID != nil { + nativeIDs = append(nativeIDs, *conn.ID) + } + } + } + return &resource.ListResult{NativeIDs: nativeIDs}, nil +} diff --git a/pkg/resources/virtualnetworkgatewayconnection_integration_test.go b/pkg/resources/virtualnetworkgatewayconnection_integration_test.go new file mode 100644 index 00000000..c97ad807 --- /dev/null +++ b/pkg/resources/virtualnetworkgatewayconnection_integration_test.go @@ -0,0 +1,400 @@ +// © 2025 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package resources + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4" + "github.com/platform-engineering-labs/formae-plugin-azure/pkg/config" + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" + "github.com/stretchr/testify/require" +) + +const ( + testConnectionNativeID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/connections/conn1" + testConnectionGw1ID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworkGateways/vngw1" + testConnectionGw2ID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/virtualNetworkGateways/vngw2" + testConnectionLngID = "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.Network/localNetworkGateways/lng1" +) + +type fakeVirtualNetworkGatewayConnectionsAPI struct { + beginCreateOrUpdateFn func(ctx context.Context, rgName, name string, params armnetwork.VirtualNetworkGatewayConnection, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) + getFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) + beginDeleteFn func(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) + newListPagerFn func(rgName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse] +} + +func (f *fakeVirtualNetworkGatewayConnectionsAPI) BeginCreateOrUpdate(ctx context.Context, rgName, name string, params armnetwork.VirtualNetworkGatewayConnection, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) { + return f.beginCreateOrUpdateFn(ctx, rgName, name, params, options) +} + +func (f *fakeVirtualNetworkGatewayConnectionsAPI) Get(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) { + return f.getFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualNetworkGatewayConnectionsAPI) BeginDelete(ctx context.Context, rgName, name string, options *armnetwork.VirtualNetworkGatewayConnectionsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) { + return f.beginDeleteFn(ctx, rgName, name, options) +} + +func (f *fakeVirtualNetworkGatewayConnectionsAPI) NewListPager(rgName string, options *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse] { + return f.newListPagerFn(rgName, options) +} + +func newTestVirtualNetworkGatewayConnection(api virtualNetworkGatewayConnectionsAPI) *VirtualNetworkGatewayConnection { + return &VirtualNetworkGatewayConnection{ + api: api, + config: &config.Config{SubscriptionId: "sub-1"}, + } +} + +func connectionDesired(routingWeight int) []byte { + out, _ := json.Marshal(map[string]any{ + "name": "conn1", + "resourceGroupName": "rg-1", + "location": "eastus", + "connectionType": "IPsec", + "virtualNetworkGateway1Id": testConnectionGw1ID, + "localNetworkGateway2Id": testConnectionLngID, + "sharedKey": "not-a-real-psk", + "enableBgp": false, + "connectionProtocol": "IKEv2", + "connectionMode": "Default", + "routingWeight": routingWeight, + "dpdTimeoutSeconds": 45, + "ipsecPolicies": []any{map[string]any{ + "saLifeTimeSeconds": 27000, + "saDataSizeKilobytes": 102400000, + "ipsecEncryption": "GCMAES256", + "ipsecIntegrity": "GCMAES256", + "ikeEncryption": "AES256", + "ikeIntegrity": "SHA384", + "dhGroup": "DHGroup24", + "pfsGroup": "PFS24", + }}, + "Tags": []any{map[string]any{"Key": "env", "Value": "test"}}, + }) + return out +} + +func TestVirtualNetworkGatewayConnection_CRUD(t *testing.T) { + connResult := armnetwork.VirtualNetworkGatewayConnection{ + ID: to.Ptr(testConnectionNativeID), + Name: to.Ptr("conn1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VirtualNetworkGatewayConnectionPropertiesFormat{ + // ARM echoes every enum back with its own casing. + ConnectionType: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionType("ipsec")), + // ARM inflates both peers into full gateway objects; only the ID is ours. + VirtualNetworkGateway1: &armnetwork.VirtualNetworkGateway{ + ID: to.Ptr(testConnectionGw1ID), + Name: to.Ptr("vngw1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.VirtualNetworkGatewayPropertiesFormat{}, + }, + LocalNetworkGateway2: &armnetwork.LocalNetworkGateway{ + ID: to.Ptr(testConnectionLngID), + Name: to.Ptr("lng1"), + Location: to.Ptr("East US"), + Properties: &armnetwork.LocalNetworkGatewayPropertiesFormat{}, + }, + // ARM does hand the shared key back; it must never reach state. + SharedKey: to.Ptr("not-a-real-psk"), + EnableBgp: to.Ptr(false), + ConnectionProtocol: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionProtocol("ikev2")), + ConnectionMode: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionMode("default")), + RoutingWeight: to.Ptr(int32(0)), + DpdTimeoutSeconds: to.Ptr(int32(45)), + IPSecPolicies: []*armnetwork.IPSecPolicy{{ + SaLifeTimeSeconds: to.Ptr(int32(27000)), + SaDataSizeKilobytes: to.Ptr(int32(102400000)), + IPSecEncryption: to.Ptr(armnetwork.IPSecEncryption("gcmaes256")), + IPSecIntegrity: to.Ptr(armnetwork.IPSecIntegrity("gcmaes256")), + IkeEncryption: to.Ptr(armnetwork.IkeEncryption("aes256")), + IkeIntegrity: to.Ptr(armnetwork.IkeIntegrity("sha384")), + DhGroup: to.Ptr(armnetwork.DhGroup("dhgroup24")), + PfsGroup: to.Ptr(armnetwork.PfsGroup("pfs24")), + }}, + // Service state. + ConnectionStatus: to.Ptr(armnetwork.VirtualNetworkGatewayConnectionStatusNotConnected), + EgressBytesTransferred: to.Ptr(int64(0)), + IngressBytesTransferred: to.Ptr(int64(0)), + ProvisioningState: to.Ptr(armnetwork.ProvisioningStateSucceeded), + ResourceGUID: to.Ptr("11112222-3333-4444-5555-666677778888"), + TunnelConnectionStatus: []*armnetwork.TunnelConnectionHealth{{ + Tunnel: to.Ptr("tunnel0"), + }}, + }, + Tags: map[string]*string{"env": to.Ptr("test")}, + Etag: to.Ptr("W/\"etag\""), + } + + var sent armnetwork.VirtualNetworkGatewayConnection + createCalls := 0 + deleteCalls := 0 + fake := &fakeVirtualNetworkGatewayConnectionsAPI{ + beginCreateOrUpdateFn: func(_ context.Context, rgName, name string, params armnetwork.VirtualNetworkGatewayConnection, _ *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) { + require.Equal(t, "rg-1", rgName) + require.Equal(t, "conn1", name) + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse{VirtualNetworkGatewayConnection: connResult}), nil + }, + getFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) { + return armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse{VirtualNetworkGatewayConnection: connResult}, nil + }, + beginDeleteFn: func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewayConnectionsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) { + deleteCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse{}), nil + }, + newListPagerFn: func(_ string, _ *armnetwork.VirtualNetworkGatewayConnectionsClientListOptions) *runtime.Pager[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse] { + return runtime.NewPager(runtime.PagingHandler[armnetwork.VirtualNetworkGatewayConnectionsClientListResponse]{ + More: func(_ armnetwork.VirtualNetworkGatewayConnectionsClientListResponse) bool { return false }, + Fetcher: func(_ context.Context, _ *armnetwork.VirtualNetworkGatewayConnectionsClientListResponse) (armnetwork.VirtualNetworkGatewayConnectionsClientListResponse, error) { + return armnetwork.VirtualNetworkGatewayConnectionsClientListResponse{ + VirtualNetworkGatewayConnectionListResult: armnetwork.VirtualNetworkGatewayConnectionListResult{ + Value: []*armnetwork.VirtualNetworkGatewayConnection{{ID: to.Ptr(testConnectionNativeID)}}, + }, + }, nil + }, + }) + }, + } + prov := newTestVirtualNetworkGatewayConnection(fake) + + t.Run("Create", func(t *testing.T) { + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "conn1", Properties: connectionDesired(0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, testConnectionNativeID, got.ProgressResult.NativeID) + + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.VirtualNetworkGatewayConnectionTypeIPsec, *sent.Properties.ConnectionType) + // ARM models the peers as whole gateway objects; only the ID is sent. + require.Equal(t, testConnectionGw1ID, *sent.Properties.VirtualNetworkGateway1.ID) + require.Nil(t, sent.Properties.VirtualNetworkGateway1.Properties) + require.Equal(t, testConnectionLngID, *sent.Properties.LocalNetworkGateway2.ID) + require.Nil(t, sent.Properties.VirtualNetworkGateway2) + require.Nil(t, sent.Properties.Peer) + // The shared key must reach ARM even though it is never read back. + require.Equal(t, "not-a-real-psk", *sent.Properties.SharedKey) + require.False(t, *sent.Properties.EnableBgp) + require.Equal(t, armnetwork.VirtualNetworkGatewayConnectionProtocolIKEv2, *sent.Properties.ConnectionProtocol) + require.Equal(t, armnetwork.VirtualNetworkGatewayConnectionModeDefault, *sent.Properties.ConnectionMode) + require.Equal(t, int32(45), *sent.Properties.DpdTimeoutSeconds) + require.Len(t, sent.Properties.IPSecPolicies, 1) + policy := sent.Properties.IPSecPolicies[0] + require.Equal(t, int32(27000), *policy.SaLifeTimeSeconds) + require.Equal(t, armnetwork.IPSecEncryptionGCMAES256, *policy.IPSecEncryption) + require.Equal(t, armnetwork.IkeIntegritySHA384, *policy.IkeIntegrity) + require.Equal(t, armnetwork.DhGroupDHGroup24, *policy.DhGroup) + require.Equal(t, armnetwork.PfsGroupPFS24, *policy.PfsGroup) + require.Equal(t, "test", *sent.Tags["env"]) + }) + + t.Run("Create_requires_first_gateway", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "IPsec", + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "virtualNetworkGateway1Id is required") + }) + + // Each connection type has exactly one peer field, and ARM's rejection for the + // wrong one is opaque. + t.Run("Create_ipsec_requires_local_network_gateway", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "IPsec", "virtualNetworkGateway1Id": testConnectionGw1ID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "localNetworkGateway2Id is required for an IPsec connection") + }) + + t.Run("Create_vnet2vnet_requires_second_gateway", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "Vnet2Vnet", "virtualNetworkGateway1Id": testConnectionGw1ID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "virtualNetworkGateway2Id is required for a Vnet2Vnet connection") + }) + + t.Run("Create_vnet2vnet_sends_second_gateway", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "Vnet2Vnet", + "virtualNetworkGateway1Id": testConnectionGw1ID, + "virtualNetworkGateway2Id": testConnectionGw2ID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.NoError(t, err) + require.Equal(t, testConnectionGw2ID, *sent.Properties.VirtualNetworkGateway2.ID) + require.Nil(t, sent.Properties.LocalNetworkGateway2) + }) + + t.Run("Create_expressroute_requires_peer", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "ExpressRoute", "virtualNetworkGateway1Id": testConnectionGw1ID, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "peerId is required for an ExpressRoute connection") + }) + + // Azure accepts at most one custom policy per connection. + t.Run("Create_rejects_two_ipsec_policies", func(t *testing.T) { + props, _ := json.Marshal(map[string]any{ + "name": "conn1", "resourceGroupName": "rg-1", "location": "eastus", + "connectionType": "IPsec", + "virtualNetworkGateway1Id": testConnectionGw1ID, + "localNetworkGateway2Id": testConnectionLngID, + "ipsecPolicies": []any{map[string]any{}, map[string]any{}}, + }) + _, err := prov.Create(context.Background(), &resource.CreateRequest{Properties: props}) + require.ErrorContains(t, err, "ipsecPolicies accepts at most one entry") + }) + + // The native ID reported while the LRO is still running must match the path ARM + // actually assigns, or the resource is orphaned once it completes. + t.Run("PendingCreateReportsRealNativeID", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, _ armnetwork.VirtualNetworkGatewayConnection, _ *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) { + return newPendingPoller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse](), nil + } + got, err := prov.Create(context.Background(), &resource.CreateRequest{ + Label: "conn1", Properties: connectionDesired(0), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testConnectionNativeID, got.ProgressResult.NativeID) + }) + + t.Run("Read", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testConnectionNativeID}) + require.NoError(t, err) + require.Empty(t, got.ErrorCode) + + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.Equal(t, "conn1", props["name"]) + require.Equal(t, "rg-1", props["resourceGroupName"]) + require.Equal(t, "eastus", props["location"]) + // Every enum comes back from ARM lower-cased and must be canonicalised. + require.Equal(t, "IPsec", props["connectionType"]) + require.Equal(t, "IKEv2", props["connectionProtocol"]) + require.Equal(t, "Default", props["connectionMode"]) + // Only the peers' ARM IDs are read back, never their inflated bodies. + require.Equal(t, testConnectionGw1ID, props["virtualNetworkGateway1Id"]) + require.Equal(t, testConnectionLngID, props["localNetworkGateway2Id"]) + require.Equal(t, false, props["enableBgp"]) + require.EqualValues(t, 0, props["routingWeight"]) + require.EqualValues(t, 45, props["dpdTimeoutSeconds"]) + + policies := props["ipsecPolicies"].([]any) + require.Len(t, policies, 1) + policy := policies[0].(map[string]any) + require.EqualValues(t, 27000, policy["saLifeTimeSeconds"]) + require.EqualValues(t, 102400000, policy["saDataSizeKilobytes"]) + require.Equal(t, "GCMAES256", policy["ipsecEncryption"]) + require.Equal(t, "GCMAES256", policy["ipsecIntegrity"]) + require.Equal(t, "AES256", policy["ikeEncryption"]) + require.Equal(t, "SHA384", policy["ikeIntegrity"]) + require.Equal(t, "DHGroup24", policy["dhGroup"]) + require.Equal(t, "PFS24", policy["pfsGroup"]) + }) + + // The shared key ARM hands back must never reach stored state, and neither may + // the inflated peer bodies or the connection's live counters. + t.Run("Read_drops_service_state_and_secrets", func(t *testing.T) { + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testConnectionNativeID}) + require.NoError(t, err) + for _, key := range []string{ + "sharedKey", "not-a-real-psk", "authorizationKey", + "provisioningState", "resourceGuid", "etag", "connectionStatus", + "egressBytesTransferred", "ingressBytesTransferred", + "tunnelConnectionStatus", + } { + require.NotContains(t, got.Properties, key) + } + // The peers must come back as bare ARM ID strings, not as the inflated + // gateway objects ARM returns. + var props map[string]any + require.NoError(t, json.Unmarshal([]byte(got.Properties), &props)) + require.IsType(t, "", props["virtualNetworkGateway1Id"]) + require.IsType(t, "", props["localNetworkGateway2Id"]) + }) + + t.Run("Update_reissues_create_or_update", func(t *testing.T) { + fake.beginCreateOrUpdateFn = func(_ context.Context, _, _ string, params armnetwork.VirtualNetworkGatewayConnection, _ *armnetwork.VirtualNetworkGatewayConnectionsClientBeginCreateOrUpdateOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse], error) { + sent = params + createCalls++ + return newDonePoller(armnetwork.VirtualNetworkGatewayConnectionsClientCreateOrUpdateResponse{VirtualNetworkGatewayConnection: connResult}), nil + } + before := createCalls + got, err := prov.Update(context.Background(), &resource.UpdateRequest{ + NativeID: testConnectionNativeID, + DesiredProperties: connectionDesired(20), + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, createCalls) + require.Equal(t, int32(20), *sent.Properties.RoutingWeight) + // Location, the connection type and both peers must ride along: a PUT without + // them is rejected. + require.Equal(t, "eastus", *sent.Location) + require.Equal(t, armnetwork.VirtualNetworkGatewayConnectionTypeIPsec, *sent.Properties.ConnectionType) + require.Equal(t, testConnectionGw1ID, *sent.Properties.VirtualNetworkGateway1.ID) + require.Equal(t, testConnectionLngID, *sent.Properties.LocalNetworkGateway2.ID) + }) + + t.Run("Delete", func(t *testing.T) { + before := deleteCalls + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testConnectionNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + }) + + t.Run("Delete_NotFound_is_success", func(t *testing.T) { + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewayConnectionsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualNetworkGatewayConnectionsClientDeleteResponse], error) { + return nil, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testConnectionNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) + }) + + t.Run("List_by_resource_group", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{ + AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{testConnectionNativeID}, got.NativeIDs) + }) + + // ARM offers no subscription-wide listing for this type. + t.Run("List_without_group_is_empty", func(t *testing.T) { + got, err := prov.List(context.Background(), &resource.ListRequest{}) + require.NoError(t, err) + require.Empty(t, got.NativeIDs) + }) + + t.Run("Read_NotFound", func(t *testing.T) { + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualNetworkGatewayConnectionsClientGetOptions) (armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse, error) { + return armnetwork.VirtualNetworkGatewayConnectionsClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + got, err := prov.Read(context.Background(), &resource.ReadRequest{NativeID: testConnectionNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationErrorCodeNotFound, got.ErrorCode) + }) +} diff --git a/schema/pkl/network/virtualnetworkgateway.pkl b/schema/pkl/network/virtualnetworkgateway.pkl new file mode 100644 index 00000000..0d529978 --- /dev/null +++ b/schema/pkl/network/virtualnetworkgateway.pkl @@ -0,0 +1,267 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.virtualnetworkgateway + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VirtualNetworkGateway" +const apiVersion = "2023-05-01" + +/// What the gateway is for. `Vpn` terminates IPsec tunnels; `ExpressRoute` is the +/// ExpressRoute virtual gateway. Immutable. +typealias VirtualNetworkGatewayType = "Vpn"|"ExpressRoute"|"LocalGateway" + +/// Routing model for a VPN gateway. `RouteBased` is what everything modern needs; +/// `PolicyBased` exists only for the Basic SKU and single-tunnel legacy peers. +typealias VpnType = "RouteBased"|"PolicyBased" + +/// Gateway SKU. VpnGw1-5 (and their zone-redundant AZ variants) are the VPN SKUs; +/// ErGw1-3AZ are ExpressRoute; Basic is legacy and cannot be resized to any of +/// the others. +typealias VirtualNetworkGatewaySkuName = + "Basic"|"Standard"|"HighPerformance"|"UltraPerformance" + |"VpnGw1"|"VpnGw2"|"VpnGw3"|"VpnGw4"|"VpnGw5" + |"VpnGw1AZ"|"VpnGw2AZ"|"VpnGw3AZ"|"VpnGw4AZ"|"VpnGw5AZ" + |"ErGw1AZ"|"ErGw2AZ"|"ErGw3AZ" + +/// SKU tier. Azure keeps name and tier identical in practice. +typealias VirtualNetworkGatewaySkuTier = VirtualNetworkGatewaySkuName + +/// Hardware generation. Generation2 is required for VpnGw4/5 and unavailable on +/// Basic; must be `None` when `gatewayType` is not `Vpn`. +typealias VpnGatewayGeneration = "None"|"Generation1"|"Generation2" + +/// Private IP allocation for a gateway IP configuration. Azure only supports +/// Dynamic here. +typealias GatewayIPAllocationMethod = "Dynamic"|"Static" + +/// Tunneling protocol offered to point-to-site clients. +typealias VpnClientProtocol = "IkeV2"|"OpenVPN"|"SSTP" + +/// Authentication method offered to point-to-site clients. +typealias VpnAuthenticationType = "Certificate"|"Radius"|"AAD" + +class VirtualNetworkGatewaySku { + @azure.FieldHint { required = true } + name: VirtualNetworkGatewaySkuName + + @azure.FieldHint { required = true } + tier: VirtualNetworkGatewaySkuTier +} + +/// BGP speaker settings for the gateway. +/// +/// Only the two values a caller owns are modelled: Azure allocates the gateway's +/// BGP peering addresses out of the GatewaySubnet, so they are read back and +/// discarded rather than compared. +class VirtualNetworkGatewayBgpSettings { + /// The gateway's autonomous system number. Azure assigns 65515 when the + /// gateway is created without one. + @azure.FieldHint { required = true } + asn: Int + + /// Weight added to routes learned over BGP by this gateway. + @azure.FieldHint { required = true } + peerWeight: Int +} + +/// One front end of the gateway: the GatewaySubnet it lives in plus the public IP +/// it answers on. An active-active gateway needs two of these. +class VirtualNetworkGatewayIPConfiguration { + /// Name of the IP configuration. + @azure.FieldHint { required = true } + name: String(length >= 1 && length <= 80) + + /// ARM ID of the subnet the gateway lives in, e.g. `gatewaySubnet.res.id`. + /// + /// The subnet MUST be named exactly `GatewaySubnet` — Azure rejects any other + /// name outright. /27 is the practical minimum and /26 or larger is + /// recommended. + @azure.FieldHint { required = true } + subnetId: String|formae.Resolvable + + /// ARM ID of the public IP for this front end, e.g. `pip.res.id`. VpnGw*AZ and + /// ErGw*AZ SKUs require a Standard-SKU static IP. + @azure.FieldHint { required = true } + publicIpAddressId: String|formae.Resolvable + + /// Allocation method for the gateway's private address. Azure only supports + /// Dynamic. + @azure.FieldHint { } + privateIpAllocationMethod: GatewayIPAllocationMethod? +} + +/// A trusted root certificate for point-to-site certificate authentication. +class VpnClientRootCertificate { + /// Name of the certificate entry. + @azure.FieldHint { required = true } + name: String(length >= 1 && length <= 80) + + /// Base-64 encoded public certificate data (the DER body of the .cer, without + /// the PEM header and footer lines). + @azure.FieldHint { required = true } + publicCertData: String +} + +/// Point-to-site (VPN client) configuration. Omit it entirely for a +/// site-to-site-only gateway. +class VpnClientConfiguration { + /// CIDR pool the gateway hands out to VPN clients. It must not overlap the + /// virtual network or any on-premises range. + @azure.FieldHint { required = true } + vpnClientAddressPool: Listing + + /// Tunneling protocols offered to clients. + @azure.FieldHint { } + vpnClientProtocols: Listing? + + /// Authentication methods offered to clients. + @azure.FieldHint { } + vpnAuthenticationTypes: Listing? + + /// Trusted roots for certificate authentication. + @azure.FieldHint { } + vpnClientRootCertificates: Listing? + + /// RADIUS server address, for `Radius` authentication. + @azure.FieldHint { } + radiusServerAddress: String? + + /// RADIUS shared secret. Write-only — Azure never returns it, so wrap with + /// `formae.value(...).opaque` to keep it out of stored state. + @azure.FieldHint { writeOnly = true } + radiusServerSecret: (formae.Value|String)? + + /// Entra tenant URL, for `AAD` authentication. + @azure.FieldHint { } + aadTenant: String? + + /// Entra application audience, for `AAD` authentication. + @azure.FieldHint { } + aadAudience: String? + + /// Entra token issuer URL, for `AAD` authentication. + @azure.FieldHint { } + aadIssuer: String? +} + +open class VirtualNetworkGatewayResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VirtualNetworkGatewayResolvable = (this) { + property = "id" + } + hidden name: VirtualNetworkGatewayResolvable = (this) { + property = "name" + } +} + +/// The classic vnet-scoped gateway: the Azure end of a site-to-site VPN, the +/// point-to-site concentrator, or the ExpressRoute virtual gateway. For the +/// Virtual WAN equivalent see `AZURE::Network::VpnGateway`. +/// +/// EXTREMELY SLOW AND BILLED: this is the slowest resource in the plugin. Azure +/// takes 30-45 minutes to create it and the same again to delete it, and it bills +/// hourly by SKU for the whole time it exists. +/// +/// It needs two things in place first: a subnet named exactly `GatewaySubnet` in +/// the target virtual network, and a public IP in the same region. Both are +/// expressed as resolvable references inside `ipConfigurations`. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VirtualNetworkGateway extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// What the gateway is for. Immutable: switching between Vpn and ExpressRoute + /// means a new gateway. + @azure.FieldHint { required = true; createOnly = true } + gatewayType: VirtualNetworkGatewayType + + /// Routing model. Immutable, and meaningful only when `gatewayType` is `Vpn`; + /// pass `RouteBased` for an ExpressRoute gateway. + @azure.FieldHint { required = true; createOnly = true } + vpnType: VpnType + + /// Gateway SKU. Resizable within a family (VpnGw1 -> VpnGw2), but Basic can + /// never be resized and moving between the AZ and non-AZ families is a + /// replace. + @azure.FieldHint { requiredOnCreate = true } + sku: VirtualNetworkGatewaySku + + /// The gateway's front ends. One entry for a normal gateway, two for + /// active-active. Azure cannot swap a front end in place. + @azure.FieldHint { requiredOnCreate = true; createOnly = true } + ipConfigurations: Listing + + /// Run two instances with two public IPs and two tunnels. Requires two + /// `ipConfigurations` and a VpnGw1 or higher SKU. + @azure.FieldHint { hasProviderDefault = true } + activeActive: Boolean? + + /// Run BGP on the gateway. Not available on the Basic SKU. + @azure.FieldHint { hasProviderDefault = true } + enableBgp: Boolean? + + /// BGP speaker settings. Azure assigns ASN 65515 and peer weight 0 when this + /// is omitted. + @azure.FieldHint { hasProviderDefault = true } + bgpSettings: VirtualNetworkGatewayBgpSettings? + + /// Hardware generation. Must be `None` unless `gatewayType` is `Vpn`. + @azure.FieldHint { createOnly = true; hasProviderDefault = true } + vpnGatewayGeneration: VpnGatewayGeneration? + + /// Point-to-site configuration. Omit for a site-to-site-only gateway. + @azure.FieldHint { } + vpnClientConfiguration: VpnClientConfiguration? + + /// Offer the gateway's private IP as a connection endpoint (private peering + /// over ExpressRoute). Requires an AZ SKU. + @azure.FieldHint { hasProviderDefault = true } + enablePrivateIpAddress: Boolean? + + /// Accept traffic from Azure Virtual WAN networks. + @azure.FieldHint { hasProviderDefault = true } + allowVirtualWanTraffic: Boolean? + + /// Accept transit traffic from peered virtual networks. + @azure.FieldHint { hasProviderDefault = true } + allowRemoteVnetTraffic: Boolean? + + /// ARM ID of the LocalNetworkGateway that carries the default route, for + /// forced tunneling, e.g. `lng.res.id`. + @azure.FieldHint { } + gatewayDefaultSiteId: (String|formae.Resolvable)? + + /// CIDR ranges advertised to VPN clients on top of the vnet's own space. + @azure.FieldHint { } + customRoutes: Listing? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VirtualNetworkGatewayResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/schema/pkl/network/virtualnetworkgatewayconnection.pkl b/schema/pkl/network/virtualnetworkgatewayconnection.pkl new file mode 100644 index 00000000..76c4695b --- /dev/null +++ b/schema/pkl/network/virtualnetworkgatewayconnection.pkl @@ -0,0 +1,195 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +module azure.network.virtualnetworkgatewayconnection + +import "@formae/formae.pkl" +import "../azure.pkl" + +const type = "AZURE::Network::VirtualNetworkGatewayConnection" +const apiVersion = "2023-05-01" + +/// What the connection joins. `IPsec` is site-to-site (gateway to +/// LocalNetworkGateway), `Vnet2Vnet` joins two virtual network gateways, +/// `ExpressRoute` binds a gateway to a circuit, and `VPNClient` is the +/// point-to-site pseudo-connection. +typealias ConnectionType = "IPsec"|"Vnet2Vnet"|"ExpressRoute"|"VPNClient" + +/// IKE version negotiated for the tunnel. +typealias ConnectionProtocol = "IKEv1"|"IKEv2" + +/// Which side may initiate the tunnel. +typealias ConnectionMode = "Default"|"InitiatorOnly"|"ResponderOnly" + +/// Diffie-Hellman group for IKE phase 1. +typealias DhGroup = "None"|"DHGroup1"|"DHGroup2"|"DHGroup14"|"DHGroup24"|"DHGroup2048"|"ECP256"|"ECP384" + +/// Perfect-forward-secrecy group for IKE phase 2. +typealias PfsGroup = "None"|"PFS1"|"PFS2"|"PFS14"|"PFS24"|"PFS2048"|"PFSMM"|"ECP256"|"ECP384" + +/// IPsec (phase 1) encryption algorithm. +typealias IpsecEncryption = "None"|"DES"|"DES3"|"AES128"|"AES192"|"AES256"|"GCMAES128"|"GCMAES192"|"GCMAES256" + +/// IPsec (phase 1) integrity algorithm. +typealias IpsecIntegrity = "MD5"|"SHA1"|"SHA256"|"GCMAES128"|"GCMAES192"|"GCMAES256" + +/// IKE (phase 2) encryption algorithm. +typealias IkeEncryption = "DES"|"DES3"|"AES128"|"AES192"|"AES256"|"GCMAES128"|"GCMAES256" + +/// IKE (phase 2) integrity algorithm. +typealias IkeIntegrity = "MD5"|"SHA1"|"SHA256"|"SHA384"|"GCMAES128"|"GCMAES256" + +/// A custom IPsec/IKE policy. Azure requires the whole set — every field below is +/// mandatory — and accepts at most one policy per connection. Omit +/// `ipsecPolicies` entirely to use Azure's default proposals. +class IpsecPolicy { + @azure.FieldHint { required = true } + saLifeTimeSeconds: Int(this >= 300 && this <= 172799) + + @azure.FieldHint { required = true } + saDataSizeKilobytes: Int(this >= 1024) + + @azure.FieldHint { required = true } + ipsecEncryption: IpsecEncryption + + @azure.FieldHint { required = true } + ipsecIntegrity: IpsecIntegrity + + @azure.FieldHint { required = true } + ikeEncryption: IkeEncryption + + @azure.FieldHint { required = true } + ikeIntegrity: IkeIntegrity + + @azure.FieldHint { required = true } + dhGroup: DhGroup + + @azure.FieldHint { required = true } + pfsGroup: PfsGroup +} + +open class VirtualNetworkGatewayConnectionResolvable extends formae.Resolvable { + hidden type = module.type + + hidden id: VirtualNetworkGatewayConnectionResolvable = (this) { + property = "id" + } + hidden name: VirtualNetworkGatewayConnectionResolvable = (this) { + property = "name" + } +} + +/// The tunnel itself: what joins an `AZURE::Network::VirtualNetworkGateway` to a +/// peer. +/// +/// The peer depends on `connectionType`: +/// +/// * `IPsec` -> `localNetworkGateway2Id` (an `AZURE::Network::LocalNetworkGateway`) +/// * `Vnet2Vnet` -> `virtualNetworkGateway2Id` (a second gateway) +/// * `ExpressRoute`-> `peerId` (the circuit) +/// +/// The connection object itself provisions in a minute or two, but it is useless +/// without its gateway — and that gateway takes 30-45 minutes to appear. Nothing +/// bills against the connection directly; the gateway does. +@azure.ResourceHint { + type = module.type + identifier = "id" + apiVersion = module.apiVersion + parent = "AZURE::Resources::ResourceGroup" + listParam = new formae.ListProperty { parentProperty = "name" listParameter = "resourceGroupName" } +} +open class VirtualNetworkGatewayConnection extends formae.Resource { + + @azure.FieldHint { required = true; createOnly = true } + name: String(length >= 1 && length <= 80) + + @azure.FieldHint { required = true; createOnly = true } + location: azure.Location + + @azure.FieldHint { required = true; createOnly = true } + resourceGroupName: String(length >= 1 && length <= 90)|formae.Resolvable + + /// What the connection joins. Immutable. + @azure.FieldHint { required = true; createOnly = true } + connectionType: ConnectionType + + /// ARM ID of the Azure-side gateway, e.g. `gw.res.id`. Immutable. + @azure.FieldHint { required = true; createOnly = true } + virtualNetworkGateway1Id: String|formae.Resolvable + + /// ARM ID of the on-premises end, e.g. `lng.res.id`. Required for an `IPsec` + /// connection and immutable. + @azure.FieldHint { createOnly = true } + localNetworkGateway2Id: (String|formae.Resolvable)? + + /// ARM ID of the second Azure gateway, e.g. `gw2.res.id`. Required for a + /// `Vnet2Vnet` connection and immutable. + @azure.FieldHint { createOnly = true } + virtualNetworkGateway2Id: (String|formae.Resolvable)? + + /// ARM ID of the ExpressRoute circuit peering. Required for an `ExpressRoute` + /// connection and immutable. + @azure.FieldHint { createOnly = true } + peerId: (String|formae.Resolvable)? + + /// Pre-shared key for the tunnel. Write-only: Azure does return it on read, + /// but it must never be stored in cleartext, so wrap it with + /// `formae.value(...).opaque` and the plugin will not surface it. + @azure.FieldHint { writeOnly = true } + sharedKey: (formae.Value|String)? + + /// Authorization key for an ExpressRoute circuit in another subscription. + /// Write-only. + @azure.FieldHint { writeOnly = true } + authorizationKey: (formae.Value|String)? + + /// Run BGP across the tunnel. Both ends must have BGP configured. + @azure.FieldHint { hasProviderDefault = true } + enableBgp: Boolean? + + /// IKE version. Azure defaults to IKEv2; IKEv1 requires a policy-based peer. + @azure.FieldHint { hasProviderDefault = true } + connectionProtocol: ConnectionProtocol? + + /// Which side may initiate. Azure defaults to Default. + @azure.FieldHint { hasProviderDefault = true } + connectionMode: ConnectionMode? + + /// Route weight applied to prefixes learned over this connection. + @azure.FieldHint { hasProviderDefault = true } + routingWeight: Int(this >= 0)? + + /// Dead-peer-detection timeout, in seconds. Azure defaults to 45. + @azure.FieldHint { hasProviderDefault = true } + dpdTimeoutSeconds: Int(this >= 9 && this <= 3600)? + + /// Negotiate one SA per traffic-selector pair, for a policy-based peer. + @azure.FieldHint { hasProviderDefault = true } + usePolicyBasedTrafficSelectors: Boolean? + + /// Disable IPsec anti-replay. Rarely wanted. + @azure.FieldHint { hasProviderDefault = true } + useLocalAzureIpAddress: Boolean? + + /// Bypass the ExpressRoute gateway for data forwarding (FastPath). + @azure.FieldHint { hasProviderDefault = true } + expressRouteGatewayBypass: Boolean? + + /// Custom IPsec/IKE proposals. Azure accepts at most one entry; omit the field + /// to use Azure's defaults. + @azure.FieldHint { } + ipsecPolicies: Listing? + + @azure.FieldHint { outputField = "Tags"; updateMethod = "EntitySet"; indexField = "Key" } + tags: Listing? + + hidden parent = this + + hidden res: VirtualNetworkGatewayConnectionResolvable = new { + label = parent.label + stack = parent.stack?.label + } +} diff --git a/testdata/virtual-network-gateway-connection-update.pkl b/testdata/virtual-network-gateway-connection-update.pkl new file mode 100644 index 00000000..3185e608 --- /dev/null +++ b/testdata/virtual-network-gateway-connection-update.pkl @@ -0,0 +1,157 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/localnetworkgateway.pkl" +import "@azure/network/virtualnetworkgateway.pkl" +import "@azure/network/virtualnetworkgatewayconnection.pkl" +import "vars.pkl" + +// EXTREMELY SLOW AND BILLED, for the same reason as virtual-network-gateway: the +// connection itself provisions in a minute or two, but it is useless without its +// virtual network gateway, and that gateway takes 30-45 minutes to create and the +// same again to delete. Budget 60-90 minutes for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT far past its 5-minute default. +// +// This is a complete site-to-site fixture: the Azure end is a +// VirtualNetworkGateway, the on-premises end is the plugin's existing +// LocalNetworkGateway, and the connection joins them. Nothing bills against the +// connection or the local network gateway; the virtual network gateway does. +// +// No tunnel is ever established: every address comes from the documentation / +// private ranges (RFC 5737, RFC 1918), so 203.0.113.50 is not a real VPN peer. + +local rg = new resourcegroup.ResourceGroup { + label = "vngw-conn-test-rg" + name = "formae-plugin-sdk-test-vngwconn-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "vngw-conn-test-vnet" + name = "formae-plugin-sdk-test-vngwconn-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.30.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a virtual network gateway whose subnet +// is called anything other than "GatewaySubnet". +local gatewaySubnet = new subnet.Subnet { + label = "vngw-conn-test-subnet" + name = "GatewaySubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.30.255.0/27" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "vngw-conn-test-pip" + name = "formae-plugin-sdk-test-vngwconn-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +local gateway = new virtualnetworkgateway.VirtualNetworkGateway { + label = "vngw-conn-test-gateway" + name = "formae-plugin-sdk-test-vngwconn-gw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayType = "Vpn" + vpnType = "RouteBased" + sku = new virtualnetworkgateway.VirtualNetworkGatewaySku { + name = "VpnGw1" + tier = "VpnGw1" + } + ipConfigurations { + new virtualnetworkgateway.VirtualNetworkGatewayIPConfiguration { + name = "default" + subnetId = gatewaySubnet.res.id + publicIpAddressId = pip.res.id + } + } + activeActive = false + enableBgp = false + vpnGatewayGeneration = "Generation1" +} + +// Free and independent: only a description of the far end of the tunnel. +local localGateway = new localnetworkgateway.LocalNetworkGateway { + label = "vngw-conn-test-local-gateway" + name = "formae-plugin-sdk-test-vngwconn-lng-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayIpAddress = "203.0.113.50" + localNetworkAddressSpace { + "192.168.30.0/24" + } +} + +forma { + vars.stack + + vars.target + + rg + vnet + gatewaySubnet + pip + gateway + localGateway + + // sharedKey is write-only: ARM accepts it, and although Get does return it the + // plugin never surfaces it, so it is not part of resource state. The value below + // is not a credential — no tunnel is ever established against a documentation + // address. + new virtualnetworkgatewayconnection.VirtualNetworkGatewayConnection { + label = "plugin-sdk-test-virtual-network-gateway-connection" + name = "formae-plugin-sdk-test-vngwconn-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + connectionType = "IPsec" + virtualNetworkGateway1Id = gateway.res.id + localNetworkGateway2Id = localGateway.res.id + sharedKey = "conformance-test-not-a-real-psk" + enableBgp = false + connectionProtocol = "IKEv2" + connectionMode = "Default" + routingWeight = 20 + dpdTimeoutSeconds = 60 + usePolicyBasedTrafficSelectors = false + ipsecPolicies { + new virtualnetworkgatewayconnection.IpsecPolicy { + saLifeTimeSeconds = 27000 + saDataSizeKilobytes = 102400000 + ipsecEncryption = "GCMAES256" + ipsecIntegrity = "GCMAES256" + ikeEncryption = "AES256" + ikeIntegrity = "SHA384" + dhGroup = "DHGroup24" + pfsGroup = "PFS24" + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-network-gateway-connection.pkl b/testdata/virtual-network-gateway-connection.pkl new file mode 100644 index 00000000..fd65aa7f --- /dev/null +++ b/testdata/virtual-network-gateway-connection.pkl @@ -0,0 +1,157 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/localnetworkgateway.pkl" +import "@azure/network/virtualnetworkgateway.pkl" +import "@azure/network/virtualnetworkgatewayconnection.pkl" +import "vars.pkl" + +// EXTREMELY SLOW AND BILLED, for the same reason as virtual-network-gateway: the +// connection itself provisions in a minute or two, but it is useless without its +// virtual network gateway, and that gateway takes 30-45 minutes to create and the +// same again to delete. Budget 60-90 minutes for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT far past its 5-minute default. +// +// This is a complete site-to-site fixture: the Azure end is a +// VirtualNetworkGateway, the on-premises end is the plugin's existing +// LocalNetworkGateway, and the connection joins them. Nothing bills against the +// connection or the local network gateway; the virtual network gateway does. +// +// No tunnel is ever established: every address comes from the documentation / +// private ranges (RFC 5737, RFC 1918), so 203.0.113.50 is not a real VPN peer. + +local rg = new resourcegroup.ResourceGroup { + label = "vngw-conn-test-rg" + name = "formae-plugin-sdk-test-vngwconn-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "vngw-conn-test-vnet" + name = "formae-plugin-sdk-test-vngwconn-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.30.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a virtual network gateway whose subnet +// is called anything other than "GatewaySubnet". +local gatewaySubnet = new subnet.Subnet { + label = "vngw-conn-test-subnet" + name = "GatewaySubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.30.255.0/27" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "vngw-conn-test-pip" + name = "formae-plugin-sdk-test-vngwconn-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +local gateway = new virtualnetworkgateway.VirtualNetworkGateway { + label = "vngw-conn-test-gateway" + name = "formae-plugin-sdk-test-vngwconn-gw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayType = "Vpn" + vpnType = "RouteBased" + sku = new virtualnetworkgateway.VirtualNetworkGatewaySku { + name = "VpnGw1" + tier = "VpnGw1" + } + ipConfigurations { + new virtualnetworkgateway.VirtualNetworkGatewayIPConfiguration { + name = "default" + subnetId = gatewaySubnet.res.id + publicIpAddressId = pip.res.id + } + } + activeActive = false + enableBgp = false + vpnGatewayGeneration = "Generation1" +} + +// Free and independent: only a description of the far end of the tunnel. +local localGateway = new localnetworkgateway.LocalNetworkGateway { + label = "vngw-conn-test-local-gateway" + name = "formae-plugin-sdk-test-vngwconn-lng-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayIpAddress = "203.0.113.50" + localNetworkAddressSpace { + "192.168.30.0/24" + } +} + +forma { + vars.stack + + vars.target + + rg + vnet + gatewaySubnet + pip + gateway + localGateway + + // sharedKey is write-only: ARM accepts it, and although Get does return it the + // plugin never surfaces it, so it is not part of resource state. The value below + // is not a credential — no tunnel is ever established against a documentation + // address. + new virtualnetworkgatewayconnection.VirtualNetworkGatewayConnection { + label = "plugin-sdk-test-virtual-network-gateway-connection" + name = "formae-plugin-sdk-test-vngwconn-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + connectionType = "IPsec" + virtualNetworkGateway1Id = gateway.res.id + localNetworkGateway2Id = localGateway.res.id + sharedKey = "conformance-test-not-a-real-psk" + enableBgp = false + connectionProtocol = "IKEv2" + connectionMode = "Default" + routingWeight = 0 + dpdTimeoutSeconds = 45 + usePolicyBasedTrafficSelectors = false + ipsecPolicies { + new virtualnetworkgatewayconnection.IpsecPolicy { + saLifeTimeSeconds = 27000 + saDataSizeKilobytes = 102400000 + ipsecEncryption = "GCMAES256" + ipsecIntegrity = "GCMAES256" + ikeEncryption = "AES256" + ikeIntegrity = "SHA384" + dhGroup = "DHGroup24" + pfsGroup = "PFS24" + } + } + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-network-gateway-update.pkl b/testdata/virtual-network-gateway-update.pkl new file mode 100644 index 00000000..758423d8 --- /dev/null +++ b/testdata/virtual-network-gateway-update.pkl @@ -0,0 +1,116 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/virtualnetworkgateway.pkl" +import "vars.pkl" + +// EXTREMELY SLOW AND BILLED - the slowest fixture in the plugin. Azure takes +// 30-45 minutes to create a virtual network gateway and the same again to delete +// it, so budget 60-90 minutes for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT far past its 5-minute default before running it. The +// gateway bills hourly by SKU (VpnGw1 here, the cheapest generation-1 VPN SKU) +// for the whole time it exists. +// +// The two hard prerequisites are both expressed as resolvable references, so the +// chain rg -> vnet -> GatewaySubnet + public IP -> gateway is ordered by formae +// with no explicit dependency: +// +// * a subnet named EXACTLY "GatewaySubnet" (/27 minimum, /26 recommended) +// * a public IP in the same region +// +// `vpnClientConfiguration` is deliberately omitted: point-to-site needs real +// root-certificate data or a RADIUS secret (write-only), neither of which the +// conformance harness can round-trip. It is covered by the marshaller assertions +// in TestVirtualNetworkGateway_CRUD instead. + +local rg = new resourcegroup.ResourceGroup { + label = "vngw-test-rg" + name = "formae-plugin-sdk-test-vngw-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "vngw-test-vnet" + name = "formae-plugin-sdk-test-vngw-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.20.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a virtual network gateway whose subnet +// is called anything other than "GatewaySubnet". +local gatewaySubnet = new subnet.Subnet { + label = "vngw-test-subnet" + name = "GatewaySubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.20.255.0/27" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "vngw-test-pip" + name = "formae-plugin-sdk-test-vngw-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +forma { + vars.stack + + vars.target + + rg + vnet + gatewaySubnet + pip + + // `bgpSettings` is omitted on purpose: Azure assigns ASN 65515 and peer weight 0, + // so letting the provider default stand avoids pinning values we would then have + // to keep in sync. + new virtualnetworkgateway.VirtualNetworkGateway { + label = "plugin-sdk-test-virtual-network-gateway" + name = "formae-plugin-sdk-test-vngw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayType = "Vpn" + vpnType = "RouteBased" + sku = new virtualnetworkgateway.VirtualNetworkGatewaySku { + name = "VpnGw1" + tier = "VpnGw1" + } + ipConfigurations { + new virtualnetworkgateway.VirtualNetworkGatewayIPConfiguration { + name = "default" + subnetId = gatewaySubnet.res.id + publicIpAddressId = pip.res.id + } + } + activeActive = false + enableBgp = true + vpnGatewayGeneration = "Generation1" + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} diff --git a/testdata/virtual-network-gateway.pkl b/testdata/virtual-network-gateway.pkl new file mode 100644 index 00000000..70f448d2 --- /dev/null +++ b/testdata/virtual-network-gateway.pkl @@ -0,0 +1,116 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +amends "@formae/forma.pkl" +import "@formae/formae.pkl" + +import "@azure/azure.pkl" + +import "@azure/resources/resourcegroup.pkl" +import "@azure/resources/virtualnetwork.pkl" +import "@azure/resources/subnet.pkl" +import "@azure/network/publicipaddress.pkl" +import "@azure/network/virtualnetworkgateway.pkl" +import "vars.pkl" + +// EXTREMELY SLOW AND BILLED - the slowest fixture in the plugin. Azure takes +// 30-45 minutes to create a virtual network gateway and the same again to delete +// it, so budget 60-90 minutes for one CRUD lifecycle and raise +// FORMAE_TEST_TIMEOUT far past its 5-minute default before running it. The +// gateway bills hourly by SKU (VpnGw1 here, the cheapest generation-1 VPN SKU) +// for the whole time it exists. +// +// The two hard prerequisites are both expressed as resolvable references, so the +// chain rg -> vnet -> GatewaySubnet + public IP -> gateway is ordered by formae +// with no explicit dependency: +// +// * a subnet named EXACTLY "GatewaySubnet" (/27 minimum, /26 recommended) +// * a public IP in the same region +// +// `vpnClientConfiguration` is deliberately omitted: point-to-site needs real +// root-certificate data or a RADIUS secret (write-only), neither of which the +// conformance harness can round-trip. It is covered by the marshaller assertions +// in TestVirtualNetworkGateway_CRUD instead. + +local rg = new resourcegroup.ResourceGroup { + label = "vngw-test-rg" + name = "formae-plugin-sdk-test-vngw-rg-\(vars.testRunID)" + location = "eastus" +} + +local vnet = new virtualnetwork.VirtualNetwork { + label = "vngw-test-vnet" + name = "formae-plugin-sdk-test-vngw-vnet-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + addressSpace = new virtualnetwork.AddressSpace { + addressPrefixes = new Listing { + "10.20.0.0/16" + } + } +} + +// The name is load-bearing: Azure rejects a virtual network gateway whose subnet +// is called anything other than "GatewaySubnet". +local gatewaySubnet = new subnet.Subnet { + label = "vngw-test-subnet" + name = "GatewaySubnet" + resourceGroupName = rg.res.name + virtualNetworkName = vnet.res.name + addressPrefix = "10.20.255.0/27" +} + +local pip = new publicipaddress.PublicIPAddress { + label = "vngw-test-pip" + name = "formae-plugin-sdk-test-vngw-pip-\(vars.testRunID)" + location = "eastus" + resourceGroupName = rg.res.name + sku = new publicipaddress.PublicIPAddressSKU { + name = "Standard" + tier = "Regional" + } + publicIPAllocationMethod = "Static" +} + +forma { + vars.stack + + vars.target + + rg + vnet + gatewaySubnet + pip + + // `bgpSettings` is omitted on purpose: Azure assigns ASN 65515 and peer weight 0, + // so letting the provider default stand avoids pinning values we would then have + // to keep in sync. + new virtualnetworkgateway.VirtualNetworkGateway { + label = "plugin-sdk-test-virtual-network-gateway" + name = "formae-plugin-sdk-test-vngw-\(vars.testRunID)" + resourceGroupName = rg.res.name + location = "eastus" + gatewayType = "Vpn" + vpnType = "RouteBased" + sku = new virtualnetworkgateway.VirtualNetworkGatewaySku { + name = "VpnGw1" + tier = "VpnGw1" + } + ipConfigurations { + new virtualnetworkgateway.VirtualNetworkGatewayIPConfiguration { + name = "default" + subnetId = gatewaySubnet.res.id + publicIpAddressId = pip.res.id + } + } + activeActive = false + enableBgp = false + vpnGatewayGeneration = "Generation1" + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} From 0e536dcf140e1bef05c3eeefbefcb40e269fcd72 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:11 +0200 Subject: [PATCH 4/6] fix(azure): declare privateIpAllocationMethod in the bastion-host fixtures The bastion-host conformance fixture omitted ipConfigurations[0].privateIpAllocationMethod. Azure populates it unconditionally, and hasProviderDefault is only honoured on top-level fields, so the value ARM returned read as drift in every comparison phase: Property ipConfigurations[0].privateIpAllocationMethod is not expected and not a provider default (after create / extract / sync / update) The host itself provisioned fine - this was purely a fixture omission, not a provisioning or marshalling bug. Declaring the value is the established fix rather than dropping the field: dns-resolver-inbound-endpoint models the same nested field, spells it out in its fixture, and passes conformance today. Keeping it in the schema also leaves it settable, which Static allocation needs. --- testdata/bastion-host-update.pkl | 5 +++++ testdata/bastion-host.pkl | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/testdata/bastion-host-update.pkl b/testdata/bastion-host-update.pkl index e1f3b5fb..5552fc45 100644 --- a/testdata/bastion-host-update.pkl +++ b/testdata/bastion-host-update.pkl @@ -91,6 +91,11 @@ forma { name = "IpConf" subnetId = bastionSubnet.res.id publicIpAddressId = pip.res.id + // Spelled out because Azure always populates it. `hasProviderDefault` is + // only honoured on top-level fields, so an omitted nested value comes back + // from ARM and reads as drift in every phase. Same reason + // dns-resolver-inbound-endpoint's fixture declares it. + privateIpAllocationMethod = "Dynamic" } } enableTunneling = true diff --git a/testdata/bastion-host.pkl b/testdata/bastion-host.pkl index 1fb4d195..75b9f7cc 100644 --- a/testdata/bastion-host.pkl +++ b/testdata/bastion-host.pkl @@ -91,6 +91,11 @@ forma { name = "IpConf" subnetId = bastionSubnet.res.id publicIpAddressId = pip.res.id + // Spelled out because Azure always populates it. `hasProviderDefault` is + // only honoured on top-level fields, so an omitted nested value comes back + // from ARM and reads as drift in every phase. Same reason + // dns-resolver-inbound-endpoint's fixture declares it. + privateIpAllocationMethod = "Dynamic" } } enableTunneling = true From 5e22aec302b85d6744b9ced38d5f843018510938 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:22:29 +0200 Subject: [PATCH 5/6] fix(azure): wait for the hub router before deleting a VirtualHub The virtual-hub conformance fixture failed on Destroy. Every earlier phase passed, so the bug was in the delete path. The provider error never reached the workflow log; the Azure activity log for the failing run has it: {"status":"Failed","error":{"code":"ResourceOperationFailure","message":"The resource operation completed with terminal provisioning state 'Failed'.", "details":[{"code":"InvalidOperation","message":"The specified operation 'DeleteVirtualHub' is not supported. Deletion is not supported when RoutingStatus on Hub is 'Provisioning'. Retry when state is not Provisioning."}]}} A virtual hub has two independent states. `provisioningState` covers the ARM resource and is what the create/update LRO reports on; `routingState` covers the hub router behind it, and the service keeps programming that for another ten to twelve minutes after the LRO says Succeeded. ARM refuses DeleteVirtualHub for the whole of that window, and the fixture's destroy landed squarely inside it. Measured on a bare hub in eastus with no gateway attached: create accepted at T+0, provisioningState Succeeded at T+5m15s, routingState Provisioned only at T+16m45s, and a delete issued at that point completed in 14m46s. Delete now reads the hub first and parks the operation when it cannot succeed: routingState Provisioning, provisioningState Deleting (a delete is already in flight, this plugin's or the resource group's), or provisioningState Updating (a write would collide). A parked delete carries the virtual-hub-only lroOpVirtualHubAwaitRouting operation type and no resume token, and Status re-evaluates it on the same rules until ARM will take the DELETE, at which point it hands back a normal lroOpDelete request ID and the operation joins the usual poller path. Delete and the parked branch share one function, so the two cannot drift apart. Delete failures now also carry StatusMessage, so the next provider error of this class shows up without a trip to the activity log. Local conformance run against subscription ddfe7ebf, one clean run, all phases: Resource Create Verify Extract Sync Update Replace Destroy OOB Del Duration WAVE1-NETGW::virtual-hub [+] [+] [+] [+] [+] [~] [+] [+] 68m 50s 1 passed, 0 failed, 0 skipped (68m 50s) The Destroy step took 27m10s of the 30 minute FORMAE_TEST_TIMEOUT the virtual-hub arm of scripts/ci/run-conformance-phase.sh grants it: roughly 11 minutes parked waiting for the router, then 15 minutes of delete. That is a pass with little headroom, and raising that arm to 45 is worth doing separately. --- pkg/resources/virtualhub.go | 165 ++++++++++++++----- pkg/resources/virtualhub_integration_test.go | 100 +++++++++++ schema/pkl/network/virtualhub.pkl | 5 + 3 files changed, 232 insertions(+), 38 deletions(-) diff --git a/pkg/resources/virtualhub.go b/pkg/resources/virtualhub.go index cf133082..97e92ab8 100644 --- a/pkg/resources/virtualhub.go +++ b/pkg/resources/virtualhub.go @@ -21,6 +21,13 @@ import ( const ResourceTypeVirtualHub = "AZURE::Network::VirtualHub" +// lroOpVirtualHubAwaitRouting is a virtual-hub-only operation type for a delete +// that has been accepted by formae but not yet issued to ARM, because the hub +// router is still coming up. It travels in the same lroRequestID envelope as the +// three shared operations but carries no resume token: there is no ARM operation to +// poll until the DELETE is actually sent. See Delete. +const lroOpVirtualHubAwaitRouting = "awaitVirtualHubRouting" + // virtualHubsAPI is the armnetwork surface used here. UpdateTags is deliberately // absent: it cannot change the routing preference or the branch-to-branch flag, so // every update is a re-PUT. @@ -78,6 +85,24 @@ func virtualHubIDParts(resourceID string) (rgName, name string, err error) { return rgName, names["virtualhubs"], nil } +// virtualHubProvisioningState and virtualHubRoutingState read the two states the +// delete path has to reason about. They are separate: provisioningState covers the +// ARM resource, routingState covers the hub router behind it, and the router is +// still being programmed for minutes after the create/update LRO reports Succeeded. +func virtualHubProvisioningState(hub *armnetwork.VirtualHub) armnetwork.ProvisioningState { + if hub.Properties == nil || hub.Properties.ProvisioningState == nil { + return "" + } + return *hub.Properties.ProvisioningState +} + +func virtualHubRoutingState(hub *armnetwork.VirtualHub) armnetwork.RoutingState { + if hub.Properties == nil || hub.Properties.RoutingState == nil { + return "" + } + return *hub.Properties.RoutingState +} + func (r *VirtualHub) buildPropertiesFromResult(hub *armnetwork.VirtualHub, rgName string) map[string]any { props := make(map[string]any) @@ -326,69 +351,119 @@ func (r *VirtualHub) Update(ctx context.Context, request *resource.UpdateRequest }, nil } +// Delete removes the hub, but only once the hub router has finished coming up. ARM +// reports provisioningState Succeeded on the create/update LRO minutes before the +// router is programmed, and refuses the delete for the whole gap: +// +// InvalidOperation: The specified operation 'DeleteVirtualHub' is not supported. +// Deletion is not supported when RoutingStatus on Hub is 'Provisioning'. Retry +// when state is not Provisioning. +// +// That is not a permanent condition, so the delete is parked under +// lroOpVirtualHubAwaitRouting and issued from Status once routingState leaves +// Provisioning, instead of failing the command. func (r *VirtualHub) Delete(ctx context.Context, request *resource.DeleteRequest) (*resource.DeleteResult, error) { rgName, name, err := virtualHubIDParts(request.NativeID) if err != nil { return nil, err } + progress, err := r.deleteWhenRouted(ctx, rgName, name, request.NativeID) + if err != nil { + return nil, err + } + return &resource.DeleteResult{ProgressResult: progress}, nil +} + +// deleteWhenRouted issues the DELETE when the hub will accept it and parks the +// operation when it will not. Shared by Delete and by the awaitRouting branch of +// Status, so a parked delete is re-evaluated on exactly the same rules that parked +// it. +func (r *VirtualHub) deleteWhenRouted(ctx context.Context, rgName, name, nativeID string) (*resource.ProgressResult, error) { + get, err := r.api.Get(ctx, rgName, name, nil) + if err != nil { + if isDeleteSuccessError(err) { + return virtualHubDeleteSucceeded(nativeID), nil + } + return virtualHubDeleteFailed(nativeID, err), nil + } + + switch virtualHubProvisioningState(&get.VirtualHub) { + case armnetwork.ProvisioningStateDeleting: + // A delete is already in flight — this plugin's, or the resource group's. + // Waiting for the hub to disappear is enough; re-issuing it is not. + return virtualHubDeleteParked(nativeID, "the hub is already in provisioningState Deleting") + case armnetwork.ProvisioningStateUpdating: + // ARM rejects a delete that overlaps another write on the hub. + return virtualHubDeleteParked(nativeID, "another operation on the hub is still in provisioningState Updating") + } + if virtualHubRoutingState(&get.VirtualHub) == armnetwork.RoutingStateProvisioning { + return virtualHubDeleteParked(nativeID, "the hub router is still in routingState Provisioning, which ARM refuses to delete over") + } + poller, err := r.api.BeginDelete(ctx, rgName, name, nil) if err != nil { if isDeleteSuccessError(err) { - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusSuccess, - NativeID: request.NativeID, - }, - }, nil + return virtualHubDeleteSucceeded(nativeID), nil } - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusFailure, - NativeID: request.NativeID, - ErrorCode: operationErrorCode(err), - }, - }, nil + return virtualHubDeleteFailed(nativeID, err), nil } if poller.Done() { if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusFailure, - NativeID: request.NativeID, - ErrorCode: operationErrorCode(err), - }, - }, nil + return virtualHubDeleteFailed(nativeID, err), nil } - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusSuccess, - NativeID: request.NativeID, - }, - }, nil + return virtualHubDeleteSucceeded(nativeID), nil } resumeToken, err := poller.ResumeToken() if err != nil { return nil, fmt.Errorf("failed to get resume token: %w", err) } - reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, request.NativeID) + reqIDJSON, err := encodeLROStart(lroOpDelete, resumeToken, nativeID) if err != nil { return nil, err } + return &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: nativeID, + }, nil +} - return &resource.DeleteResult{ - ProgressResult: &resource.ProgressResult{ - Operation: resource.OperationDelete, - OperationStatus: resource.OperationStatusInProgress, - RequestID: reqIDJSON, - NativeID: request.NativeID, - }, +func virtualHubDeleteSucceeded(nativeID string) *resource.ProgressResult { + return &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusSuccess, + NativeID: nativeID, + } +} + +func virtualHubDeleteFailed(nativeID string, err error) *resource.ProgressResult { + return &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusFailure, + NativeID: nativeID, + ErrorCode: operationErrorCode(err), + StatusMessage: err.Error(), + } +} + +// virtualHubDeleteParked reports a delete that has not reached ARM yet. The request +// ID carries no resume token because there is no operation to resume; the next +// Status re-reads the hub and decides again. +func virtualHubDeleteParked(nativeID, reason string) (*resource.ProgressResult, error) { + reqIDJSON, err := encodeLROStart(lroOpVirtualHubAwaitRouting, "", nativeID) + if err != nil { + return nil, err + } + return &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: nativeID, + StatusMessage: fmt.Sprintf("waiting to delete virtual hub: %s", reason), }, nil } @@ -418,6 +493,20 @@ func (r *VirtualHub) Status(ctx context.Context, request *resource.StatusRequest func(token string) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { return resumePoller[armnetwork.VirtualHubsClientDeleteResponse](r.pipeline, token) }, nil) + case lroOpVirtualHubAwaitRouting: + // The delete has not been issued yet: re-read the hub and either issue it now + // or stay parked. deleteWhenRouted hands back the real delete request ID once + // ARM accepts the DELETE, so the operation joins the normal lroOpDelete path + // from the next poll onwards. + rgName, name, err := virtualHubIDParts(reqID.NativeID) + if err != nil { + return nil, err + } + progress, err := r.deleteWhenRouted(ctx, rgName, name, reqID.NativeID) + if err != nil { + return nil, err + } + return &resource.StatusResult{ProgressResult: progress}, nil default: return nil, fmt.Errorf("unknown operation type: %s", reqID.OperationType) } diff --git a/pkg/resources/virtualhub_integration_test.go b/pkg/resources/virtualhub_integration_test.go index aca6240c..59b74db0 100644 --- a/pkg/resources/virtualhub_integration_test.go +++ b/pkg/resources/virtualhub_integration_test.go @@ -259,6 +259,106 @@ func TestVirtualHub_CRUD(t *testing.T) { require.Equal(t, resource.OperationStatusSuccess, got.ProgressResult.OperationStatus) }) + // ARM reports provisioningState Succeeded on the create/update LRO minutes before + // the hub router is programmed, and refuses DeleteVirtualHub for the whole gap. + // The delete has to wait it out rather than fail. + t.Run("Delete_parks_while_the_router_is_provisioning", func(t *testing.T) { + provisioning := hubResult + props := *hubResult.Properties + props.RoutingState = to.Ptr(armnetwork.RoutingStateProvisioning) + provisioning.Properties = &props + settled := fake.getFn + defer func() { fake.getFn = settled }() + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return armnetwork.VirtualHubsClientGetResponse{VirtualHub: provisioning}, nil + } + before := deleteCalls + + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Equal(t, testVirtualHubNativeID, got.ProgressResult.NativeID) + require.Contains(t, got.ProgressResult.StatusMessage, "routingState Provisioning") + // Nothing may reach ARM while the hub would refuse it. + require.Equal(t, before, deleteCalls) + + reqID, err := decodeLROStatus(got.ProgressResult.RequestID) + require.NoError(t, err) + require.Equal(t, lroOpVirtualHubAwaitRouting, reqID.OperationType) + require.Empty(t, reqID.ResumeToken) + require.Equal(t, testVirtualHubNativeID, reqID.NativeID) + + // Status re-reads the hub and stays parked for as long as it keeps saying + // Provisioning. + status, err := prov.Status(context.Background(), &resource.StatusRequest{ + RequestID: got.ProgressResult.RequestID, + NativeID: testVirtualHubNativeID, + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, status.ProgressResult.OperationStatus) + require.Equal(t, before, deleteCalls) + + // Once the router settles the same Status call issues the DELETE and hands + // back a real delete request ID. + fake.getFn = settled + fake.beginDeleteFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientBeginDeleteOptions) (*runtime.Poller[armnetwork.VirtualHubsClientDeleteResponse], error) { + deleteCalls++ + return newPendingPoller[armnetwork.VirtualHubsClientDeleteResponse](), nil + } + status, err = prov.Status(context.Background(), &resource.StatusRequest{ + RequestID: got.ProgressResult.RequestID, + NativeID: testVirtualHubNativeID, + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, status.ProgressResult.OperationStatus) + require.Equal(t, before+1, deleteCalls) + + reqID, err = decodeLROStatus(status.ProgressResult.RequestID) + require.NoError(t, err) + require.Equal(t, lroOpDelete, reqID.OperationType) + require.NotEmpty(t, reqID.ResumeToken) + }) + + // A delete already in flight - this plugin's, or the resource group's - must be + // waited out, not re-issued. + t.Run("Delete_parks_while_a_delete_is_already_in_flight", func(t *testing.T) { + deleting := hubResult + props := *hubResult.Properties + props.ProvisioningState = to.Ptr(armnetwork.ProvisioningStateDeleting) + deleting.Properties = &props + settled := fake.getFn + defer func() { fake.getFn = settled }() + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return armnetwork.VirtualHubsClientGetResponse{VirtualHub: deleting}, nil + } + before := deleteCalls + + got, err := prov.Delete(context.Background(), &resource.DeleteRequest{NativeID: testVirtualHubNativeID}) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusInProgress, got.ProgressResult.OperationStatus) + require.Contains(t, got.ProgressResult.StatusMessage, "provisioningState Deleting") + require.Equal(t, before, deleteCalls) + }) + + // The hub disappearing under a parked delete is the goal, not an error. + t.Run("Delete_parked_then_gone_is_success", func(t *testing.T) { + settled := fake.getFn + defer func() { fake.getFn = settled }() + fake.getFn = func(_ context.Context, _, _ string, _ *armnetwork.VirtualHubsClientGetOptions) (armnetwork.VirtualHubsClientGetResponse, error) { + return armnetwork.VirtualHubsClientGetResponse{}, &azcore.ResponseError{StatusCode: 404} + } + reqIDJSON, err := encodeLROStart(lroOpVirtualHubAwaitRouting, "", testVirtualHubNativeID) + require.NoError(t, err) + + status, err := prov.Status(context.Background(), &resource.StatusRequest{ + RequestID: reqIDJSON, + NativeID: testVirtualHubNativeID, + }) + require.NoError(t, err) + require.Equal(t, resource.OperationStatusSuccess, status.ProgressResult.OperationStatus) + require.Equal(t, resource.OperationDelete, status.ProgressResult.Operation) + }) + t.Run("List_by_resource_group", func(t *testing.T) { got, err := prov.List(context.Background(), &resource.ListRequest{ AdditionalProperties: map[string]string{"resourceGroupName": "rg-1"}, diff --git a/schema/pkl/network/virtualhub.pkl b/schema/pkl/network/virtualhub.pkl index 66cabdb8..e8453647 100644 --- a/schema/pkl/network/virtualhub.pkl +++ b/schema/pkl/network/virtualhub.pkl @@ -37,6 +37,11 @@ open class VirtualHubResolvable extends formae.Resolvable { /// provision (and to delete), well before any gateway is attached. It must be /// created after its `AZURE::Network::VirtualWan` and deleted before it, which /// `virtualWanId` expresses for you when you point it at `wan.res.id`. +/// +/// A destroy can sit for a long time before ARM will even accept it: the hub +/// router keeps programming for tens of minutes after the create or update +/// reports success, and Azure refuses `DeleteVirtualHub` for the whole of that +/// window. The plugin waits the hub out rather than failing, so budget for it. @azure.ResourceHint { type = module.type identifier = "id" From 3f360ae71fe8ec4262b41d204b15f555c60539ed Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:24:18 +0200 Subject: [PATCH 6/6] ci(azure): give virtual-hub a 45 minute per-command conformance budget Splits virtual-hub out of the shared virtual-hub|bastion-host arm and raises its per-command timeout from 30 to 45 minutes. virtual-hub's Destroy measured 27m10s against the 30 minute budget - three minutes of headroom, which will flake. The wait is irreducible: ARM refuses DeleteVirtualHub while routingState is still Provisioning, which runs ~11 min past the point the create LRO reports Succeeded, and the delete itself takes ~15 min. The plugin now parks the delete until ARM will accept it rather than failing, so the time is spent waiting instead of erroring. The go-test cap stays at 90: the full lifecycle measured 69 min. bastion-host keeps 30/90 - its lifecycle measured ~57 min with no single phase close to the limit. --- scripts/ci/run-conformance-phase.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run-conformance-phase.sh b/scripts/ci/run-conformance-phase.sh index d21505e5..18b34a61 100755 --- a/scripts/ci/run-conformance-phase.sh +++ b/scripts/ci/run-conformance-phase.sh @@ -77,9 +77,16 @@ case "$RESOURCE" in # Virtual WAN control plane only - nothing is provisioned behind it. set_timeouts 10 30 ;; - virtual-hub|bastion-host) - # A hub programs routing and a Bastion deploys real instances: ~20-25 min - # per lifecycle, measured. + virtual-hub) + # A hub's Destroy is the long pole, measured at 27m10s: ARM refuses + # DeleteVirtualHub while routingState is still Provisioning, which runs ~11 + # min past the create LRO reporting Succeeded, and the delete itself is ~15 + # min. 30 left only 3 minutes of headroom and would flake; 45 is comfortable. + # The whole lifecycle measured 69 min, so the 90 min go-test cap is fine. + set_timeouts 45 90 + ;; + bastion-host) + # A Bastion deploys real instances; a full lifecycle measured ~57 min. set_timeouts 30 90 ;; vpn-gateway|virtual-network-gateway|virtual-network-gateway-connection)