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/virtualhub.go b/pkg/resources/virtualhub.go new file mode 100644 index 00000000..97e92ab8 --- /dev/null +++ b/pkg/resources/virtualhub.go @@ -0,0 +1,566 @@ +// © 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" + +// 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. +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 +} + +// 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) + + 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 +} + +// 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 virtualHubDeleteSucceeded(nativeID), nil + } + return virtualHubDeleteFailed(nativeID, err), nil + } + + if poller.Done() { + if _, err := poller.Result(ctx); err != nil && !isDeleteSuccessError(err) { + return virtualHubDeleteFailed(nativeID, err), 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, nativeID) + if err != nil { + return nil, err + } + return &resource.ProgressResult{ + Operation: resource.OperationDelete, + OperationStatus: resource.OperationStatusInProgress, + RequestID: reqIDJSON, + NativeID: nativeID, + }, nil +} + +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 +} + +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) + 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) + } +} + +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..59b74db0 --- /dev/null +++ b/pkg/resources/virtualhub_integration_test.go @@ -0,0 +1,384 @@ +// © 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) + }) + + // 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"}, + }) + 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/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/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/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/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/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/virtualhub.pkl b/schema/pkl/network/virtualhub.pkl new file mode 100644 index 00000000..e8453647 --- /dev/null +++ b/schema/pkl/network/virtualhub.pkl @@ -0,0 +1,96 @@ +/* + * © 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`. +/// +/// 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" + 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/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/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/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/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/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) diff --git a/testdata/bastion-host-update.pkl b/testdata/bastion-host-update.pkl new file mode 100644 index 00000000..5552fc45 --- /dev/null +++ b/testdata/bastion-host-update.pkl @@ -0,0 +1,111 @@ +/* + * © 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 + // 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 + 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..75b9f7cc --- /dev/null +++ b/testdata/bastion-host.pkl @@ -0,0 +1,111 @@ +/* + * © 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 + // 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 + enableIpConnect = true + enableFileCopy = false + enableShareableLink = false + enableKerberos = false + disableCopyPaste = false + tags { + new azure.Tag { key = "environment"; value = "conformance-test" } + } + } +} 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-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" } + } + } +} 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-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" } + } + } +} 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" } + } + } +}