From 0d3273fc309c3db3e9afcf1203a0d975daba2297 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:50:54 +0300 Subject: [PATCH 01/10] feat(azure): add Azure integration backend Signed-off-by: Tamiru Alemnew --- go.mod | 10 + go.sum | 20 +- pkg/integrations/azure/README.md | 134 ++++ pkg/integrations/azure/actions.go | 491 ++++++++++++++ pkg/integrations/azure/actions_test.go | 338 ++++++++++ pkg/integrations/azure/azure_test.go | 265 ++++++++ pkg/integrations/azure/component_create_vm.go | 506 +++++++++++++++ pkg/integrations/azure/events.go | 188 ++++++ .../examples/example_data_on_vm_created.json | 16 + .../examples/example_output_create_vm.json | 15 + pkg/integrations/azure/integration.go | 256 ++++++++ pkg/integrations/azure/provider.go | 180 ++++++ pkg/integrations/azure/provider_test.go | 208 ++++++ pkg/integrations/azure/resources.go | 280 ++++++++ .../azure/trigger_on_vm_created.go | 283 +++++++++ .../azure/trigger_vm_created_test.go | 600 ++++++++++++++++++ pkg/integrations/azure/webhook_events.go | 162 +++++ pkg/integrations/azure/webhook_events_test.go | 384 +++++++++++ pkg/integrations/azure/webhook_handler.go | 107 ++++ 19 files changed, 4439 insertions(+), 4 deletions(-) create mode 100644 pkg/integrations/azure/README.md create mode 100644 pkg/integrations/azure/actions.go create mode 100644 pkg/integrations/azure/actions_test.go create mode 100644 pkg/integrations/azure/azure_test.go create mode 100644 pkg/integrations/azure/component_create_vm.go create mode 100644 pkg/integrations/azure/events.go create mode 100644 pkg/integrations/azure/examples/example_data_on_vm_created.json create mode 100644 pkg/integrations/azure/examples/example_output_create_vm.json create mode 100644 pkg/integrations/azure/integration.go create mode 100644 pkg/integrations/azure/provider.go create mode 100644 pkg/integrations/azure/provider_test.go create mode 100644 pkg/integrations/azure/resources.go create mode 100644 pkg/integrations/azure/trigger_on_vm_created.go create mode 100644 pkg/integrations/azure/trigger_vm_created_test.go create mode 100644 pkg/integrations/azure/webhook_events.go create mode 100644 pkg/integrations/azure/webhook_events_test.go create mode 100644 pkg/integrations/azure/webhook_handler.go diff --git a/go.mod b/go.mod index 044290866c..35041d43a7 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,11 @@ module github.com/superplanehq/superplane go 1.25 require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5 v5.2.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/casbin/casbin/v2 v2.134.0 @@ -43,11 +48,16 @@ require ( ) require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect diff --git a/go.sum b/go.sum index a52c8806c8..f2c43579b7 100644 --- a/go.sum +++ b/go.sum @@ -52,21 +52,33 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 h1:GJHeeA2N7xrG3q30L2UXDyuWRzDM900/65j70wcM4Ww= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0 h1:zDeQI/PaWztI2tcrGO/9RIMey9NvqYbnyttf/0P3QWM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0/go.mod h1:zflC9v4VfViJrSvcvplqws/yGXVbUEMZi/iHpZdSPWA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5 v5.2.0 h1:qBlqTo40ARdI7Pmq+enBiTnejZk2BF+PHgktgG8k3r8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5 v5.2.0/go.mod h1:UmyOatRyQodVpp55Jr5WJmnkmVW4wKfo85uHFmMEjfM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= diff --git a/pkg/integrations/azure/README.md b/pkg/integrations/azure/README.md new file mode 100644 index 0000000000..91e4df8c40 --- /dev/null +++ b/pkg/integrations/azure/README.md @@ -0,0 +1,134 @@ +# Azure Integration for SuperPlane + +This integration connects SuperPlane with Microsoft Azure to automate VM-centric workflows. +It currently provides: + +- `azure.onVirtualMachineCreated` trigger (Event Grid driven) +- `azure.createVirtualMachine` action (VM provisioning with dynamic networking UX) + +Website: https://azure.microsoft.com/ + +## Authentication + +The integration follows a dual-mode authentication strategy. + +### Production Mode (Workload Identity Federation / OIDC) + +Production uses Azure Workload Identity Federation with no client secret storage. + +- Provider reads a signed OIDC assertion from `AZURE_FEDERATED_TOKEN_FILE` +- Provider authenticates using `azidentity.NewClientAssertionCredential` +- Azure AD exchanges assertion for Azure access token + +This is the secure default for hosted and production environments. + +### Local Development Mode (`az login`) + +For local development, setup and iteration can use Azure CLI authentication (`az login`), while SuperPlane keeps the same federated flow at runtime. + +Typical local flow: + +1. Run `az login` (for portal/CLI setup and quick validation) +2. Set a reachable public issuer URL for your local app (tunnel) +3. Regenerate/update the OIDC token file referenced by `AZURE_FEDERATED_TOKEN_FILE` + +This provides a seamless developer workflow without switching production auth architecture. + +## Components + +## Trigger: `azure.onVirtualMachineCreated` + +Starts workflows when Azure emits `Microsoft.Resources.ResourceWriteSuccess` for VM resources via Event Grid. + +Key behavior: + +- Handles Event Grid subscription validation handshake +- Filters to VM resources (`Microsoft.Compute/virtualMachines`) +- Emits event payload when provisioning state is `Succeeded` + +## Action: `azure.createVirtualMachine` + +Creates a new Azure VM using Azure SDK long-running operations. + +### UX and Networking Model (Important) + +Users do **not** need to manually create a NIC. + +- User selects: Resource Group -> Virtual Network -> Subnet (cascading dropdowns) +- Backend automatically performs networking plumbing: + - resolves subnet + - creates/attaches NIC in background + - optionally creates/attaches Public IP + +An existing `networkInterfaceId` is still accepted as an optional advanced override. + +### Supported VM Configuration + +- Basics: Resource Group, Region, VM Name, Image, Size +- Networking: VNet + Subnet cascading selection +- Disks: OS Disk Type (`Standard_LRS`, `StandardSSD_LRS`, `Premium_LRS`) +- Public IP: optional Standard SKU Public IP +- Advanced: Custom Data / cloud-init (base64 encoded before Azure API call) + +### Action Output + +The action returns: + +- `id` (VM resource ID) +- `privateIp` +- `publicIp` (empty when no Public IP is attached) +- plus operational metadata (`name`, `provisioningState`, `location`, `size`, `adminUsername`) + +## Prerequisites + +Before using the integration: + +1. Create an Azure App Registration +2. Configure Federated Credential(s) for SuperPlane issuer + subject + audience +3. Grant RBAC permissions + +### Required Azure Resources + +- Azure Subscription +- Resource Group(s) where VMs will be created +- Existing VNet/Subnet (for implicit NIC path) +- Azure App Registration (Microsoft Entra ID) +- Federated Credential on that app registration + +### Recommended RBAC Roles + +At minimum, assign permissions at Resource Group (or Subscription) scope. + +- `Contributor` (simplest recommended role for full VM provisioning path) + +Or use least-privilege split roles: + +- `Virtual Machine Contributor` +- `Network Contributor` + +If using Event Grid provisioning externally, include corresponding Event Grid role(s). + +## Troubleshooting + +### `failed to read OIDC token ... AZURE_FEDERATED_TOKEN_FILE ... no such file or directory` + +- Ensure env var is set in the running app container/process +- Ensure token file exists and is readable at that path +- In local dev, run `az login` and refresh your local federation setup/token generation flow + +### `AADSTS90061` / `AADSTS50166` External OIDC endpoint failed + +- Your public issuer URL is stale/unreachable +- Refresh tunnel URL +- Update app `BASE_URL`/`WEBHOOKS_BASE_URL` +- Update Azure federated credential issuer to match exactly +- Regenerate token file and restart app + +## Development Validation + +Run Azure integration tests: + +```bash +go test ./pkg/integrations/azure/... +``` + diff --git a/pkg/integrations/azure/actions.go b/pkg/integrations/azure/actions.go new file mode 100644 index 0000000000..6c208e95f3 --- /dev/null +++ b/pkg/integrations/azure/actions.go @@ -0,0 +1,491 @@ +package azure + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5" + "github.com/sirupsen/logrus" +) + +// CreateVMRequest contains parameters for Azure VM creation. +type CreateVMRequest struct { + ResourceGroup string + VMName string + Location string + Size string + AdminUsername string + AdminPassword string + NetworkInterfaceID string + VirtualNetworkName string + SubnetName string + PublicIPName string + ImagePublisher string + ImageOffer string + ImageSku string + ImageVersion string + OSDiskType string + CustomData string +} + +// CreateVMResponse contains created VM details. +type CreateVMResponse struct { + VMID string + Name string + ProvisioningState string + Location string + Size string + PublicIP string + PrivateIP string + AdminUsername string +} + +// CreateVM creates a VM and waits for provisioning completion. +func CreateVM(ctx context.Context, provider *AzureProvider, req CreateVMRequest, logger *logrus.Entry) (*CreateVMResponse, error) { + if err := validateCreateVMRequest(req); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + logger.Infof("Starting VM creation: name=%s, location=%s, size=%s", + req.VMName, req.Location, req.Size) + + networkInterfaceID, err := ensureNetworkInterface(ctx, provider, req, logger) + if err != nil { + return nil, fmt.Errorf("failed to ensure network interface: %w", err) + } + + osDiskType := armcompute.StorageAccountTypes(req.OSDiskType) + if osDiskType == "" { + osDiskType = armcompute.StorageAccountTypesStandardSSDLRS + } + + var customData *string + if strings.TrimSpace(req.CustomData) != "" { + encoded := base64.StdEncoding.EncodeToString([]byte(req.CustomData)) + customData = to.Ptr(encoded) + } + + vmParameters := armcompute.VirtualMachine{ + Location: to.Ptr(req.Location), + Properties: &armcompute.VirtualMachineProperties{ + HardwareProfile: &armcompute.HardwareProfile{ + VMSize: to.Ptr(armcompute.VirtualMachineSizeTypes(req.Size)), + }, + StorageProfile: &armcompute.StorageProfile{ + ImageReference: &armcompute.ImageReference{ + Publisher: to.Ptr(req.ImagePublisher), + Offer: to.Ptr(req.ImageOffer), + SKU: to.Ptr(req.ImageSku), + Version: to.Ptr(req.ImageVersion), + }, + OSDisk: &armcompute.OSDisk{ + CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage), + ManagedDisk: &armcompute.ManagedDiskParameters{ + StorageAccountType: to.Ptr(osDiskType), + }, + }, + }, + OSProfile: &armcompute.OSProfile{ + ComputerName: to.Ptr(req.VMName), + AdminUsername: to.Ptr(req.AdminUsername), + AdminPassword: to.Ptr(req.AdminPassword), + CustomData: customData, + }, + NetworkProfile: &armcompute.NetworkProfile{ + NetworkInterfaces: []*armcompute.NetworkInterfaceReference{ + { + ID: to.Ptr(networkInterfaceID), + Properties: &armcompute.NetworkInterfaceReferenceProperties{ + Primary: to.Ptr(true), + }, + }, + }, + }, + }, + } + + logger.Infof("Initiating VM creation with Azure Compute API") + + client := provider.GetComputeClient() + poller, err := client.BeginCreateOrUpdate( + ctx, + req.ResourceGroup, + req.VMName, + vmParameters, + nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to begin VM creation: %w", err) + } + + logger.Infof("VM creation initiated, polling until completion...") + + result, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return nil, fmt.Errorf("VM creation failed during provisioning: %w", err) + } + + vm := result.VirtualMachine + if vm.ID == nil || vm.Name == nil { + return nil, fmt.Errorf("invalid VM response: missing ID or Name") + } + + provisioningState := "Unknown" + if vm.Properties != nil && vm.Properties.ProvisioningState != nil { + provisioningState = *vm.Properties.ProvisioningState + } + + location := "" + if vm.Location != nil { + location = *vm.Location + } + + size := "" + if vm.Properties != nil && vm.Properties.HardwareProfile != nil && vm.Properties.HardwareProfile.VMSize != nil { + size = string(*vm.Properties.HardwareProfile.VMSize) + } + + privateIP, publicIP, err := getPrimaryNICIPAddresses(ctx, provider, vm) + if err != nil { + logger.WithError(err).Warn("VM created but failed to resolve NIC IP addresses") + } + + response := &CreateVMResponse{ + VMID: *vm.ID, + Name: *vm.Name, + ProvisioningState: provisioningState, + Location: location, + Size: size, + PublicIP: publicIP, + PrivateIP: privateIP, + AdminUsername: req.AdminUsername, + } + + logger.Infof("VM created successfully: id=%s, state=%s", response.VMID, response.ProvisioningState) + + return response, nil +} + +func ensureNetworkInterface(ctx context.Context, provider *AzureProvider, req CreateVMRequest, logger *logrus.Entry) (string, error) { + if strings.TrimSpace(req.NetworkInterfaceID) != "" { + return req.NetworkInterfaceID, nil + } + virtualNetworkName := azureResourceName(req.VirtualNetworkName) + subnetName := azureResourceName(req.SubnetName) + if virtualNetworkName == "" || subnetName == "" { + return "", fmt.Errorf("either networkInterfaceId or (virtualNetworkName and subnetName) must be provided") + } + + subnetResp, err := provider.GetSubnetsClient().Get(ctx, req.ResourceGroup, virtualNetworkName, subnetName, nil) + if err != nil { + return "", fmt.Errorf("failed to resolve subnet %s in virtual network %s: %w", subnetName, virtualNetworkName, err) + } + if subnetResp.Subnet.ID == nil { + return "", fmt.Errorf("resolved subnet %s in virtual network %s has no resource ID", subnetName, virtualNetworkName) + } + + var publicIPAddress *armnetwork.PublicIPAddress + if strings.TrimSpace(req.PublicIPName) != "" { + publicIPID, err := ensurePublicIP(ctx, provider, req.ResourceGroup, req.Location, req.PublicIPName) + if err != nil { + return "", err + } + publicIPAddress = &armnetwork.PublicIPAddress{ + ID: to.Ptr(publicIPID), + } + } + + nicName := fmt.Sprintf("%s-nic", req.VMName) + nicParams := armnetwork.Interface{ + Location: to.Ptr(req.Location), + Properties: &armnetwork.InterfacePropertiesFormat{ + IPConfigurations: []*armnetwork.InterfaceIPConfiguration{ + { + Name: to.Ptr("ipconfig1"), + Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{ + PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethod(IPAllocationMethodDynamic)), + Subnet: &armnetwork.Subnet{ID: subnetResp.Subnet.ID}, + PublicIPAddress: publicIPAddress, + }, + }, + }, + }, + } + + logger.Infof("Creating network interface %s using subnet %s/%s", nicName, virtualNetworkName, subnetName) + poller, err := provider.GetNetworkInterfacesClient().BeginCreateOrUpdate(ctx, req.ResourceGroup, nicName, nicParams, nil) + if err != nil { + return "", fmt.Errorf("failed to create network interface %s: %w", nicName, err) + } + + nicResult, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return "", fmt.Errorf("failed while creating network interface %s: %w", nicName, err) + } + if nicResult.Interface.ID == nil { + return "", fmt.Errorf("created network interface %s has no resource ID", nicName) + } + + return *nicResult.Interface.ID, nil +} + +func getPrimaryNICIPAddresses(ctx context.Context, provider *AzureProvider, vm armcompute.VirtualMachine) (string, string, error) { + if vm.Properties == nil || vm.Properties.NetworkProfile == nil || len(vm.Properties.NetworkProfile.NetworkInterfaces) == 0 { + return "", "", nil + } + + primaryNIC := vm.Properties.NetworkProfile.NetworkInterfaces[0] + if primaryNIC == nil || primaryNIC.ID == nil || strings.TrimSpace(*primaryNIC.ID) == "" { + return "", "", nil + } + + nicResourceGroup, nicName, err := resourceGroupAndNameFromResourceID( + *primaryNIC.ID, + fmt.Sprintf("%s/%s", ResourceProviderNetwork, "networkInterfaces"), + ) + if err != nil { + return "", "", fmt.Errorf("failed to parse primary NIC ID: %w", err) + } + + nicResp, err := provider.GetNetworkInterfacesClient().Get(ctx, nicResourceGroup, nicName, nil) + if err != nil { + return "", "", fmt.Errorf("failed to get primary network interface %s: %w", nicName, err) + } + + nic := nicResp.Interface + if nic.Properties == nil || len(nic.Properties.IPConfigurations) == 0 || nic.Properties.IPConfigurations[0] == nil { + return "", "", nil + } + + ipConfig := nic.Properties.IPConfigurations[0] + if ipConfig.Properties == nil { + return "", "", nil + } + + privateIP := "" + if ipConfig.Properties.PrivateIPAddress != nil { + privateIP = *ipConfig.Properties.PrivateIPAddress + } + + publicIP := "" + if ipConfig.Properties.PublicIPAddress != nil && + ipConfig.Properties.PublicIPAddress.ID != nil && + strings.TrimSpace(*ipConfig.Properties.PublicIPAddress.ID) != "" { + publicIPResourceGroup, publicIPName, err := resourceGroupAndNameFromResourceID( + *ipConfig.Properties.PublicIPAddress.ID, + fmt.Sprintf("%s/%s", ResourceProviderNetwork, "publicIPAddresses"), + ) + if err != nil { + return privateIP, "", fmt.Errorf("failed to parse public IP resource ID: %w", err) + } + + publicIPResp, err := provider.GetPublicIPClient().Get(ctx, publicIPResourceGroup, publicIPName, nil) + if err != nil { + return privateIP, "", fmt.Errorf("failed to get public IP resource %s: %w", publicIPName, err) + } + + if publicIPResp.PublicIPAddress.Properties != nil && publicIPResp.PublicIPAddress.Properties.IPAddress != nil { + publicIP = *publicIPResp.PublicIPAddress.Properties.IPAddress + } + } + + return privateIP, publicIP, nil +} + +func resourceGroupAndNameFromResourceID(resourceID, expectedResourceType string) (string, string, error) { + trimmedID := strings.TrimSpace(resourceID) + if trimmedID == "" { + return "", "", fmt.Errorf("resource ID is empty") + } + + normalizedID := strings.Trim(trimmedID, "/") + parts := strings.Split(normalizedID, "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid resource ID: %s", resourceID) + } + + if expectedResourceType != "" { + expectedPathToken := "/" + strings.ToLower(expectedResourceType) + "/" + if !strings.Contains(strings.ToLower(trimmedID), expectedPathToken) { + return "", "", fmt.Errorf("resource ID %q is not a %s resource", resourceID, expectedResourceType) + } + } + + resourceGroup := "" + for i := 0; i < len(parts)-1; i++ { + if strings.EqualFold(parts[i], "resourceGroups") { + resourceGroup = parts[i+1] + break + } + } + if resourceGroup == "" { + return "", "", fmt.Errorf("resource group segment not found in %q", resourceID) + } + + resourceName := parts[len(parts)-1] + if resourceName == "" { + return "", "", fmt.Errorf("resource name segment not found in %q", resourceID) + } + + return resourceGroup, resourceName, nil +} + +func ensurePublicIP(ctx context.Context, provider *AzureProvider, resourceGroup, location, publicIPName string) (string, error) { + publicIPResp, err := provider.GetPublicIPClient().Get(ctx, resourceGroup, publicIPName, nil) + if err == nil && publicIPResp.PublicIPAddress.ID != nil { + return *publicIPResp.PublicIPAddress.ID, nil + } + if err != nil && !isAzureNotFound(err) { + return "", fmt.Errorf("failed to get public IP %s: %w", publicIPName, err) + } + + params := armnetwork.PublicIPAddress{ + Location: to.Ptr(location), + SKU: &armnetwork.PublicIPAddressSKU{ + Name: to.Ptr(armnetwork.PublicIPAddressSKUName(SKUStandard)), + }, + Properties: &armnetwork.PublicIPAddressPropertiesFormat{ + PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethod(IPAllocationMethodStatic)), + PublicIPAddressVersion: to.Ptr(armnetwork.IPVersionIPv4), + }, + } + + poller, err := provider.GetPublicIPClient().BeginCreateOrUpdate(ctx, resourceGroup, publicIPName, params, nil) + if err != nil { + return "", fmt.Errorf("failed to create public IP %s: %w", publicIPName, err) + } + publicIPResult, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return "", fmt.Errorf("failed while creating public IP %s: %w", publicIPName, err) + } + if publicIPResult.PublicIPAddress.ID == nil { + return "", fmt.Errorf("created public IP has no resource ID") + } + + return *publicIPResult.PublicIPAddress.ID, nil +} + +func isAzureNotFound(err error) bool { + var responseErr *azcore.ResponseError + if errors.As(err, &responseErr) { + return strings.EqualFold(responseErr.ErrorCode, "NotFound") || strings.EqualFold(responseErr.ErrorCode, "ResourceNotFound") + } + normalized := strings.ToLower(err.Error()) + return strings.Contains(normalized, "notfound") || strings.Contains(normalized, "not found") +} + +// validateCreateVMRequest validates required VM request fields. +func validateCreateVMRequest(req CreateVMRequest) error { + if req.ResourceGroup == "" { + return fmt.Errorf("resource group is required") + } + + if req.VMName == "" { + return fmt.Errorf("VM name is required") + } + + if req.Location == "" { + return fmt.Errorf("location is required") + } + + if req.Size == "" { + return fmt.Errorf("VM size is required") + } + + if req.AdminUsername == "" { + return fmt.Errorf("admin username is required") + } + + if req.AdminPassword == "" { + return fmt.Errorf("admin password is required") + } + + if strings.TrimSpace(req.NetworkInterfaceID) == "" && + (strings.TrimSpace(req.VirtualNetworkName) == "" || strings.TrimSpace(req.SubnetName) == "") { + return fmt.Errorf("either networkInterfaceId or (virtualNetworkName and subnetName) is required") + } + + if req.ImagePublisher == "" { + return fmt.Errorf("image publisher is required") + } + + if req.ImageOffer == "" { + return fmt.Errorf("image offer is required") + } + + if req.ImageSku == "" { + return fmt.Errorf("image SKU is required") + } + + if req.ImageVersion == "" { + return fmt.Errorf("image version is required") + } + + if req.OSDiskType != "" && + req.OSDiskType != string(armcompute.StorageAccountTypesStandardLRS) && + req.OSDiskType != string(armcompute.StorageAccountTypesStandardSSDLRS) && + req.OSDiskType != string(armcompute.StorageAccountTypesPremiumLRS) { + return fmt.Errorf("unsupported OS disk type: %s", req.OSDiskType) + } + + return nil +} + +const ( + VMSizeStandardB1s = "Standard_B1s" + VMSizeStandardB1ms = "Standard_B1ms" + VMSizeStandardB2s = "Standard_B2s" + VMSizeStandardD2sV3 = "Standard_D2s_v3" + VMSizeStandardD4sV3 = "Standard_D4s_v3" + VMSizeStandardD8sV3 = "Standard_D8s_v3" + VMSizeStandardF2sV2 = "Standard_F2s_v2" + VMSizeStandardF4sV2 = "Standard_F4s_v2" + VMSizeStandardF8sV2 = "Standard_F8s_v2" + VMSizeStandardE2sV3 = "Standard_E2s_v3" + VMSizeStandardE4sV3 = "Standard_E4s_v3" + VMSizeStandardE8sV3 = "Standard_E8s_v3" +) + +var ( + ImageUbuntu1804LTS = ImageReference{ + Publisher: "Canonical", + Offer: "UbuntuServer", + SKU: "18.04-LTS", + Version: "latest", + } + + ImageUbuntu2004LTS = ImageReference{ + Publisher: "Canonical", + Offer: "0001-com-ubuntu-server-focal", + SKU: "20_04-lts-gen2", + Version: "latest", + } + + ImageWindowsServer2019 = ImageReference{ + Publisher: "MicrosoftWindowsServer", + Offer: "WindowsServer", + SKU: "2019-Datacenter", + Version: "latest", + } + + ImageWindowsServer2022 = ImageReference{ + Publisher: "MicrosoftWindowsServer", + Offer: "WindowsServer", + SKU: "2022-datacenter-azure-edition", + Version: "latest", + } +) + +// ImageReference defines an image publisher/offer/sku/version tuple. +type ImageReference struct { + Publisher string + Offer string + SKU string + Version string +} diff --git a/pkg/integrations/azure/actions_test.go b/pkg/integrations/azure/actions_test.go new file mode 100644 index 0000000000..5e0573494b --- /dev/null +++ b/pkg/integrations/azure/actions_test.go @@ -0,0 +1,338 @@ +package azure + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateCreateVMRequest(t *testing.T) { + validRequest := CreateVMRequest{ + ResourceGroup: "test-rg", + VMName: "test-vm", + Location: "eastus", + Size: "Standard_B1s", + AdminUsername: "azureuser", + AdminPassword: "P@ssw0rd123!", + NetworkInterfaceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic", + ImagePublisher: "Canonical", + ImageOffer: "UbuntuServer", + ImageSku: "18.04-LTS", + ImageVersion: "latest", + } + + tests := []struct { + name string + modifyReq func(*CreateVMRequest) + expectedErr string + }{ + { + name: "valid request", + modifyReq: func(r *CreateVMRequest) {}, + expectedErr: "", + }, + { + name: "missing resource group", + modifyReq: func(r *CreateVMRequest) { + r.ResourceGroup = "" + }, + expectedErr: "resource group is required", + }, + { + name: "missing VM name", + modifyReq: func(r *CreateVMRequest) { + r.VMName = "" + }, + expectedErr: "VM name is required", + }, + { + name: "missing location", + modifyReq: func(r *CreateVMRequest) { + r.Location = "" + }, + expectedErr: "location is required", + }, + { + name: "missing VM size", + modifyReq: func(r *CreateVMRequest) { + r.Size = "" + }, + expectedErr: "VM size is required", + }, + { + name: "missing admin username", + modifyReq: func(r *CreateVMRequest) { + r.AdminUsername = "" + }, + expectedErr: "admin username is required", + }, + { + name: "missing admin password", + modifyReq: func(r *CreateVMRequest) { + r.AdminPassword = "" + }, + expectedErr: "admin password is required", + }, + { + name: "missing network interface ID", + modifyReq: func(r *CreateVMRequest) { + r.NetworkInterfaceID = "" + }, + expectedErr: "either networkInterfaceId or (virtualNetworkName and subnetName) is required", + }, + { + name: "valid request with implicit NIC fields", + modifyReq: func(r *CreateVMRequest) { + r.NetworkInterfaceID = "" + r.VirtualNetworkName = "my-vnet" + r.SubnetName = "my-subnet" + }, + expectedErr: "", + }, + { + name: "missing image publisher", + modifyReq: func(r *CreateVMRequest) { + r.ImagePublisher = "" + }, + expectedErr: "image publisher is required", + }, + { + name: "missing image offer", + modifyReq: func(r *CreateVMRequest) { + r.ImageOffer = "" + }, + expectedErr: "image offer is required", + }, + { + name: "missing image SKU", + modifyReq: func(r *CreateVMRequest) { + r.ImageSku = "" + }, + expectedErr: "image SKU is required", + }, + { + name: "missing image version", + modifyReq: func(r *CreateVMRequest) { + r.ImageVersion = "" + }, + expectedErr: "image version is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := validRequest + tt.modifyReq(&req) + + err := validateCreateVMRequest(req) + + if tt.expectedErr == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + } + }) + } +} + +func TestCreateVMRequest_AllFields(t *testing.T) { + // Test that all fields can be set and accessed + req := CreateVMRequest{ + ResourceGroup: "my-rg", + VMName: "my-vm", + Location: "westus2", + Size: "Standard_D2s_v3", + AdminUsername: "adminuser", + AdminPassword: "SecureP@ss123", + NetworkInterfaceID: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Network/networkInterfaces/nic1", + ImagePublisher: "Canonical", + ImageOffer: "UbuntuServer", + ImageSku: "20.04-LTS", + ImageVersion: "latest", + } + + assert.Equal(t, "my-rg", req.ResourceGroup) + assert.Equal(t, "my-vm", req.VMName) + assert.Equal(t, "westus2", req.Location) + assert.Equal(t, "Standard_D2s_v3", req.Size) + assert.Equal(t, "adminuser", req.AdminUsername) + assert.Equal(t, "SecureP@ss123", req.AdminPassword) + assert.Contains(t, req.NetworkInterfaceID, "networkInterfaces/nic1") + assert.Equal(t, "Canonical", req.ImagePublisher) + assert.Equal(t, "UbuntuServer", req.ImageOffer) + assert.Equal(t, "20.04-LTS", req.ImageSku) + assert.Equal(t, "latest", req.ImageVersion) +} + +func TestCreateVMResponse_AllFields(t *testing.T) { + // Test that all response fields can be set and accessed + resp := CreateVMResponse{ + VMID: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1", + Name: "vm1", + ProvisioningState: "Succeeded", + Location: "eastus", + Size: "Standard_B1s", + PublicIP: "20.10.10.10", + PrivateIP: "10.0.0.4", + AdminUsername: "azureuser", + } + + assert.Contains(t, resp.VMID, "virtualMachines/vm1") + assert.Equal(t, "vm1", resp.Name) + assert.Equal(t, "Succeeded", resp.ProvisioningState) + assert.Equal(t, "eastus", resp.Location) + assert.Equal(t, "Standard_B1s", resp.Size) + assert.Equal(t, "20.10.10.10", resp.PublicIP) + assert.Equal(t, "10.0.0.4", resp.PrivateIP) + assert.Equal(t, "azureuser", resp.AdminUsername) +} + +func TestVMSizeConstants(t *testing.T) { + // Verify VM size constants are defined correctly + assert.Equal(t, "Standard_B1s", VMSizeStandardB1s) + assert.Equal(t, "Standard_B1ms", VMSizeStandardB1ms) + assert.Equal(t, "Standard_B2s", VMSizeStandardB2s) + assert.Equal(t, "Standard_D2s_v3", VMSizeStandardD2sV3) + assert.Equal(t, "Standard_D4s_v3", VMSizeStandardD4sV3) + assert.Equal(t, "Standard_D8s_v3", VMSizeStandardD8sV3) + assert.Equal(t, "Standard_F2s_v2", VMSizeStandardF2sV2) + assert.Equal(t, "Standard_F4s_v2", VMSizeStandardF4sV2) + assert.Equal(t, "Standard_F8s_v2", VMSizeStandardF8sV2) + assert.Equal(t, "Standard_E2s_v3", VMSizeStandardE2sV3) + assert.Equal(t, "Standard_E4s_v3", VMSizeStandardE4sV3) + assert.Equal(t, "Standard_E8s_v3", VMSizeStandardE8sV3) +} + +func TestImageReferenceConstants(t *testing.T) { + // Test Ubuntu 18.04 LTS + assert.Equal(t, "Canonical", ImageUbuntu1804LTS.Publisher) + assert.Equal(t, "UbuntuServer", ImageUbuntu1804LTS.Offer) + assert.Equal(t, "18.04-LTS", ImageUbuntu1804LTS.SKU) + assert.Equal(t, "latest", ImageUbuntu1804LTS.Version) + + // Test Ubuntu 20.04 LTS + assert.Equal(t, "Canonical", ImageUbuntu2004LTS.Publisher) + assert.Equal(t, "0001-com-ubuntu-server-focal", ImageUbuntu2004LTS.Offer) + assert.Equal(t, "20_04-lts-gen2", ImageUbuntu2004LTS.SKU) + assert.Equal(t, "latest", ImageUbuntu2004LTS.Version) + + // Test Windows Server 2019 + assert.Equal(t, "MicrosoftWindowsServer", ImageWindowsServer2019.Publisher) + assert.Equal(t, "WindowsServer", ImageWindowsServer2019.Offer) + assert.Equal(t, "2019-Datacenter", ImageWindowsServer2019.SKU) + assert.Equal(t, "latest", ImageWindowsServer2019.Version) + + // Test Windows Server 2022 + assert.Equal(t, "MicrosoftWindowsServer", ImageWindowsServer2022.Publisher) + assert.Equal(t, "WindowsServer", ImageWindowsServer2022.Offer) + assert.Equal(t, "2022-datacenter-azure-edition", ImageWindowsServer2022.SKU) + assert.Equal(t, "latest", ImageWindowsServer2022.Version) +} + +func TestImageReference_StructFields(t *testing.T) { + // Test that ImageReference struct works correctly + img := ImageReference{ + Publisher: "TestPublisher", + Offer: "TestOffer", + SKU: "TestSKU", + Version: "1.0.0", + } + + assert.Equal(t, "TestPublisher", img.Publisher) + assert.Equal(t, "TestOffer", img.Offer) + assert.Equal(t, "TestSKU", img.SKU) + assert.Equal(t, "1.0.0", img.Version) +} + +func TestCreateVMRequest_WithCommonImages(t *testing.T) { + // Test that CreateVMRequest works with predefined image references + tests := []struct { + name string + image ImageReference + }{ + { + name: "Ubuntu 18.04 LTS", + image: ImageUbuntu1804LTS, + }, + { + name: "Ubuntu 20.04 LTS", + image: ImageUbuntu2004LTS, + }, + { + name: "Windows Server 2019", + image: ImageWindowsServer2019, + }, + { + name: "Windows Server 2022", + image: ImageWindowsServer2022, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := CreateVMRequest{ + ResourceGroup: "test-rg", + VMName: "test-vm", + Location: "eastus", + Size: VMSizeStandardB1s, + AdminUsername: "azureuser", + AdminPassword: "P@ssw0rd123!", + NetworkInterfaceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic", + ImagePublisher: tt.image.Publisher, + ImageOffer: tt.image.Offer, + ImageSku: tt.image.SKU, + ImageVersion: tt.image.Version, + } + + err := validateCreateVMRequest(req) + assert.NoError(t, err) + }) + } +} + +func TestCreateVMRequest_WithCommonVMSizes(t *testing.T) { + // Test that CreateVMRequest works with predefined VM sizes + vmSizes := []string{ + VMSizeStandardB1s, + VMSizeStandardB1ms, + VMSizeStandardB2s, + VMSizeStandardD2sV3, + VMSizeStandardD4sV3, + VMSizeStandardD8sV3, + VMSizeStandardF2sV2, + VMSizeStandardF4sV2, + VMSizeStandardF8sV2, + VMSizeStandardE2sV3, + VMSizeStandardE4sV3, + VMSizeStandardE8sV3, + } + + for _, size := range vmSizes { + t.Run(size, func(t *testing.T) { + req := CreateVMRequest{ + ResourceGroup: "test-rg", + VMName: "test-vm", + Location: "eastus", + Size: size, + AdminUsername: "azureuser", + AdminPassword: "P@ssw0rd123!", + NetworkInterfaceID: "/subscriptions/test/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic", + ImagePublisher: "Canonical", + ImageOffer: "UbuntuServer", + ImageSku: "18.04-LTS", + ImageVersion: "latest", + } + + err := validateCreateVMRequest(req) + assert.NoError(t, err) + }) + } +} + +// Note: Integration tests for CreateVM would require: +// 1. Valid Azure credentials +// 2. Existing resource group +// 3. Existing network interface +// 4. Permissions to create VMs +// These should be run separately as E2E tests, not unit tests diff --git a/pkg/integrations/azure/azure_test.go b/pkg/integrations/azure/azure_test.go new file mode 100644 index 0000000000..59eeeb4a7f --- /dev/null +++ b/pkg/integrations/azure/azure_test.go @@ -0,0 +1,265 @@ +package azure + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +func TestAzureIntegration_Name(t *testing.T) { + integration := &AzureIntegration{} + assert.Equal(t, "azure", integration.Name()) +} + +func TestAzureIntegration_Label(t *testing.T) { + integration := &AzureIntegration{} + assert.Equal(t, "Microsoft Azure", integration.Label()) +} + +func TestAzureIntegration_Icon(t *testing.T) { + integration := &AzureIntegration{} + assert.Equal(t, "azure", integration.Icon()) +} + +func TestAzureIntegration_Description(t *testing.T) { + integration := &AzureIntegration{} + description := integration.Description() + assert.NotEmpty(t, description) + assert.Contains(t, description, "Azure") +} + +func TestAzureIntegration_Instructions(t *testing.T) { + integration := &AzureIntegration{} + instructions := integration.Instructions() + assert.NotEmpty(t, instructions) + assert.Contains(t, instructions, "Workload Identity Federation") + assert.Contains(t, instructions, "App Registration") + assert.Contains(t, instructions, "Tenant ID") + assert.Contains(t, instructions, "Client ID") + assert.Contains(t, instructions, "Subscription ID") +} + +func TestAzureIntegration_Configuration(t *testing.T) { + integration := &AzureIntegration{} + fields := integration.Configuration() + + require.Len(t, fields, 3, "Should have exactly 3 configuration fields") + + // Check tenantId field + tenantField := fields[0] + assert.Equal(t, "tenantId", tenantField.Name) + assert.Equal(t, "Tenant ID", tenantField.Label) + assert.Equal(t, configuration.FieldTypeString, tenantField.Type) + assert.True(t, tenantField.Required) + assert.NotEmpty(t, tenantField.Description) + + // Check clientId field + clientField := fields[1] + assert.Equal(t, "clientId", clientField.Name) + assert.Equal(t, "Client ID", clientField.Label) + assert.Equal(t, configuration.FieldTypeString, clientField.Type) + assert.True(t, clientField.Required) + assert.NotEmpty(t, clientField.Description) + + // Check subscriptionId field + subscriptionField := fields[2] + assert.Equal(t, "subscriptionId", subscriptionField.Name) + assert.Equal(t, "Subscription ID", subscriptionField.Label) + assert.Equal(t, configuration.FieldTypeString, subscriptionField.Type) + assert.True(t, subscriptionField.Required) + assert.NotEmpty(t, subscriptionField.Description) +} + +func TestAzureIntegration_Components(t *testing.T) { + integration := &AzureIntegration{} + components := integration.Components() + + assert.NotNil(t, components) + assert.IsType(t, []core.Component{}, components) +} + +func TestAzureIntegration_Triggers(t *testing.T) { + integration := &AzureIntegration{} + triggers := integration.Triggers() + + assert.NotNil(t, triggers) + assert.IsType(t, []core.Trigger{}, triggers) +} + +func TestAzureIntegration_Actions(t *testing.T) { + integration := &AzureIntegration{} + actions := integration.Actions() + + // Currently returns empty list + assert.NotNil(t, actions) + assert.IsType(t, []core.Action{}, actions) +} + +func TestAzureIntegration_HandleAction(t *testing.T) { + integration := &AzureIntegration{} + + // Create mock context + ctx := core.IntegrationActionContext{ + Name: "unknown-action", + } + + err := integration.HandleAction(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown action") +} + +func TestAzureIntegration_ListResources(t *testing.T) { + integration := &AzureIntegration{} + + logger := logrus.NewEntry(logrus.New()) + ctx := core.ListResourcesContext{ + Logger: logger, + } + + tests := []struct { + name string + resourceType string + expectError bool + }{ + { + name: "resource group", + resourceType: "resourceGroup", + expectError: false, + }, + { + name: "virtual network", + resourceType: "virtualNetwork", + expectError: false, + }, + { + name: "subnet", + resourceType: "subnet", + expectError: false, + }, + { + name: "unsupported type", + resourceType: "unsupported", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resources, err := integration.ListResources(tt.resourceType, ctx) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported resource type") + } else { + assert.NoError(t, err) + assert.NotNil(t, resources) + } + }) + } +} + +func TestAzureIntegration_Cleanup(t *testing.T) { + integration := &AzureIntegration{} + + logger := logrus.NewEntry(logrus.New()) + ctx := core.IntegrationCleanupContext{ + Logger: logger, + } + + err := integration.Cleanup(ctx) + assert.NoError(t, err) +} + +func TestAzureIntegration_HandleRequest_Unknown(t *testing.T) { + integration := &AzureIntegration{} + + req := httptest.NewRequest(http.MethodGet, "/unknown", nil) + rec := httptest.NewRecorder() + + logger := logrus.NewEntry(logrus.New()) + ctx := core.HTTPRequestContext{ + Request: req, + Response: rec, + Logger: logger, + } + + integration.HandleRequest(ctx) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestAzureIntegration_GetProvider(t *testing.T) { + integration := &AzureIntegration{} + + // Initially nil + assert.Nil(t, integration.GetProvider()) + + // Set a mock provider + provider := &AzureProvider{} + integration.provider = provider + + assert.NotNil(t, integration.GetProvider()) + assert.Equal(t, provider, integration.GetProvider()) +} + +func TestConfiguration_Struct(t *testing.T) { + config := Configuration{ + TenantID: "test-tenant-id", + ClientID: "test-client-id", + SubscriptionID: "test-subscription-id", + } + + assert.Equal(t, "test-tenant-id", config.TenantID) + assert.Equal(t, "test-client-id", config.ClientID) + assert.Equal(t, "test-subscription-id", config.SubscriptionID) +} + +func TestMetadata_Struct(t *testing.T) { + // Test that Metadata struct exists and can be instantiated + metadata := Metadata{} + assert.NotNil(t, metadata) +} + +// Note: Testing Sync() would require: +// 1. Mock core.SyncContext +// 2. Valid OIDC token file setup +// 3. Mock Azure API responses +// These should be tested in integration tests, not unit tests + +// Integration test example (commented out, requires real setup): +/* +func TestAzureIntegration_Sync_Integration(t *testing.T) { + t.Skip("Requires real Azure credentials and OIDC token") + + // Setup OIDC token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token") + err := os.WriteFile(tokenFile, []byte("mock-token"), 0600) + require.NoError(t, err) + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", tokenFile) + + integration := &AzureIntegration{} + logger := logrus.NewEntry(logrus.New()) + + // Mock SyncContext + ctx := core.SyncContext{ + Logger: logger, + Configuration: map[string]any{ + "tenantId": "test-tenant-id", + "clientId": "test-client-id", + "subscriptionId": "test-subscription-id", + }, + Integration: mockIntegrationContext{}, + } + + err = integration.Sync(ctx) + assert.NoError(t, err) + assert.NotNil(t, integration.GetProvider()) +} +*/ diff --git a/pkg/integrations/azure/component_create_vm.go b/pkg/integrations/azure/component_create_vm.go new file mode 100644 index 0000000000..bdb3f3a63c --- /dev/null +++ b/pkg/integrations/azure/component_create_vm.go @@ -0,0 +1,506 @@ +package azure + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type CreateVMComponent struct{} + +type CreateVMConfiguration struct { + ResourceGroup string `json:"resourceGroup" mapstructure:"resourceGroup"` + Name string `json:"name" mapstructure:"name"` + Location string `json:"location" mapstructure:"location"` + VirtualNetworkName string `json:"virtualNetworkName" mapstructure:"virtualNetworkName"` + SubnetName string `json:"subnetName" mapstructure:"subnetName"` + Size string `json:"size" mapstructure:"size"` + PublicIPName string `json:"publicIpName" mapstructure:"publicIpName"` + AdminUsername string `json:"adminUsername" mapstructure:"adminUsername"` + AdminPassword string `json:"adminPassword" mapstructure:"adminPassword"` + NetworkInterfaceID string `json:"networkInterfaceId" mapstructure:"networkInterfaceId"` + OSDiskType string `json:"osDiskType" mapstructure:"osDiskType"` + CustomData string `json:"customData" mapstructure:"customData"` + ImagePublisher string `json:"imagePublisher" mapstructure:"imagePublisher"` + ImageOffer string `json:"imageOffer" mapstructure:"imageOffer"` + ImageSku string `json:"imageSku" mapstructure:"imageSku"` + ImageVersion string `json:"imageVersion" mapstructure:"imageVersion"` +} + +func (c *CreateVMComponent) Name() string { + return "azure.createVirtualMachine" +} + +func (c *CreateVMComponent) Label() string { + return "Azure • Create Virtual Machine" +} + +func (c *CreateVMComponent) Description() string { + return "Creates a new Azure Virtual Machine with the specified configuration" +} + +func (c *CreateVMComponent) Documentation() string { + return ` +The Create Virtual Machine component creates a new Azure VM with full configuration options. + +## Use Cases + +- **Infrastructure provisioning**: Automatically create VMs as part of deployment workflows +- **Development environments**: Spin up temporary VMs for testing and development +- **Auto-scaling**: Create VMs in response to load or events +- **Disaster recovery**: Quickly provision replacement VMs + +## How It Works + +1. Validates the VM configuration parameters +2. Initiates VM creation via the Azure Compute API +3. Waits for the VM to be fully provisioned (using Azure's Long-Running Operation pattern) +4. Returns the VM details including ID, name, and provisioning state + +## Configuration + +- **Resource Group**: The Azure resource group where the VM will be created +- **Name**: The name for the new virtual machine +- **Location**: The Azure region (e.g., "eastus", "westeurope") +- **Size**: The VM size (e.g., "Standard_B1s", "Standard_D2s_v3") +- **Admin Username**: Administrator username for the VM +- **Admin Password**: Administrator password for the VM (must meet Azure complexity requirements) +- **Network Interface ID**: Optional existing NIC. Leave empty to create NIC from selected VNet/Subnet. +- **Image**: The OS image to use (publisher, offer, SKU, version) + +## Output + +Returns the created VM information including: +- **id**: The Azure resource ID of the VM +- **name**: The name of the VM +- **provisioningState**: The provisioning state (typically "Succeeded") +- **location**: The Azure region where the VM was created +- **size**: The VM size + +## Notes + +- The VM creation is a Long-Running Operation (LRO) that typically takes 2-5 minutes +- The component waits for the VM to be fully provisioned before completing +- The admin password must meet Azure's complexity requirements (12+ characters, mixed case, numbers, symbols) +- If Network Interface ID is empty, a NIC is created automatically from the selected VNet/Subnet +` +} + +func (c *CreateVMComponent) Icon() string { + return "azure" +} + +func (c *CreateVMComponent) Color() string { + return "blue" +} + +func (c *CreateVMComponent) ExampleOutput() map[string]any { + return map[string]any{ + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm", + "name": "my-vm", + "provisioningState": "Succeeded", + "location": "eastus", + "size": "Standard_B1s", + } +} + +func (c *CreateVMComponent) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateVMComponent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "resourceGroup", + Label: "Resource Group", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The Azure resource group where the VM will be created", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeResourceGroupDropdown, + UseNameAsValue: true, + }, + }, + }, + { + Name: "name", + Label: "VM Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "The name for the new virtual machine", + Placeholder: "my-vm", + }, + { + Name: "location", + Label: "Location", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "The Azure region where the VM will be created", + Default: "eastus", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "East US", Value: "eastus"}, + {Label: "East US 2", Value: "eastus2"}, + {Label: "West US", Value: "westus"}, + {Label: "West US 2", Value: "westus2"}, + {Label: "West US 3", Value: "westus3"}, + {Label: "Central US", Value: "centralus"}, + {Label: "North Central US", Value: "northcentralus"}, + {Label: "South Central US", Value: "southcentralus"}, + {Label: "West Central US", Value: "westcentralus"}, + {Label: "North Europe", Value: "northeurope"}, + {Label: "West Europe", Value: "westeurope"}, + {Label: "UK South", Value: "uksouth"}, + {Label: "UK West", Value: "ukwest"}, + {Label: "Southeast Asia", Value: "southeastasia"}, + {Label: "East Asia", Value: "eastasia"}, + {Label: "Australia East", Value: "australiaeast"}, + {Label: "Australia Southeast", Value: "australiasoutheast"}, + {Label: "Japan East", Value: "japaneast"}, + {Label: "Japan West", Value: "japanwest"}, + {Label: "Brazil South", Value: "brazilsouth"}, + {Label: "Canada Central", Value: "canadacentral"}, + {Label: "Canada East", Value: "canadaeast"}, + {Label: "France Central", Value: "francecentral"}, + {Label: "Germany West Central", Value: "germanywestcentral"}, + {Label: "Korea Central", Value: "koreacentral"}, + {Label: "Central India", Value: "centralindia"}, + {Label: "South India", Value: "southindia"}, + {Label: "West India", Value: "westindia"}, + {Label: "Poland Central", Value: "polandcentral"}, + {Label: "Austria East", Value: "austriaeast"}, + {Label: "UAE North", Value: "uaenorth"}, + {Label: "Switzerland North", Value: "switzerlandnorth"}, + {Label: "Spain Central", Value: "spaincentral"}, + }, + }, + }, + }, + { + Name: "size", + Label: "VM Size", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "The size of the virtual machine", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeVMSizeDropdown, + Parameters: []configuration.ParameterRef{ + { + Name: "location", + ValueFrom: &configuration.ParameterValueFrom{Field: "location"}, + }, + }, + }, + }, + }, + { + Name: "virtualNetworkName", + Label: "Virtual Network", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Select an existing virtual network in the selected resource group", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeVirtualNetworkDropdown, + UseNameAsValue: true, + Parameters: []configuration.ParameterRef{ + { + Name: "resourceGroup", + ValueFrom: &configuration.ParameterValueFrom{Field: "resourceGroup"}, + }, + }, + }, + }, + }, + { + Name: "subnetName", + Label: "Subnet", + Type: configuration.FieldTypeIntegrationResource, + Required: false, + Description: "Select an existing subnet in the selected virtual network", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeSubnetDropdown, + UseNameAsValue: true, + Parameters: []configuration.ParameterRef{ + { + Name: "resourceGroup", + ValueFrom: &configuration.ParameterValueFrom{Field: "resourceGroup"}, + }, + { + Name: "virtualNetworkName", + ValueFrom: &configuration.ParameterValueFrom{Field: "virtualNetworkName"}, + }, + }, + }, + }, + }, + { + Name: "publicIpName", + Label: "Public IP Name", + Type: configuration.FieldTypeString, + Required: false, + Description: "Optional public IP resource name. Leave empty for private-only VM", + Placeholder: "my-vm-pip", + }, + { + Name: "adminUsername", + Label: "Admin Username", + Type: configuration.FieldTypeString, + Required: true, + Description: "Administrator username for the VM", + Placeholder: "azureuser", + }, + { + Name: "adminPassword", + Label: "Admin Password", + Type: configuration.FieldTypeString, + Required: true, + Description: "Administrator password (12+ characters, mixed case, numbers, symbols)", + Placeholder: "••••••••••••", + Sensitive: true, + }, + { + Name: "networkInterfaceId", + Label: "Network Interface ID", + Type: configuration.FieldTypeString, + Required: false, + Description: "Optional: existing network interface ID (leave empty to create NIC from selected VNet/Subnet)", + Placeholder: "/subscriptions/.../resourceGroups/.../providers/Microsoft.Network/networkInterfaces/my-nic", + }, + { + Name: "osDiskType", + Label: "OS Disk Type", + Type: configuration.FieldTypeSelect, + Required: false, + Default: "StandardSSD_LRS", + Description: "Managed disk performance tier for the VM OS disk", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Standard HDD", Value: "Standard_LRS"}, + {Label: "Standard SSD", Value: "StandardSSD_LRS"}, + {Label: "Premium SSD", Value: "Premium_LRS"}, + }, + }, + }, + }, + { + Name: "customData", + Label: "Custom Data (cloud-init)", + Type: configuration.FieldTypeText, + Required: false, + Description: "Cloud-init script (bash/yaml) to run on boot", + Placeholder: "#cloud-config\npackages:\n - nginx", + }, + { + Name: "imagePublisher", + Label: "Image Publisher", + Type: configuration.FieldTypeString, + Required: false, + Description: "The publisher of the VM image", + Default: ImageUbuntu2004LTS.Publisher, + Placeholder: "Canonical", + }, + { + Name: "imageOffer", + Label: "Image Offer", + Type: configuration.FieldTypeString, + Required: false, + Description: "The offer of the VM image", + Default: ImageUbuntu2004LTS.Offer, + Placeholder: "UbuntuServer", + }, + { + Name: "imageSku", + Label: "Image SKU", + Type: configuration.FieldTypeString, + Required: false, + Description: "The SKU of the VM image", + Default: ImageUbuntu2004LTS.SKU, + Placeholder: "20.04-LTS", + }, + { + Name: "imageVersion", + Label: "Image Version", + Type: configuration.FieldTypeString, + Required: false, + Description: "The version of the VM image", + Default: ImageUbuntu2004LTS.Version, + Placeholder: "latest", + }, + } +} + +func (c *CreateVMComponent) Setup(ctx core.SetupContext) error { + config := CreateVMConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.ResourceGroup == "" { + return fmt.Errorf("resource group is required") + } + if config.Name == "" { + return fmt.Errorf("VM name is required") + } + if config.Location == "" { + return fmt.Errorf("location is required") + } + if config.Size == "" { + return fmt.Errorf("VM size is required") + } + if config.AdminUsername == "" { + return fmt.Errorf("admin username is required") + } + if config.AdminPassword == "" { + return fmt.Errorf("admin password is required") + } + if config.NetworkInterfaceID == "" && (config.VirtualNetworkName == "" || config.SubnetName == "") { + return fmt.Errorf("either network interface ID or both virtual network and subnet are required") + } + if config.OSDiskType == "" { + config.OSDiskType = "StandardSSD_LRS" + } + + if config.ImagePublisher == "" { + config.ImagePublisher = ImageUbuntu2004LTS.Publisher + } + if config.ImageOffer == "" { + config.ImageOffer = ImageUbuntu2004LTS.Offer + } + if config.ImageSku == "" { + config.ImageSku = ImageUbuntu2004LTS.SKU + } + if config.ImageVersion == "" { + config.ImageVersion = ImageUbuntu2004LTS.Version + } + + return nil +} + +func (c *CreateVMComponent) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateVMComponent) Execute(ctx core.ExecutionContext) error { + config := CreateVMConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if ctx.Integration == nil { + return fmt.Errorf("integration context is required") + } + + tenantID, err := ctx.Integration.GetConfig("tenantId") + if err != nil { + return fmt.Errorf("failed to get tenant ID: %w", err) + } + + clientID, err := ctx.Integration.GetConfig("clientId") + if err != nil { + return fmt.Errorf("failed to get client ID: %w", err) + } + + subscriptionID, err := ctx.Integration.GetConfig("subscriptionId") + if err != nil { + return fmt.Errorf("failed to get subscription ID: %w", err) + } + + provider, err := NewAzureProvider( + context.Background(), + string(tenantID), + string(clientID), + string(subscriptionID), + ctx.Logger, + ) + if err != nil { + return fmt.Errorf("failed to create Azure provider: %w", err) + } + + if config.ImagePublisher == "" { + config.ImagePublisher = ImageUbuntu2004LTS.Publisher + } + if config.ImageOffer == "" { + config.ImageOffer = ImageUbuntu2004LTS.Offer + } + if config.ImageSku == "" { + config.ImageSku = ImageUbuntu2004LTS.SKU + } + if config.ImageVersion == "" { + config.ImageVersion = ImageUbuntu2004LTS.Version + } + + req := CreateVMRequest{ + ResourceGroup: config.ResourceGroup, + VMName: config.Name, + Location: config.Location, + Size: config.Size, + PublicIPName: config.PublicIPName, + AdminUsername: config.AdminUsername, + AdminPassword: config.AdminPassword, + NetworkInterfaceID: config.NetworkInterfaceID, + VirtualNetworkName: config.VirtualNetworkName, + SubnetName: config.SubnetName, + OSDiskType: config.OSDiskType, + CustomData: config.CustomData, + ImagePublisher: config.ImagePublisher, + ImageOffer: config.ImageOffer, + ImageSku: config.ImageSku, + ImageVersion: config.ImageVersion, + } + + ctx.Logger.Infof("Creating Azure VM: %s in resource group %s", config.Name, config.ResourceGroup) + response, err := CreateVM(context.Background(), provider, req, ctx.Logger) + if err != nil { + return fmt.Errorf("failed to create VM: %w", err) + } + + output := map[string]any{ + "id": response.VMID, + "name": response.Name, + "provisioningState": response.ProvisioningState, + "location": response.Location, + "size": response.Size, + "publicIp": response.PublicIP, + "privateIp": response.PrivateIP, + "adminUsername": response.AdminUsername, + } + + ctx.Logger.Infof("VM created successfully: %s (ID: %s)", response.Name, response.VMID) + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "azure.vm", + []any{output}, + ) +} + +func (c *CreateVMComponent) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateVMComponent) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateVMComponent) HandleAction(ctx core.ActionContext) error { + return fmt.Errorf("no actions defined for this component") +} + +func (c *CreateVMComponent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *CreateVMComponent) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/azure/events.go b/pkg/integrations/azure/events.go new file mode 100644 index 0000000000..74c501ccf5 --- /dev/null +++ b/pkg/integrations/azure/events.go @@ -0,0 +1,188 @@ +package azure + +import ( + "time" +) + +// EventGridEvent represents the top-level structure of an Azure Event Grid event +// See: https://learn.microsoft.com/en-us/azure/event-grid/event-schema +type EventGridEvent struct { + // ID is the unique identifier for the event + ID string `json:"id"` + + // Topic is the full resource path to the event source + Topic string `json:"topic"` + + // Subject is the publisher-defined path to the event subject + Subject string `json:"subject"` + + // EventType is the one of the registered event types for this event source + EventType string `json:"eventType"` + + // EventTime is the time the event is generated based on the provider's UTC time + EventTime time.Time `json:"eventTime"` + + // Data contains the event data specific to the event type + Data map[string]any `json:"data"` + + // DataVersion is the schema version of the data object + DataVersion string `json:"dataVersion"` + + // MetadataVersion is the schema version of the event metadata + MetadataVersion string `json:"metadataVersion"` +} + +// SubscriptionValidationEventData contains the validation code for Event Grid subscription validation +// This is sent when Azure Event Grid first subscribes to a webhook endpoint +type SubscriptionValidationEventData struct { + // ValidationCode is the code that must be returned to complete the handshake + ValidationCode string `json:"validationCode"` + + // ValidationURL is an optional URL that can be used instead of returning the code + ValidationURL string `json:"validationUrl,omitempty"` +} + +// SubscriptionValidationResponse is the response sent back to Azure Event Grid +// to complete the subscription validation handshake +type SubscriptionValidationResponse struct { + // ValidationResponse contains the validation code from the subscription validation event + ValidationResponse string `json:"validationResponse"` +} + +// ResourceWriteSuccessData contains the data for a successful resource write operation +// This is used for events like VM creation, update, or deletion +type ResourceWriteSuccessData struct { + // ProvisioningState indicates the current state of the resource operation + // Common values: "Succeeded", "Failed", "Canceled", "Creating", "Updating", "Deleting" + ProvisioningState string `json:"provisioningState"` + + // ResourceProvider is the Azure resource provider (e.g., "Microsoft.Compute") + ResourceProvider string `json:"resourceProvider"` + + // ResourceURI is the full resource ID + ResourceURI string `json:"resourceUri"` + + // OperationName is the name of the operation that was performed + OperationName string `json:"operationName"` + + // Status indicates the HTTP status of the operation + Status string `json:"status"` + + // SubscriptionID is the Azure subscription ID + SubscriptionID string `json:"subscriptionId"` + + // TenantID is the Azure tenant ID + TenantID string `json:"tenantId"` + + // Authorization contains information about the authorization for the operation + Authorization *AuthorizationInfo `json:"authorization,omitempty"` + + // Claims contains the JWT claims from the request + Claims map[string]any `json:"claims,omitempty"` + + // CorrelationID is the correlation ID for the operation + CorrelationID string `json:"correlationId"` + + // HTTPRequest contains information about the HTTP request that triggered the operation + HTTPRequest *HTTPRequestInfo `json:"httpRequest,omitempty"` +} + +// AuthorizationInfo contains authorization information for a resource operation +type AuthorizationInfo struct { + // Scope is the scope of the authorization + Scope string `json:"scope"` + + // Action is the action that was authorized + Action string `json:"action"` + + // Evidence contains additional evidence for the authorization + Evidence map[string]any `json:"evidence,omitempty"` +} + +// HTTPRequestInfo contains information about the HTTP request that triggered an event +type HTTPRequestInfo struct { + // ClientRequestID is the client request ID + ClientRequestID string `json:"clientRequestId"` + + // ClientIPAddress is the IP address of the client + ClientIPAddress string `json:"clientIpAddress"` + + // Method is the HTTP method (GET, POST, PUT, DELETE, etc.) + Method string `json:"method"` + + // URL is the request URL + URL string `json:"url"` +} + +// Event type constants for Azure Event Grid +const ( + // EventTypeSubscriptionValidation is sent when Event Grid first subscribes to a webhook + EventTypeSubscriptionValidation = "Microsoft.EventGrid.SubscriptionValidationEvent" + + // EventTypeResourceWriteSuccess is sent when a resource write operation succeeds + EventTypeResourceWriteSuccess = "Microsoft.Resources.ResourceWriteSuccess" + + // EventTypeResourceWriteFailure is sent when a resource write operation fails + EventTypeResourceWriteFailure = "Microsoft.Resources.ResourceWriteFailure" + + // EventTypeResourceWriteCancel is sent when a resource write operation is canceled + EventTypeResourceWriteCancel = "Microsoft.Resources.ResourceWriteCancel" + + // EventTypeResourceDeleteSuccess is sent when a resource delete operation succeeds + EventTypeResourceDeleteSuccess = "Microsoft.Resources.ResourceDeleteSuccess" + + // EventTypeResourceDeleteFailure is sent when a resource delete operation fails + EventTypeResourceDeleteFailure = "Microsoft.Resources.ResourceDeleteFailure" + + // EventTypeResourceDeleteCancel is sent when a resource delete operation is canceled + EventTypeResourceDeleteCancel = "Microsoft.Resources.ResourceDeleteCancel" + + // EventTypeResourceActionSuccess is sent when a resource action succeeds + EventTypeResourceActionSuccess = "Microsoft.Resources.ResourceActionSuccess" + + // EventTypeResourceActionFailure is sent when a resource action fails + EventTypeResourceActionFailure = "Microsoft.Resources.ResourceActionFailure" + + // EventTypeResourceActionCancel is sent when a resource action is canceled + EventTypeResourceActionCancel = "Microsoft.Resources.ResourceActionCancel" +) + +// Resource type constants +const ( + // ResourceTypeVirtualMachine is the resource type for Azure Virtual Machines + ResourceTypeVirtualMachine = "Microsoft.Compute/virtualMachines" + + // ResourceTypeStorageAccount is the resource type for Azure Storage Accounts + ResourceTypeStorageAccount = "Microsoft.Storage/storageAccounts" + + // ResourceTypeNetworkInterface is the resource type for Azure Network Interfaces + ResourceTypeNetworkInterface = "Microsoft.Network/networkInterfaces" + + // ResourceTypeVirtualNetwork is the resource type for Azure Virtual Networks + ResourceTypeVirtualNetwork = "Microsoft.Network/virtualNetworks" + + // ResourceTypePublicIPAddress is the resource type for Azure Public IP Addresses + ResourceTypePublicIPAddress = "Microsoft.Network/publicIPAddresses" +) + +// Provisioning state constants +const ( + // ProvisioningStateSucceeded indicates the resource operation completed successfully + ProvisioningStateSucceeded = "Succeeded" + + // ProvisioningStateFailed indicates the resource operation failed + ProvisioningStateFailed = "Failed" + + // ProvisioningStateCanceled indicates the resource operation was canceled + ProvisioningStateCanceled = "Canceled" + + // ProvisioningStateCreating indicates the resource is being created + ProvisioningStateCreating = "Creating" + + // ProvisioningStateUpdating indicates the resource is being updated + ProvisioningStateUpdating = "Updating" + + // ProvisioningStateDeleting indicates the resource is being deleted + ProvisioningStateDeleting = "Deleting" +) + diff --git a/pkg/integrations/azure/examples/example_data_on_vm_created.json b/pkg/integrations/azure/examples/example_data_on_vm_created.json new file mode 100644 index 0000000000..860e4a64f9 --- /dev/null +++ b/pkg/integrations/azure/examples/example_data_on_vm_created.json @@ -0,0 +1,16 @@ +{ + "data": { + "vmName": "my-vm-01", + "vmId": "/subscriptions//resourceGroups//providers/Microsoft.Compute/virtualMachines/my-vm-01", + "resourceGroup": "", + "subscriptionId": "", + "location": "", + "provisioningState": "Succeeded", + "timestamp": "2026-02-12T12:00:00Z", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Succeeded" + }, + "timestamp": "2026-02-12T12:00:00Z", + "type": "azure.vm.created" +} + diff --git a/pkg/integrations/azure/examples/example_output_create_vm.json b/pkg/integrations/azure/examples/example_output_create_vm.json new file mode 100644 index 0000000000..97269c14a3 --- /dev/null +++ b/pkg/integrations/azure/examples/example_output_create_vm.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "/subscriptions//resourceGroups//providers/Microsoft.Compute/virtualMachines/my-vm-01", + "name": "my-vm-01", + "provisioningState": "Succeeded", + "location": "polandcentral", + "size": "Standard_B2s", + "publicIp": "20.10.10.10", + "privateIp": "10.0.0.4", + "adminUsername": "azureuser" + }, + "timestamp": "2026-02-12T12:05:00Z", + "type": "azure.vm" +} + diff --git a/pkg/integrations/azure/integration.go b/pkg/integrations/azure/integration.go new file mode 100644 index 0000000000..1755328a38 --- /dev/null +++ b/pkg/integrations/azure/integration.go @@ -0,0 +1,256 @@ +package azure + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegrationWithWebhookHandler("azure", &AzureIntegration{}, &AzureWebhookHandler{}) +} + +type AzureIntegration struct { + provider *AzureProvider +} + +type Configuration struct { + TenantID string `json:"tenantId" mapstructure:"tenantId"` + ClientID string `json:"clientId" mapstructure:"clientId"` + SubscriptionID string `json:"subscriptionId" mapstructure:"subscriptionId"` +} + +type Metadata struct { +} + +func (a *AzureIntegration) Name() string { + return "azure" +} + +func (a *AzureIntegration) Label() string { + return "Microsoft Azure" +} + +func (a *AzureIntegration) Icon() string { + return "azure" +} + +func (a *AzureIntegration) Description() string { + return "Manage and automate Microsoft Azure resources and services" +} + +func (a *AzureIntegration) Instructions() string { + return `## Azure Workload Identity Federation Setup + +To connect SuperPlane to Microsoft Azure using Workload Identity Federation: + +### 1. Create or Select an App Registration + +1. Go to **Azure Portal** → **Azure Active Directory** → **App registrations** +2. Create a new registration or select an existing app +3. Note the **Application (client) ID** and **Directory (tenant) ID** + +### 2. Configure Federated Identity Credential + +1. In your app registration, go to **Certificates & secrets** → **Federated credentials** +2. Click **Add credential** +3. Select **Other issuer** +4. Configure the credential: + - **Issuer**: The SuperPlane OIDC issuer URL (provided after creation) + - **Subject identifier**: ` + "`app-installation:`" + ` (provided after creation) + - **Audience**: The integration ID (provided after creation) + - **Name**: ` + "`superplane-integration`" + ` (or any descriptive name) + +### 3. Grant Required Permissions + +Assign appropriate Azure RBAC roles to your app registration: + +- **Virtual Machine Contributor** - For VM management +- **Network Contributor** - For network resource management +- **Storage Account Contributor** - For storage operations (if needed) +- **EventGrid Contributor** - For Event Grid subscriptions + +You can assign these roles at the subscription or resource group level. + +### 4. Complete the Connection + +Enter the following information below: +- **Tenant ID**: Your Azure AD tenant ID +- **Client ID**: Your app registration's client ID +- **Subscription ID**: Your Azure subscription ID + +SuperPlane will use Workload Identity Federation to authenticate without storing any credentials.` +} + +func (a *AzureIntegration) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "tenantId", + Label: "Tenant ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "Azure Active Directory tenant ID (Directory ID)", + Placeholder: "00000000-0000-0000-0000-000000000000", + }, + { + Name: "clientId", + Label: "Client ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "Application (client) ID from your Azure app registration", + Placeholder: "00000000-0000-0000-0000-000000000000", + }, + { + Name: "subscriptionId", + Label: "Subscription ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "Azure subscription ID where resources will be managed", + Placeholder: "00000000-0000-0000-0000-000000000000", + }, + } +} + +func (a *AzureIntegration) Components() []core.Component { + return []core.Component{ + &CreateVMComponent{}, + } +} + +func (a *AzureIntegration) Triggers() []core.Trigger { + return []core.Trigger{ + &OnVMCreatedTrigger{}, + } +} + +// Sync validates configuration and initializes Azure clients. +func (a *AzureIntegration) Sync(ctx core.SyncContext) error { + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.TenantID == "" { + return fmt.Errorf("tenant ID is required") + } + + if config.ClientID == "" { + return fmt.Errorf("client ID is required") + } + + if config.SubscriptionID == "" { + return fmt.Errorf("subscription ID is required") + } + + ctx.Logger.Infof("Initializing Azure provider: tenant=%s, subscription=%s", + config.TenantID, config.SubscriptionID) + + provider, err := NewAzureProvider( + context.Background(), + config.TenantID, + config.ClientID, + config.SubscriptionID, + ctx.Logger, + ) + if err != nil { + return fmt.Errorf("failed to initialize Azure provider: %w", err) + } + + a.provider = provider + + ctx.Logger.Info("Azure integration synchronized successfully") + + ctx.Integration.Ready() + + return nil +} + +// Cleanup handles integration teardown. +func (a *AzureIntegration) Cleanup(ctx core.IntegrationCleanupContext) error { + ctx.Logger.Info("Cleaning up Azure integration") + return nil +} + +// Actions returns integration-level actions. +func (a *AzureIntegration) Actions() []core.Action { + return []core.Action{} +} + +// HandleAction executes an integration-level action. +func (a *AzureIntegration) HandleAction(ctx core.IntegrationActionContext) error { + return fmt.Errorf("unknown action: %s", ctx.Name) +} + +// ListResources lists Azure resources by resource type. +func (a *AzureIntegration) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case ResourceTypeResourceGroupDropdown: + return a.ListResourceGroups(ctx) + + case ResourceTypeVMSizeDropdown: + return a.ListVMSizes(ctx, firstNonEmptyParameter(ctx.Parameters, "location")) + + case ResourceTypeVirtualNetworkDropdown: + return a.ListVirtualNetworks(ctx, firstNonEmptyParameter(ctx.Parameters, "resourceGroup")) + + case ResourceTypeSubnetDropdown: + return a.ListSubnets( + ctx, + firstNonEmptyParameter(ctx.Parameters, "resourceGroup"), + firstNonEmptyParameter(ctx.Parameters, "virtualNetworkName", "virtualNetwork", "vnetName"), + ) + + case "resourceGroup", "virtualNetwork", "subnet": + return []core.IntegrationResource{}, nil + + default: + return nil, fmt.Errorf("unsupported resource type: %s", resourceType) + } +} + +func firstNonEmptyParameter(parameters map[string]string, keys ...string) string { + for _, key := range keys { + if value, ok := parameters[key]; ok && value != "" { + return value + } + } + return "" +} + +// HandleRequest routes incoming webhook requests. +func (a *AzureIntegration) HandleRequest(ctx core.HTTPRequestContext) { + if ctx.Request.Method == http.MethodPost { + if strings.HasSuffix(ctx.Request.URL.Path, "/webhook") || + strings.HasSuffix(ctx.Request.URL.Path, "/events") { + a.handleWebhook(ctx) + return + } + } + + ctx.Logger.Warnf("Unknown request path: %s %s", ctx.Request.Method, ctx.Request.URL.Path) + ctx.Response.WriteHeader(http.StatusNotFound) + ctx.Response.Write([]byte("not found")) +} + +// handleWebhook processes Azure Event Grid webhooks. +func (a *AzureIntegration) handleWebhook(ctx core.HTTPRequestContext) { + ctx.Logger.Infof("Handling Azure Event Grid webhook: %s", ctx.Request.URL.Path) + + if err := HandleWebhook(ctx.Response, ctx.Request, ctx.Logger); err != nil { + ctx.Logger.Errorf("Failed to handle webhook: %v", err) + return + } + + ctx.Logger.Info("Webhook processed successfully") +} + +// GetProvider returns the initialized provider. +func (a *AzureIntegration) GetProvider() *AzureProvider { + return a.provider +} diff --git a/pkg/integrations/azure/provider.go b/pkg/integrations/azure/provider.go new file mode 100644 index 0000000000..99cd9c126a --- /dev/null +++ b/pkg/integrations/azure/provider.go @@ -0,0 +1,180 @@ +package azure + +import ( + "context" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v5" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/sirupsen/logrus" +) + +const ( + AzureFederatedTokenFileEnv = "AZURE_FEDERATED_TOKEN_FILE" + + ResourceProviderNetwork = "Microsoft.Network" + ResourceProviderCompute = "Microsoft.Compute" + + IPAllocationMethodDynamic = "Dynamic" + IPAllocationMethodStatic = "Static" + + SKUStandard = "Standard" +) + +type AzureProvider struct { + credential azcore.TokenCredential + subscriptionID string + computeClient *armcompute.VirtualMachinesClient + networkInterfacesClient *armnetwork.InterfacesClient + publicIPClient *armnetwork.PublicIPAddressesClient + resourceGroupsClient *armresources.ResourceGroupsClient + resourceSKUsClient *armcompute.ResourceSKUsClient + virtualNetworksClient *armnetwork.VirtualNetworksClient + subnetsClient *armnetwork.SubnetsClient + logger *logrus.Entry +} + +// NewAzureProvider creates an authenticated Azure provider using federated OIDC. +func NewAzureProvider(ctx context.Context, tenantID, clientID, subscriptionID string, logger *logrus.Entry) (*AzureProvider, error) { + if tenantID == "" { + return nil, fmt.Errorf("tenant ID is required") + } + + if clientID == "" { + return nil, fmt.Errorf("client ID is required") + } + + if subscriptionID == "" { + return nil, fmt.Errorf("subscription ID is required") + } + + tokenFilePath := os.Getenv(AzureFederatedTokenFileEnv) + if tokenFilePath == "" { + return nil, fmt.Errorf("environment variable %s is not set", AzureFederatedTokenFileEnv) + } + + getAssertion := func(ctx context.Context) (string, error) { + tokenBytes, err := os.ReadFile(tokenFilePath) + if err != nil { + return "", fmt.Errorf("failed to read OIDC token from %s: %w", tokenFilePath, err) + } + + token := string(tokenBytes) + if token == "" { + return "", fmt.Errorf("OIDC token file at %s is empty", tokenFilePath) + } + + return token, nil + } + + _, err := getAssertion(ctx) + if err != nil { + return nil, fmt.Errorf("failed to read OIDC token: %w", err) + } + + credential, err := azidentity.NewClientAssertionCredential(tenantID, clientID, getAssertion, nil) + if err != nil { + return nil, fmt.Errorf("failed to create client assertion credential: %w", err) + } + + computeClient, err := armcompute.NewVirtualMachinesClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create compute client: %w", err) + } + + networkInterfacesClient, err := armnetwork.NewInterfacesClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create network interfaces client: %w", err) + } + + publicIPClient, err := armnetwork.NewPublicIPAddressesClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create public IP addresses client: %w", err) + } + + resourceGroupsClient, err := armresources.NewResourceGroupsClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create resource groups client: %w", err) + } + + resourceSKUsClient, err := armcompute.NewResourceSKUsClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create resource SKUs client: %w", err) + } + + virtualNetworksClient, err := armnetwork.NewVirtualNetworksClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create virtual networks client: %w", err) + } + + subnetsClient, err := armnetwork.NewSubnetsClient(subscriptionID, credential, nil) + if err != nil { + return nil, fmt.Errorf("failed to create subnets client: %w", err) + } + + provider := &AzureProvider{ + credential: credential, + subscriptionID: subscriptionID, + computeClient: computeClient, + networkInterfacesClient: networkInterfacesClient, + publicIPClient: publicIPClient, + resourceGroupsClient: resourceGroupsClient, + resourceSKUsClient: resourceSKUsClient, + virtualNetworksClient: virtualNetworksClient, + subnetsClient: subnetsClient, + logger: logger, + } + + logger.Infof("Azure provider initialized for subscription %s", subscriptionID) + + return provider, nil +} + +// GetCredential returns the Azure token credential. +func (p *AzureProvider) GetCredential() azcore.TokenCredential { + return p.credential +} + +// GetComputeClient returns the VM client. +func (p *AzureProvider) GetComputeClient() *armcompute.VirtualMachinesClient { + return p.computeClient +} + +// GetNetworkInterfacesClient returns the NIC client. +func (p *AzureProvider) GetNetworkInterfacesClient() *armnetwork.InterfacesClient { + return p.networkInterfacesClient +} + +// GetPublicIPClient returns the Public IP client. +func (p *AzureProvider) GetPublicIPClient() *armnetwork.PublicIPAddressesClient { + return p.publicIPClient +} + +// GetResourceGroupsClient returns the Resource Groups client. +func (p *AzureProvider) GetResourceGroupsClient() *armresources.ResourceGroupsClient { + return p.resourceGroupsClient +} + +// GetResourceSKUsClient returns the Resource SKUs client. +func (p *AzureProvider) GetResourceSKUsClient() *armcompute.ResourceSKUsClient { + return p.resourceSKUsClient +} + +// GetVirtualNetworksClient returns the VNet client. +func (p *AzureProvider) GetVirtualNetworksClient() *armnetwork.VirtualNetworksClient { + return p.virtualNetworksClient +} + +// GetSubnetsClient returns the Subnets client. +func (p *AzureProvider) GetSubnetsClient() *armnetwork.SubnetsClient { + return p.subnetsClient +} + +// GetSubscriptionID returns the Azure subscription ID. +func (p *AzureProvider) GetSubscriptionID() string { + return p.subscriptionID +} diff --git a/pkg/integrations/azure/provider_test.go b/pkg/integrations/azure/provider_test.go new file mode 100644 index 0000000000..10a84ecd3c --- /dev/null +++ b/pkg/integrations/azure/provider_test.go @@ -0,0 +1,208 @@ +package azure + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAzureProvider(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + t.Run("success with valid configuration", func(t *testing.T) { + // Create temporary OIDC token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token") + tokenContent := "mock-oidc-token-content" + err := os.WriteFile(tokenFile, []byte(tokenContent), 0600) + require.NoError(t, err) + + // Set environment variable + t.Setenv(AzureFederatedTokenFileEnv, tokenFile) + + // Create provider + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logger, + ) + + assert.NoError(t, err) + assert.NotNil(t, provider) + assert.NotNil(t, provider.credential) + assert.NotNil(t, provider.computeClient) + assert.Equal(t, "test-subscription-id", provider.GetSubscriptionID()) + }) + + t.Run("error when tenant ID is empty", func(t *testing.T) { + provider, err := NewAzureProvider( + context.Background(), + "", + "test-client-id", + "test-subscription-id", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "tenant ID is required") + }) + + t.Run("error when client ID is empty", func(t *testing.T) { + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "", + "test-subscription-id", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "client ID is required") + }) + + t.Run("error when subscription ID is empty", func(t *testing.T) { + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "subscription ID is required") + }) + + t.Run("error when token file environment variable is not set", func(t *testing.T) { + // Ensure environment variable is not set + os.Unsetenv(AzureFederatedTokenFileEnv) + + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "environment variable") + assert.Contains(t, err.Error(), "is not set") + }) + + t.Run("error when token file does not exist", func(t *testing.T) { + // Set environment variable to non-existent file + t.Setenv(AzureFederatedTokenFileEnv, "/non/existent/path/token") + + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + }) + + t.Run("error when token file is empty", func(t *testing.T) { + // Create empty token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "empty-token") + err := os.WriteFile(tokenFile, []byte(""), 0600) + require.NoError(t, err) + + t.Setenv(AzureFederatedTokenFileEnv, tokenFile) + + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logger, + ) + + assert.Error(t, err) + assert.Nil(t, provider) + }) +} + +func TestAzureProvider_GetCredential(t *testing.T) { + // Create temporary OIDC token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token") + err := os.WriteFile(tokenFile, []byte("mock-token"), 0600) + require.NoError(t, err) + + t.Setenv(AzureFederatedTokenFileEnv, tokenFile) + + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logrus.NewEntry(logrus.New()), + ) + require.NoError(t, err) + + credential := provider.GetCredential() + assert.NotNil(t, credential) +} + +func TestAzureProvider_GetComputeClient(t *testing.T) { + // Create temporary OIDC token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token") + err := os.WriteFile(tokenFile, []byte("mock-token"), 0600) + require.NoError(t, err) + + t.Setenv(AzureFederatedTokenFileEnv, tokenFile) + + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + "test-subscription-id", + logrus.NewEntry(logrus.New()), + ) + require.NoError(t, err) + + computeClient := provider.GetComputeClient() + assert.NotNil(t, computeClient) +} + +func TestAzureProvider_GetSubscriptionID(t *testing.T) { + // Create temporary OIDC token file + tmpDir := t.TempDir() + tokenFile := filepath.Join(tmpDir, "token") + err := os.WriteFile(tokenFile, []byte("mock-token"), 0600) + require.NoError(t, err) + + t.Setenv(AzureFederatedTokenFileEnv, tokenFile) + + expectedSubscriptionID := "test-subscription-123" + provider, err := NewAzureProvider( + context.Background(), + "test-tenant-id", + "test-client-id", + expectedSubscriptionID, + logrus.NewEntry(logrus.New()), + ) + require.NoError(t, err) + + subscriptionID := provider.GetSubscriptionID() + assert.Equal(t, expectedSubscriptionID, subscriptionID) +} + diff --git a/pkg/integrations/azure/resources.go b/pkg/integrations/azure/resources.go new file mode 100644 index 0000000000..acf2227a51 --- /dev/null +++ b/pkg/integrations/azure/resources.go @@ -0,0 +1,280 @@ +package azure + +import ( + "context" + "fmt" + "net/url" + "path" + "sort" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + ResourceTypeResourceGroupDropdown = "azure.resourceGroup" + ResourceTypeVMSizeDropdown = "azure.vmSize" + ResourceTypeVirtualNetworkDropdown = "azure.virtualNetwork" + ResourceTypeSubnetDropdown = "azure.subnet" +) + +func (a *AzureIntegration) ListResourceGroups(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + provider, err := a.providerFromListResourcesContext(ctx) + if err != nil { + return nil, err + } + + pager := provider.GetResourceGroupsClient().NewListPager(nil) + resources := []core.IntegrationResource{} + + for pager.More() { + page, err := pager.NextPage(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to list resource groups: %w", err) + } + + for _, group := range page.Value { + if group == nil || group.Name == nil { + continue + } + + id := *group.Name + if group.ID != nil { + id = *group.ID + } + + resources = append(resources, core.IntegrationResource{ + Type: ResourceTypeResourceGroupDropdown, + Name: *group.Name, + ID: id, + }) + } + } + + sort.Slice(resources, func(i, j int) bool { + return strings.ToLower(resources[i].Name) < strings.ToLower(resources[j].Name) + }) + + return resources, nil +} + +func (a *AzureIntegration) ListVMSizes(ctx core.ListResourcesContext, location string) ([]core.IntegrationResource, error) { + if location == "" { + return []core.IntegrationResource{}, nil + } + + provider, err := a.providerFromListResourcesContext(ctx) + if err != nil { + return nil, err + } + + filter := fmt.Sprintf("location eq '%s'", strings.ToLower(location)) + pager := provider.GetResourceSKUsClient().NewListPager(&armcompute.ResourceSKUsClientListOptions{ + Filter: &filter, + }) + resourcesByID := map[string]core.IntegrationResource{} + + for pager.More() { + page, err := pager.NextPage(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to list VM sizes: %w", err) + } + + for _, sku := range page.Value { + if !isVirtualMachineSKU(sku) || !isSKUAvailableInLocation(sku, location) || sku.Name == nil { + continue + } + + resourcesByID[*sku.Name] = core.IntegrationResource{ + Type: ResourceTypeVMSizeDropdown, + Name: *sku.Name, + ID: *sku.Name, + } + } + } + + resources := make([]core.IntegrationResource, 0, len(resourcesByID)) + for _, item := range resourcesByID { + resources = append(resources, item) + } + + sort.Slice(resources, func(i, j int) bool { + return strings.ToLower(resources[i].Name) < strings.ToLower(resources[j].Name) + }) + + return resources, nil +} + +func (a *AzureIntegration) ListVirtualNetworks(ctx core.ListResourcesContext, resourceGroup string) ([]core.IntegrationResource, error) { + if resourceGroup == "" { + return []core.IntegrationResource{}, nil + } + resourceGroup = azureResourceName(resourceGroup) + + provider, err := a.providerFromListResourcesContext(ctx) + if err != nil { + return nil, err + } + + pager := provider.GetVirtualNetworksClient().NewListPager(resourceGroup, nil) + resources := []core.IntegrationResource{} + + for pager.More() { + page, err := pager.NextPage(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to list virtual networks: %w", err) + } + + for _, vnet := range page.Value { + if vnet.Name == nil { + continue + } + + id := *vnet.Name + if vnet.ID != nil { + id = *vnet.ID + } + + resources = append(resources, core.IntegrationResource{ + Type: ResourceTypeVirtualNetworkDropdown, + Name: *vnet.Name, + ID: id, + }) + } + } + + sort.Slice(resources, func(i, j int) bool { + return strings.ToLower(resources[i].Name) < strings.ToLower(resources[j].Name) + }) + + return resources, nil +} + +func (a *AzureIntegration) ListSubnets(ctx core.ListResourcesContext, resourceGroup, vnetName string) ([]core.IntegrationResource, error) { + if resourceGroup == "" || vnetName == "" { + return []core.IntegrationResource{}, nil + } + resourceGroup = azureResourceName(resourceGroup) + vnetName = azureResourceName(vnetName) + + provider, err := a.providerFromListResourcesContext(ctx) + if err != nil { + return nil, err + } + + pager := provider.GetSubnetsClient().NewListPager(resourceGroup, vnetName, nil) + resources := []core.IntegrationResource{} + + for pager.More() { + page, err := pager.NextPage(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to list subnets: %w", err) + } + + for _, subnet := range page.Value { + if subnet.Name == nil { + continue + } + + id := *subnet.Name + if subnet.ID != nil { + id = *subnet.ID + } + + resources = append(resources, core.IntegrationResource{ + Type: ResourceTypeSubnetDropdown, + Name: *subnet.Name, + ID: id, + }) + } + } + + sort.Slice(resources, func(i, j int) bool { + return strings.ToLower(resources[i].Name) < strings.ToLower(resources[j].Name) + }) + + return resources, nil +} + +func (a *AzureIntegration) providerFromListResourcesContext(ctx core.ListResourcesContext) (*AzureProvider, error) { + if a.provider != nil { + return a.provider, nil + } + + if ctx.Integration == nil { + return nil, fmt.Errorf("integration context is required") + } + + tenantID, err := ctx.Integration.GetConfig("tenantId") + if err != nil { + return nil, fmt.Errorf("failed to get tenant ID: %w", err) + } + + clientID, err := ctx.Integration.GetConfig("clientId") + if err != nil { + return nil, fmt.Errorf("failed to get client ID: %w", err) + } + + subscriptionID, err := ctx.Integration.GetConfig("subscriptionId") + if err != nil { + return nil, fmt.Errorf("failed to get subscription ID: %w", err) + } + + provider, err := NewAzureProvider( + context.Background(), + string(tenantID), + string(clientID), + string(subscriptionID), + ctx.Logger, + ) + if err != nil { + return nil, err + } + + a.provider = provider + return provider, nil +} + +func isVirtualMachineSKU(sku *armcompute.ResourceSKU) bool { + if sku == nil || sku.ResourceType == nil { + return false + } + + return strings.EqualFold(*sku.ResourceType, "virtualMachines") +} + +func isSKUAvailableInLocation(sku *armcompute.ResourceSKU, location string) bool { + if sku == nil { + return false + } + + for _, skuLocation := range sku.Locations { + if skuLocation != nil && strings.EqualFold(*skuLocation, location) { + return true + } + } + + return false +} + +// azureResourceName extracts the final resource name segment from an Azure resource ID. +// It handles plain names, full ARM IDs, and URL-encoded ARM IDs (e.g. from query parameters). +func azureResourceName(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + + // URL-decode first to handle values that arrive encoded from the query parser + // (e.g. %2Fsubscriptions%2F... instead of /subscriptions/...). + if decoded, err := url.QueryUnescape(trimmed); err == nil { + trimmed = decoded + } + + if !strings.Contains(trimmed, "/") { + return trimmed + } + + return path.Base(strings.TrimRight(trimmed, "/")) +} diff --git a/pkg/integrations/azure/trigger_on_vm_created.go b/pkg/integrations/azure/trigger_on_vm_created.go new file mode 100644 index 0000000000..75579c40f6 --- /dev/null +++ b/pkg/integrations/azure/trigger_on_vm_created.go @@ -0,0 +1,283 @@ +package azure + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnVMCreatedTrigger struct{} + +type OnVMCreatedConfiguration struct { + ResourceGroup string `json:"resourceGroup" mapstructure:"resourceGroup"` +} + +func (t *OnVMCreatedTrigger) Name() string { + return "azure.onVirtualMachineCreated" +} + +func (t *OnVMCreatedTrigger) Label() string { + return "Azure • On VM Created" +} + +func (t *OnVMCreatedTrigger) Description() string { + return "Triggers when a new Virtual Machine is successfully provisioned in Azure" +} + +func (t *OnVMCreatedTrigger) Documentation() string { + return ` +The On VM Created trigger starts a workflow execution when a new Azure Virtual Machine is successfully provisioned. + +## Use Cases + +- **Automated configuration**: Run configuration scripts on newly created VMs +- **Compliance checks**: Verify that new VMs meet security and compliance requirements +- **Inventory tracking**: Update external inventory systems when VMs are created +- **Notification workflows**: Send notifications to teams when new VMs are provisioned +- **Cost tracking**: Log VM creation events for cost analysis and reporting + +## How It Works + +This trigger listens to Azure Event Grid events for Virtual Machine resource write operations. +When a VM is successfully created (` + "`provisioningState: Succeeded`" + `), the trigger fires and +provides detailed information about the new VM. + +## Configuration + +- **Resource Group** (optional): Filter events to only trigger for VMs created in a specific + resource group. Leave empty to trigger for all resource groups in the subscription. + +## Event Data + +Each VM creation event includes: + +- **vmName**: The name of the created virtual machine +- **vmId**: The full Azure resource ID of the VM +- **resourceGroup**: The resource group containing the VM +- **subscriptionId**: The Azure subscription ID +- **location**: The Azure region where the VM was created +- **provisioningState**: The provisioning state (typically "Succeeded") +- **timestamp**: The timestamp when the event occurred + +## Azure Event Grid Setup + +**Important**: This trigger requires manual setup of an Azure Event Grid subscription. + +1. **Create an Event Grid System Topic** (if not already created): + - Go to Azure Portal → Event Grid System Topics + - Create a new topic for your subscription + - Topic Type: "Azure Subscriptions" + - Select your subscription + +2. **Create an Event Subscription**: + - In your Event Grid System Topic, create a new Event Subscription + - **Event Types**: Select "Resource Write Success" + - **Filters**: + - Subject begins with: ` + "`/subscriptions//resourceGroups/`" + ` + - Subject ends with: ` + "`/providers/Microsoft.Compute/virtualMachines/`" + ` + - **Endpoint Type**: Webhook + - **Endpoint**: Use the webhook URL provided by SuperPlane for this trigger node + +3. **Validation**: Azure Event Grid will send a validation event to verify the endpoint. + SuperPlane will automatically respond to this validation request. + +## Notes + +- The trigger only fires for successfully provisioned VMs (` + "`provisioningState: Succeeded`" + `) +- Failed VM creations do not trigger the workflow +- The trigger processes events from Azure Event Grid in real-time +- Multiple triggers can share the same Event Grid subscription if configured correctly +` +} + +func (t *OnVMCreatedTrigger) Icon() string { + return "azure" +} + +func (t *OnVMCreatedTrigger) Color() string { + return "blue" +} + +func (t *OnVMCreatedTrigger) ExampleData() map[string]any { + return map[string]any{ + "vmName": "my-vm-01", + "vmId": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm-01", + "resourceGroup": "my-rg", + "subscriptionId": "12345678-1234-1234-1234-123456789abc", + "location": "eastus", + "provisioningState": "Succeeded", + "timestamp": "2026-02-11T10:30:00Z", + "operationName": "Microsoft.Compute/virtualMachines/write", + } +} + +func (t *OnVMCreatedTrigger) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "resourceGroup", + Label: "Resource Group", + Type: configuration.FieldTypeString, + Required: false, + Description: "Filter events to a specific resource group (optional - leave empty for all resource groups)", + Placeholder: "my-resource-group", + }, + } +} + +// Setup configures trigger webhooks. +func (t *OnVMCreatedTrigger) Setup(ctx core.TriggerContext) error { + // Decode configuration + config := OnVMCreatedConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if ctx.Integration != nil { + err := ctx.Integration.RequestWebhook(AzureWebhookConfiguration{ + EventTypes: []string{EventTypeResourceWriteSuccess}, + ResourceType: ResourceTypeVirtualMachine, + ResourceGroup: config.ResourceGroup, + }) + if err != nil { + return fmt.Errorf("failed to request webhook: %w", err) + } + } else { + ctx.Logger.Warn("Integration context missing; skipping webhook request") + } + + ctx.Logger.Info("Azure VM Created trigger configured successfully") + if config.ResourceGroup != "" { + ctx.Logger.Infof("Filtering events for resource group: %s", config.ResourceGroup) + } else { + ctx.Logger.Info("Listening for VM creation events in all resource groups") + } + + return nil +} + +// HandleWebhook processes Event Grid webhook requests. +func (t *OnVMCreatedTrigger) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + // Decode configuration + config := OnVMCreatedConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + // Parse Event Grid events + var events []EventGridEvent + if err := json.Unmarshal(ctx.Body, &events); err != nil { + ctx.Logger.Errorf("Failed to parse Event Grid events: %v", err) + return http.StatusBadRequest, fmt.Errorf("failed to parse events: %w", err) + } + + ctx.Logger.Infof("Received %d Event Grid event(s)", len(events)) + + for _, event := range events { + if event.EventType == EventTypeSubscriptionValidation { + if err := t.handleSubscriptionValidation(ctx, event); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil + } + + if event.EventType == EventTypeResourceWriteSuccess { + if err := t.handleVMCreationEvent(ctx, event, config); err != nil { + ctx.Logger.Errorf("Failed to process VM creation event: %v", err) + continue + } + } + } + + return http.StatusOK, nil +} + +// handleSubscriptionValidation validates Event Grid subscription setup. +func (t *OnVMCreatedTrigger) handleSubscriptionValidation(ctx core.WebhookRequestContext, event EventGridEvent) error { + var validationData SubscriptionValidationEventData + if err := mapstructure.Decode(event.Data, &validationData); err != nil { + return fmt.Errorf("failed to parse validation data: %w", err) + } + + ctx.Logger.Infof("Responding to Event Grid subscription validation with code: %s", validationData.ValidationCode) + + return nil +} + +// handleVMCreationEvent processes VM creation events. +func (t *OnVMCreatedTrigger) handleVMCreationEvent( + ctx core.WebhookRequestContext, + event EventGridEvent, + config OnVMCreatedConfiguration, +) error { + if !strings.Contains(event.Subject, ResourceTypeVirtualMachine) { + return nil + } + + var eventData ResourceWriteSuccessData + if err := mapstructure.Decode(event.Data, &eventData); err != nil { + return fmt.Errorf("failed to parse event data: %w", err) + } + + if eventData.ProvisioningState != ProvisioningStateSucceeded { + ctx.Logger.Infof("Skipping VM event with provisioning state: %s", eventData.ProvisioningState) + return nil + } + + resourceGroup := "" + parts := strings.Split(event.Subject, "/") + for i, part := range parts { + if part == "resourceGroups" && i+1 < len(parts) { + resourceGroup = parts[i+1] + break + } + } + + if config.ResourceGroup != "" && resourceGroup != config.ResourceGroup { + ctx.Logger.Debugf("Skipping VM event for resource group %s (filter: %s)", resourceGroup, config.ResourceGroup) + return nil + } + + vmName := "" + if len(parts) > 0 { + vmName = parts[len(parts)-1] + } + + payload := map[string]any{ + "vmName": vmName, + "vmId": event.Subject, + "resourceGroup": resourceGroup, + "subscriptionId": eventData.SubscriptionID, + "location": "", + "provisioningState": eventData.ProvisioningState, + "timestamp": event.EventTime, + "operationName": eventData.OperationName, + "status": eventData.Status, + } + + ctx.Logger.Infof("VM created: %s in resource group %s", vmName, resourceGroup) + + if err := ctx.Events.Emit("azure.vm.created", payload); err != nil { + return fmt.Errorf("failed to emit event: %w", err) + } + + return nil +} + +func (t *OnVMCreatedTrigger) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnVMCreatedTrigger) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +// Cleanup is called when the trigger is removed. +func (t *OnVMCreatedTrigger) Cleanup(ctx core.TriggerContext) error { + ctx.Logger.Info("Cleaning up Azure VM Created trigger") + return nil +} diff --git a/pkg/integrations/azure/trigger_vm_created_test.go b/pkg/integrations/azure/trigger_vm_created_test.go new file mode 100644 index 0000000000..7c25a1f9fe --- /dev/null +++ b/pkg/integrations/azure/trigger_vm_created_test.go @@ -0,0 +1,600 @@ +package azure + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +// TestOnVMCreatedTrigger_Metadata verifies the trigger's metadata methods +func TestOnVMCreatedTrigger_Metadata(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + assert.Equal(t, "azure.onVirtualMachineCreated", trigger.Name()) + assert.Equal(t, "Azure • On VM Created", trigger.Label()) + assert.Equal(t, "azure", trigger.Icon()) + assert.Equal(t, "blue", trigger.Color()) + assert.NotEmpty(t, trigger.Description()) + assert.NotEmpty(t, trigger.Documentation()) +} + +// TestOnVMCreatedTrigger_Configuration verifies the trigger's configuration fields +func TestOnVMCreatedTrigger_Configuration(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + config := trigger.Configuration() + + require.Len(t, config, 1) + assert.Equal(t, "resourceGroup", config[0].Name) + assert.Equal(t, "Resource Group", config[0].Label) + assert.False(t, config[0].Required) +} + +// TestOnVMCreatedTrigger_ExampleData verifies the trigger's example output +func TestOnVMCreatedTrigger_ExampleData(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + example := trigger.ExampleData() + + require.NotNil(t, example) + assert.Contains(t, example, "vmName") + assert.Contains(t, example, "vmId") + assert.Contains(t, example, "resourceGroup") + assert.Contains(t, example, "subscriptionId") + assert.Contains(t, example, "provisioningState") +} + +// TestOnVMCreatedTrigger_Setup verifies the trigger setup method +func TestOnVMCreatedTrigger_Setup(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + t.Run("setup with no resource group filter", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.TriggerContext{ + Logger: logger, + Configuration: map[string]any{}, + Metadata: metadataCtx, + } + + err := trigger.Setup(ctx) + assert.NoError(t, err) + }) + + t.Run("setup with resource group filter", func(t *testing.T) { + metadataCtx := &contexts.MetadataContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.TriggerContext{ + Logger: logger, + Configuration: map[string]any{ + "resourceGroup": "my-rg", + }, + Metadata: metadataCtx, + } + + err := trigger.Setup(ctx) + assert.NoError(t, err) + }) +} + +// TestOnVMCreatedTrigger_Cleanup verifies the trigger cleanup method +func TestOnVMCreatedTrigger_Cleanup(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + metadataCtx := &contexts.MetadataContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.TriggerContext{ + Logger: logger, + Configuration: map[string]any{}, + Metadata: metadataCtx, + } + + err := trigger.Cleanup(ctx) + assert.NoError(t, err) +} + +// TestOnVMCreatedTrigger_Actions verifies the trigger has no actions +func TestOnVMCreatedTrigger_Actions(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + actions := trigger.Actions() + assert.Empty(t, actions) +} + +// TestOnVMCreatedTrigger_HandleAction verifies the trigger's action handler +func TestOnVMCreatedTrigger_HandleAction(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.TriggerActionContext{ + Name: "test", + Parameters: map[string]any{}, + Configuration: map[string]any{}, + Logger: logger, + } + + result, err := trigger.HandleAction(ctx) + assert.NoError(t, err) + assert.Nil(t, result) +} + +// TestOnVMCreatedTrigger_HandleWebhook_SubscriptionValidation verifies subscription validation handling +func TestOnVMCreatedTrigger_HandleWebhook_SubscriptionValidation(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + validationCode := "test-validation-code-12345" + events := []EventGridEvent{ + { + ID: "validation-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "", + EventType: EventTypeSubscriptionValidation, + EventTime: time.Now(), + DataVersion: "1.0", + MetadataVersion: "1", + Data: map[string]any{ + "validationCode": validationCode, + }, + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Subscription validation should not emit any events to the workflow + assert.Equal(t, 0, eventsCtx.Count()) +} + +// TestOnVMCreatedTrigger_HandleWebhook_VMCreatedSuccess verifies VM creation event handling +func TestOnVMCreatedTrigger_HandleWebhook_VMCreatedSuccess(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + t.Run("VM created with no filter", func(t *testing.T) { + events := []EventGridEvent{ + { + ID: "vm-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Succeeded", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should emit one event + require.Equal(t, 1, eventsCtx.Count()) + assert.Equal(t, "azure.vm.created", eventsCtx.Payloads[0].Type) + + // Verify payload + payload, ok := eventsCtx.Payloads[0].Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, "test-vm", payload["vmName"]) + assert.Equal(t, "test-rg", payload["resourceGroup"]) + assert.Equal(t, "test-sub", payload["subscriptionId"]) + assert.Equal(t, ProvisioningStateSucceeded, payload["provisioningState"]) + }) + + t.Run("VM created with matching resource group filter", func(t *testing.T) { + events := []EventGridEvent{ + { + ID: "vm-event-2", + Topic: "/subscriptions/test-sub/resourceGroups/my-target-rg", + Subject: "/subscriptions/test-sub/resourceGroups/my-target-rg/providers/Microsoft.Compute/virtualMachines/test-vm-2", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Succeeded", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "resourceGroup": "my-target-rg", + }, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should emit one event + require.Equal(t, 1, eventsCtx.Count()) + assert.Equal(t, "azure.vm.created", eventsCtx.Payloads[0].Type) + + // Verify resource group in payload + payload, ok := eventsCtx.Payloads[0].Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, "my-target-rg", payload["resourceGroup"]) + }) +} + +// TestOnVMCreatedTrigger_HandleWebhook_FilterMismatch verifies resource group filtering +func TestOnVMCreatedTrigger_HandleWebhook_FilterMismatch(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + events := []EventGridEvent{ + { + ID: "vm-event-3", + Topic: "/subscriptions/test-sub/resourceGroups/rg-other", + Subject: "/subscriptions/test-sub/resourceGroups/rg-other/providers/Microsoft.Compute/virtualMachines/test-vm-other", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Succeeded", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "resourceGroup": "rg-target", // Different from rg-other + }, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should NOT emit any events due to filter mismatch + assert.Equal(t, 0, eventsCtx.Count()) +} + +// TestOnVMCreatedTrigger_HandleWebhook_NonVMResource verifies non-VM resource filtering +func TestOnVMCreatedTrigger_HandleWebhook_NonVMResource(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + t.Run("storage account creation", func(t *testing.T) { + events := []EventGridEvent{ + { + ID: "storage-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorage", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Storage/storageAccounts/write", + "status": "Succeeded", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should NOT emit any events - not a VM + assert.Equal(t, 0, eventsCtx.Count()) + }) + + t.Run("network interface creation", func(t *testing.T) { + events := []EventGridEvent{ + { + ID: "nic-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Network/networkInterfaces/test-nic", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Network/networkInterfaces/write", + "status": "Succeeded", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should NOT emit any events - not a VM + assert.Equal(t, 0, eventsCtx.Count()) + }) +} + +// TestOnVMCreatedTrigger_HandleWebhook_ProvisioningStateFailed verifies failed VM creation handling +func TestOnVMCreatedTrigger_HandleWebhook_ProvisioningStateFailed(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + events := []EventGridEvent{ + { + ID: "vm-event-failed", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/failed-vm", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateFailed, + "subscriptionId": "test-sub", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Failed", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should NOT emit any events - provisioning failed + assert.Equal(t, 0, eventsCtx.Count()) +} + +// TestOnVMCreatedTrigger_HandleWebhook_MultipleEvents verifies handling multiple events in one batch +func TestOnVMCreatedTrigger_HandleWebhook_MultipleEvents(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + events := []EventGridEvent{ + { + ID: "vm-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-1", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + { + ID: "vm-event-2", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-2", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + { + ID: "storage-event", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststorage", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + "subscriptionId": "test-sub", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, code) + + // Should emit two events (only the VM events, not the storage account) + assert.Equal(t, 2, eventsCtx.Count()) + assert.Equal(t, "azure.vm.created", eventsCtx.Payloads[0].Type) + assert.Equal(t, "azure.vm.created", eventsCtx.Payloads[1].Type) +} + +// TestOnVMCreatedTrigger_HandleWebhook_InvalidJSON verifies error handling for invalid JSON +func TestOnVMCreatedTrigger_HandleWebhook_InvalidJSON(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: []byte("invalid json"), + Headers: http.Header{}, + Configuration: map[string]any{}, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.Error(t, err) + assert.Equal(t, http.StatusBadRequest, code) + assert.Equal(t, 0, eventsCtx.Count()) +} + +// TestOnVMCreatedTrigger_HandleWebhook_InvalidConfiguration verifies error handling for invalid configuration +func TestOnVMCreatedTrigger_HandleWebhook_InvalidConfiguration(t *testing.T) { + trigger := &OnVMCreatedTrigger{} + + events := []EventGridEvent{ + { + ID: "vm-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": ProvisioningStateSucceeded, + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + eventsCtx := &contexts.EventContext{} + webhookCtx := &contexts.WebhookContext{} + logger := logrus.NewEntry(logrus.New()) + + ctx := core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "resourceGroup": 123, // Invalid type - should be string + }, + Webhook: webhookCtx, + Events: eventsCtx, + Logger: logger, + } + + code, err := trigger.HandleWebhook(ctx) + assert.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, code) +} + diff --git a/pkg/integrations/azure/webhook_events.go b/pkg/integrations/azure/webhook_events.go new file mode 100644 index 0000000000..c41414194d --- /dev/null +++ b/pkg/integrations/azure/webhook_events.go @@ -0,0 +1,162 @@ +package azure + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/sirupsen/logrus" +) + +// HandleWebhook processes Event Grid webhook requests. +func HandleWebhook(w http.ResponseWriter, r *http.Request, logger *logrus.Entry) error { + body, err := io.ReadAll(r.Body) + if err != nil { + logger.Errorf("Failed to read request body: %v", err) + http.Error(w, "failed to read request body", http.StatusBadRequest) + return fmt.Errorf("failed to read request body: %w", err) + } + defer r.Body.Close() + + var events []EventGridEvent + if err := json.Unmarshal(body, &events); err != nil { + logger.Errorf("Failed to parse Event Grid events: %v", err) + http.Error(w, "invalid event format", http.StatusBadRequest) + return fmt.Errorf("failed to parse events: %w", err) + } + + logger.Infof("Received %d Event Grid event(s)", len(events)) + + for _, event := range events { + logger.Infof("Processing event: type=%s, subject=%s, id=%s", event.EventType, event.Subject, event.ID) + + switch event.EventType { + case EventTypeSubscriptionValidation: + return handleSubscriptionValidation(w, event, logger) + + case EventTypeResourceWriteSuccess: + if err := handleResourceWriteSuccess(event, logger); err != nil { + logger.Errorf("Failed to handle resource write success: %v", err) + } + + default: + logger.Infof("Ignoring event type: %s", event.EventType) + } + } + + w.WriteHeader(http.StatusOK) + return nil +} + +// handleSubscriptionValidation responds to Event Grid validation events. +func handleSubscriptionValidation(w http.ResponseWriter, event EventGridEvent, logger *logrus.Entry) error { + logger.Info("Handling Event Grid subscription validation") + + var validationData SubscriptionValidationEventData + if err := mapstructure.Decode(event.Data, &validationData); err != nil { + logger.Errorf("Failed to decode validation data: %v", err) + http.Error(w, "invalid validation data", http.StatusBadRequest) + return fmt.Errorf("failed to decode validation data: %w", err) + } + + if validationData.ValidationCode == "" { + logger.Error("Validation code is empty") + http.Error(w, "validation code is empty", http.StatusBadRequest) + return fmt.Errorf("validation code is empty") + } + + logger.Infof("Validation code received: %s", validationData.ValidationCode) + + response := SubscriptionValidationResponse{ + ValidationResponse: validationData.ValidationCode, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(response); err != nil { + logger.Errorf("Failed to encode validation response: %v", err) + return fmt.Errorf("failed to encode validation response: %w", err) + } + + logger.Info("Subscription validation response sent successfully") + return nil +} + +// handleResourceWriteSuccess processes VM resource write success events. +func handleResourceWriteSuccess(event EventGridEvent, logger *logrus.Entry) error { + if !strings.Contains(event.Subject, ResourceTypeVirtualMachine) { + logger.Infof("Skipping non-VM resource: %s", event.Subject) + return nil + } + + var resourceData ResourceWriteSuccessData + if err := mapstructure.Decode(event.Data, &resourceData); err != nil { + return fmt.Errorf("failed to decode resource data: %w", err) + } + + logger.Infof("Resource write success: provisioning_state=%s, resource=%s", + resourceData.ProvisioningState, event.Subject) + + if resourceData.ProvisioningState != ProvisioningStateSucceeded { + logger.Infof("VM not in Succeeded state, current state: %s", resourceData.ProvisioningState) + return nil + } + + vmID := event.Subject + vmName := extractVMName(vmID) + + logger.Infof("VM created successfully: name=%s, id=%s, subscription=%s", + vmName, vmID, resourceData.SubscriptionID) + + if resourceData.Authorization != nil { + logger.Infof("Operation authorized: action=%s, scope=%s", + resourceData.Authorization.Action, resourceData.Authorization.Scope) + } + + return nil +} + +// extractVMName returns VM name from ARM resource ID. +func extractVMName(resourceID string) string { + parts := strings.Split(resourceID, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +// extractResourceGroup returns resource group from ARM resource ID. +func extractResourceGroup(resourceID string) string { + parts := strings.Split(resourceID, "/") + for i, part := range parts { + if part == "resourceGroups" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// extractSubscriptionID returns subscription ID from ARM resource ID. +func extractSubscriptionID(resourceID string) string { + parts := strings.Split(resourceID, "/") + for i, part := range parts { + if part == "subscriptions" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// isVirtualMachineEvent reports whether an event subject targets a VM. +func isVirtualMachineEvent(subject string) bool { + return strings.Contains(subject, ResourceTypeVirtualMachine) +} + +// isSuccessfulProvisioning reports whether provisioning succeeded. +func isSuccessfulProvisioning(provisioningState string) bool { + return provisioningState == ProvisioningStateSucceeded +} diff --git a/pkg/integrations/azure/webhook_events_test.go b/pkg/integrations/azure/webhook_events_test.go new file mode 100644 index 0000000000..7361abf170 --- /dev/null +++ b/pkg/integrations/azure/webhook_events_test.go @@ -0,0 +1,384 @@ +package azure + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleWebhook_SubscriptionValidation(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create a subscription validation event + validationCode := "test-validation-code-12345" + events := []EventGridEvent{ + { + ID: "validation-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "", + EventType: EventTypeSubscriptionValidation, + EventTime: time.Now(), + DataVersion: "1.0", + MetadataVersion: "1", + Data: map[string]any{ + "validationCode": validationCode, + }, + }, + } + + // Create request + body, err := json.Marshal(events) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Handle webhook + err = HandleWebhook(rec, req, logger) + assert.NoError(t, err) + + // Verify response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + // Parse response + var response SubscriptionValidationResponse + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, validationCode, response.ValidationResponse) +} + +func TestHandleWebhook_ResourceWriteSuccess_VM(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create a VM creation success event + events := []EventGridEvent{ + { + ID: "vm-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": "Succeeded", + "resourceProvider": "Microsoft.Compute", + "resourceUri": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm", + "operationName": "Microsoft.Compute/virtualMachines/write", + "status": "Succeeded", + "subscriptionId": "test-sub", + "tenantId": "test-tenant", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + // Create request + body, err := json.Marshal(events) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Handle webhook + err = HandleWebhook(rec, req, logger) + assert.NoError(t, err) + + // Verify response + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandleWebhook_ResourceWriteSuccess_NonVM(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create a storage account event (should be ignored) + events := []EventGridEvent{ + { + ID: "storage-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststore", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": "Succeeded", + "resourceProvider": "Microsoft.Storage", + "resourceUri": "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/teststore", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + // Create request + body, err := json.Marshal(events) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Handle webhook + err = HandleWebhook(rec, req, logger) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandleWebhook_MultipleEvents(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create multiple events + events := []EventGridEvent{ + { + ID: "vm-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-1", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": "Succeeded", + "subscriptionId": "test-sub", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + { + ID: "vm-event-2", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/test-vm-2", + EventType: EventTypeResourceWriteSuccess, + EventTime: time.Now(), + Data: map[string]any{ + "provisioningState": "Succeeded", + "subscriptionId": "test-sub", + }, + DataVersion: "1.0", + MetadataVersion: "1", + }, + } + + // Create request + body, err := json.Marshal(events) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Handle webhook + err = HandleWebhook(rec, req, logger) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandleWebhook_InvalidJSON(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create invalid JSON + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + // Handle webhook + err := HandleWebhook(rec, req, logger) + assert.Error(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestHandleWebhook_EmptyValidationCode(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + + // Create validation event with empty code + events := []EventGridEvent{ + { + ID: "validation-event-1", + Topic: "/subscriptions/test-sub/resourceGroups/test-rg", + Subject: "", + EventType: EventTypeSubscriptionValidation, + EventTime: time.Now(), + DataVersion: "1.0", + MetadataVersion: "1", + Data: map[string]any{ + "validationCode": "", + }, + }, + } + + body, err := json.Marshal(events) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + err = HandleWebhook(rec, req, logger) + assert.Error(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestExtractVMName(t *testing.T) { + tests := []struct { + name string + resourceID string + expected string + }{ + { + name: "valid VM resource ID", + resourceID: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.Compute/virtualMachines/my-vm", + expected: "my-vm", + }, + { + name: "empty resource ID", + resourceID: "", + expected: "", + }, + { + name: "single segment", + resourceID: "vm-name", + expected: "vm-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractVMName(tt.resourceID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractResourceGroup(t *testing.T) { + tests := []struct { + name string + resourceID string + expected string + }{ + { + name: "valid resource ID", + resourceID: "/subscriptions/test-sub/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm", + expected: "my-rg", + }, + { + name: "no resource group", + resourceID: "/subscriptions/test-sub", + expected: "", + }, + { + name: "empty resource ID", + resourceID: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractResourceGroup(tt.resourceID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractSubscriptionID(t *testing.T) { + tests := []struct { + name string + resourceID string + expected string + }{ + { + name: "valid resource ID", + resourceID: "/subscriptions/my-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm", + expected: "my-subscription-id", + }, + { + name: "no subscription", + resourceID: "/resourceGroups/my-rg", + expected: "", + }, + { + name: "empty resource ID", + resourceID: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractSubscriptionID(tt.resourceID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsVirtualMachineEvent(t *testing.T) { + tests := []struct { + name string + subject string + expected bool + }{ + { + name: "VM event", + subject: "/subscriptions/test/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm1", + expected: true, + }, + { + name: "storage event", + subject: "/subscriptions/test/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage1", + expected: false, + }, + { + name: "empty subject", + subject: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVirtualMachineEvent(tt.subject) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsSuccessfulProvisioning(t *testing.T) { + tests := []struct { + name string + provisioningState string + expected bool + }{ + { + name: "succeeded", + provisioningState: ProvisioningStateSucceeded, + expected: true, + }, + { + name: "failed", + provisioningState: ProvisioningStateFailed, + expected: false, + }, + { + name: "creating", + provisioningState: ProvisioningStateCreating, + expected: false, + }, + { + name: "empty", + provisioningState: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSuccessfulProvisioning(tt.provisioningState) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/integrations/azure/webhook_handler.go b/pkg/integrations/azure/webhook_handler.go new file mode 100644 index 0000000000..63180259be --- /dev/null +++ b/pkg/integrations/azure/webhook_handler.go @@ -0,0 +1,107 @@ +package azure + +import ( + "fmt" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +// AzureWebhookConfiguration defines how a webhook should be configured for Azure triggers. +type AzureWebhookConfiguration struct { + EventTypes []string `json:"eventTypes" mapstructure:"eventTypes"` + ResourceType string `json:"resourceType" mapstructure:"resourceType"` + ResourceGroup string `json:"resourceGroup,omitempty" mapstructure:"resourceGroup"` +} + +// AzureWebhookHandler manages webhook lifecycle for Azure integration triggers. +// Event Grid subscription setup is currently manual, so setup/cleanup are no-ops. +type AzureWebhookHandler struct{} + +func (h *AzureWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + ctx.Logger.Infof("Azure webhook ready at %s (manual Event Grid setup required)", ctx.Webhook.GetURL()) + return map[string]any{"mode": "manual", "url": ctx.Webhook.GetURL()}, nil +} + +func (h *AzureWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + ctx.Logger.Info("Azure webhook cleanup completed (no external resources to remove)") + return nil +} + +func (h *AzureWebhookHandler) CompareConfig(a, b any) (bool, error) { + left, err := decodeAzureWebhookConfiguration(a) + if err != nil { + return false, err + } + + right, err := decodeAzureWebhookConfiguration(b) + if err != nil { + return false, err + } + + slices.Sort(left.EventTypes) + slices.Sort(right.EventTypes) + + if left.ResourceType != right.ResourceType { + return false, nil + } + + if left.ResourceGroup != right.ResourceGroup { + return false, nil + } + + if len(left.EventTypes) != len(right.EventTypes) { + return false, nil + } + + for i := range left.EventTypes { + if left.EventTypes[i] != right.EventTypes[i] { + return false, nil + } + } + + return true, nil +} + +func (h *AzureWebhookHandler) Merge(current, requested any) (merged any, changed bool, err error) { + currentConfig, err := decodeAzureWebhookConfiguration(current) + if err != nil { + return nil, false, err + } + + requestedConfig, err := decodeAzureWebhookConfiguration(requested) + if err != nil { + return nil, false, err + } + + // Keep webhook semantics deterministic: if configs differ, prefer requested. + equal, err := h.CompareConfig(currentConfig, requestedConfig) + if err != nil { + return nil, false, err + } + + if equal { + return currentConfig, false, nil + } + + return requestedConfig, true, nil +} + +func decodeAzureWebhookConfiguration(raw any) (AzureWebhookConfiguration, error) { + config := AzureWebhookConfiguration{} + if err := mapstructure.Decode(raw, &config); err != nil { + return AzureWebhookConfiguration{}, fmt.Errorf("failed to decode webhook configuration: %w", err) + } + + if config.ResourceType == "" { + return AzureWebhookConfiguration{}, fmt.Errorf("resourceType is required") + } + + if len(config.EventTypes) == 0 { + return AzureWebhookConfiguration{}, fmt.Errorf("eventTypes is required") + } + + return config, nil +} + From cf722f6654fba0506410fecf14dab50d3fdbfbd3 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:51:13 +0300 Subject: [PATCH 02/10] feat(server): register Azure integration Signed-off-by: Tamiru Alemnew --- pkg/server/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/server/server.go b/pkg/server/server.go index 78bf5e69eb..8c2d8cb58f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -33,6 +33,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/components/timegate" _ "github.com/superplanehq/superplane/pkg/components/wait" _ "github.com/superplanehq/superplane/pkg/integrations/aws" + _ "github.com/superplanehq/superplane/pkg/integrations/azure" _ "github.com/superplanehq/superplane/pkg/integrations/claude" _ "github.com/superplanehq/superplane/pkg/integrations/cloudflare" _ "github.com/superplanehq/superplane/pkg/integrations/dash0" From d2fb097c8fd18c56187b1309a661c74dd5f5c590 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:51:43 +0300 Subject: [PATCH 03/10] feat(azure-ui): add workflow mappers for Azure Signed-off-by: Tamiru Alemnew --- web_src/src/pages/workflowv2/mappers/azure/index.ts | 10 ++++++++++ web_src/src/pages/workflowv2/mappers/index.ts | 8 ++++++++ 2 files changed, 18 insertions(+) create mode 100644 web_src/src/pages/workflowv2/mappers/azure/index.ts diff --git a/web_src/src/pages/workflowv2/mappers/azure/index.ts b/web_src/src/pages/workflowv2/mappers/azure/index.ts new file mode 100644 index 0000000000..8e1ed84aa0 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/azure/index.ts @@ -0,0 +1,10 @@ +import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = {}; + +export const triggerRenderers: Record = {}; + +export const eventStateRegistry: Record = { + createVirtualMachine: buildActionStateRegistry("created"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 1060a598b1..1f92db89d6 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -85,6 +85,11 @@ import { triggerRenderers as awsTriggerRenderers, eventStateRegistry as awsEventStateRegistry, } from "./aws"; +import { + componentMappers as azureComponentMappers, + triggerRenderers as azureTriggerRenderers, + eventStateRegistry as azureEventStateRegistry, +} from "./azure/index"; import { timeGateMapper, TIME_GATE_STATE_REGISTRY } from "./timegate"; import { componentMappers as discordComponentMappers, @@ -154,6 +159,7 @@ const appMappers: Record> = { render: renderComponentMappers, rootly: rootlyComponentMappers, aws: awsComponentMappers, + azure: azureComponentMappers, discord: discordComponentMappers, openai: openaiComponentMappers, claude: claudeComponentMappers, @@ -175,6 +181,7 @@ const appTriggerRenderers: Record> = { render: renderTriggerRenderers, rootly: rootlyTriggerRenderers, aws: awsTriggerRenderers, + azure: azureTriggerRenderers, discord: discordTriggerRenderers, openai: openaiTriggerRenderers, claude: claudeTriggerRenderers, @@ -198,6 +205,7 @@ const appEventStateRegistries: Record openai: openaiEventStateRegistry, claude: claudeEventStateRegistry, aws: awsEventStateRegistry, + azure: azureEventStateRegistry, gitlab: gitlabEventStateRegistry, dockerhub: dockerhubEventStateRegistry, }; From dce6e6cabd9720e502f0ef765628785468c2cd5d Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:51:59 +0300 Subject: [PATCH 04/10] feat(docs): generate Microsoft Azure component docs Signed-off-by: Tamiru Alemnew --- docs/components/Microsoft Azure.mdx | 203 ++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/components/Microsoft Azure.mdx diff --git a/docs/components/Microsoft Azure.mdx b/docs/components/Microsoft Azure.mdx new file mode 100644 index 0000000000..caabb7fc06 --- /dev/null +++ b/docs/components/Microsoft Azure.mdx @@ -0,0 +1,203 @@ +--- +title: "Microsoft Azure" +--- + +Manage and automate Microsoft Azure resources and services + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +## Azure Workload Identity Federation Setup + +To connect SuperPlane to Microsoft Azure using Workload Identity Federation: + +### 1. Create or Select an App Registration + +1. Go to **Azure Portal** → **Azure Active Directory** → **App registrations** +2. Create a new registration or select an existing app +3. Note the **Application (client) ID** and **Directory (tenant) ID** + +### 2. Configure Federated Identity Credential + +1. In your app registration, go to **Certificates & secrets** → **Federated credentials** +2. Click **Add credential** +3. Select **Other issuer** +4. Configure the credential: + - **Issuer**: The SuperPlane OIDC issuer URL (provided after creation) + - **Subject identifier**: `app-installation:` (provided after creation) + - **Audience**: The integration ID (provided after creation) + - **Name**: `superplane-integration` (or any descriptive name) + +### 3. Grant Required Permissions + +Assign appropriate Azure RBAC roles to your app registration: + +- **Virtual Machine Contributor** - For VM management +- **Network Contributor** - For network resource management +- **Storage Account Contributor** - For storage operations (if needed) +- **EventGrid Contributor** - For Event Grid subscriptions + +You can assign these roles at the subscription or resource group level. + +### 4. Complete the Connection + +Enter the following information below: +- **Tenant ID**: Your Azure AD tenant ID +- **Client ID**: Your app registration's client ID +- **Subscription ID**: Your Azure subscription ID + +SuperPlane will use Workload Identity Federation to authenticate without storing any credentials. + + + +## Azure • On VM Created + +The On VM Created trigger starts a workflow execution when a new Azure Virtual Machine is successfully provisioned. + +### Use Cases + +- **Automated configuration**: Run configuration scripts on newly created VMs +- **Compliance checks**: Verify that new VMs meet security and compliance requirements +- **Inventory tracking**: Update external inventory systems when VMs are created +- **Notification workflows**: Send notifications to teams when new VMs are provisioned +- **Cost tracking**: Log VM creation events for cost analysis and reporting + +### How It Works + +This trigger listens to Azure Event Grid events for Virtual Machine resource write operations. +When a VM is successfully created (`provisioningState: Succeeded`), the trigger fires and +provides detailed information about the new VM. + +### Configuration + +- **Resource Group** (optional): Filter events to only trigger for VMs created in a specific + resource group. Leave empty to trigger for all resource groups in the subscription. + +### Event Data + +Each VM creation event includes: + +- **vmName**: The name of the created virtual machine +- **vmId**: The full Azure resource ID of the VM +- **resourceGroup**: The resource group containing the VM +- **subscriptionId**: The Azure subscription ID +- **location**: The Azure region where the VM was created +- **provisioningState**: The provisioning state (typically "Succeeded") +- **timestamp**: The timestamp when the event occurred + +### Azure Event Grid Setup + +**Important**: This trigger requires manual setup of an Azure Event Grid subscription. + +1. **Create an Event Grid System Topic** (if not already created): + - Go to Azure Portal → Event Grid System Topics + - Create a new topic for your subscription + - Topic Type: "Azure Subscriptions" + - Select your subscription + +2. **Create an Event Subscription**: + - In your Event Grid System Topic, create a new Event Subscription + - **Event Types**: Select "Resource Write Success" + - **Filters**: + - Subject begins with: `/subscriptions//resourceGroups/` + - Subject ends with: `/providers/Microsoft.Compute/virtualMachines/` + - **Endpoint Type**: Webhook + - **Endpoint**: Use the webhook URL provided by SuperPlane for this trigger node + +3. **Validation**: Azure Event Grid will send a validation event to verify the endpoint. + SuperPlane will automatically respond to this validation request. + +### Notes + +- The trigger only fires for successfully provisioned VMs (`provisioningState: Succeeded`) +- Failed VM creations do not trigger the workflow +- The trigger processes events from Azure Event Grid in real-time +- Multiple triggers can share the same Event Grid subscription if configured correctly + +### Example Data + +```json +{ + "location": "eastus", + "operationName": "Microsoft.Compute/virtualMachines/write", + "provisioningState": "Succeeded", + "resourceGroup": "my-rg", + "subscriptionId": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-02-11T10:30:00Z", + "vmId": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm-01", + "vmName": "my-vm-01" +} +``` + + + +## Azure • Create Virtual Machine + +The Create Virtual Machine component creates a new Azure VM with full configuration options. + +### Use Cases + +- **Infrastructure provisioning**: Automatically create VMs as part of deployment workflows +- **Development environments**: Spin up temporary VMs for testing and development +- **Auto-scaling**: Create VMs in response to load or events +- **Disaster recovery**: Quickly provision replacement VMs + +### How It Works + +1. Validates the VM configuration parameters +2. Initiates VM creation via the Azure Compute API +3. Waits for the VM to be fully provisioned (using Azure's Long-Running Operation pattern) +4. Returns the VM details including ID, name, and provisioning state + +### Configuration + +- **Resource Group**: The Azure resource group where the VM will be created +- **Name**: The name for the new virtual machine +- **Location**: The Azure region (e.g., "eastus", "westeurope") +- **Size**: The VM size (e.g., "Standard_B1s", "Standard_D2s_v3") +- **Admin Username**: Administrator username for the VM +- **Admin Password**: Administrator password for the VM (must meet Azure complexity requirements) +- **Network Interface ID**: Optional existing NIC. Leave empty to create NIC from selected VNet/Subnet. +- **Image**: The OS image to use (publisher, offer, SKU, version) + +### Output + +Returns the created VM information including: +- **id**: The Azure resource ID of the VM +- **name**: The name of the VM +- **provisioningState**: The provisioning state (typically "Succeeded") +- **location**: The Azure region where the VM was created +- **size**: The VM size + +### Notes + +- The VM creation is a Long-Running Operation (LRO) that typically takes 2-5 minutes +- The component waits for the VM to be fully provisioned before completing +- The admin password must meet Azure's complexity requirements (12+ characters, mixed case, numbers, symbols) +- If Network Interface ID is empty, a NIC is created automatically from the selected VNet/Subnet + +### Example Output + +```json +{ + "id": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm", + "location": "eastus", + "name": "my-vm", + "provisioningState": "Succeeded", + "size": "Standard_B1s" +} +``` + From 3ed0c95f91b5115be227a88c2cefb673b2617df3 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:52:22 +0300 Subject: [PATCH 05/10] feat(icons): add Azure integration logo Signed-off-by: Tamiru Alemnew --- web_src/src/assets/icons/integrations/azure.svg | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 web_src/src/assets/icons/integrations/azure.svg diff --git a/web_src/src/assets/icons/integrations/azure.svg b/web_src/src/assets/icons/integrations/azure.svg new file mode 100644 index 0000000000..273e02805a --- /dev/null +++ b/web_src/src/assets/icons/integrations/azure.svg @@ -0,0 +1,5 @@ + + + + + From 252e815f3e11ca50599b00590f9f31b6e72a7bfe Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:52:39 +0300 Subject: [PATCH 06/10] feat(ui): wire Azure logo into integration sidebar Signed-off-by: Tamiru Alemnew --- web_src/src/ui/componentSidebar/integrationIcons.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index 205ba5a8eb..7014aee12d 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -2,6 +2,7 @@ import { resolveIcon } from "@/lib/utils"; import React from "react"; import awsIcon from "@/assets/icons/integrations/aws.svg"; import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg"; +import azureIcon from "@/assets/icons/integrations/azure.svg"; import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; @@ -24,6 +25,7 @@ import dockerIcon from "@/assets/icons/integrations/docker.svg"; /** Integration type name (e.g. "github") → logo src. Used for Settings tab and header. */ export const INTEGRATION_APP_LOGO_MAP: Record = { aws: awsIcon, + azure: azureIcon, cloudflare: cloudflareIcon, dash0: dash0Icon, datadog: datadogIcon, @@ -65,6 +67,7 @@ export const APP_LOGO_MAP: Record> = { sendgrid: sendgridIcon, render: renderIcon, dockerhub: dockerIcon, + azure: azureIcon, aws: { lambda: awsLambdaIcon, }, From c56194526dad334d7e201b29a4bbb66fdfa16866 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:53:00 +0300 Subject: [PATCH 07/10] feat(ui): show Azure logo in building blocks Signed-off-by: Tamiru Alemnew --- web_src/src/ui/BuildingBlocksSidebar/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index 8b85279533..ed333c9ea0 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -29,6 +29,7 @@ import awsIcon from "@/assets/icons/integrations/aws.svg"; import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg"; import awsEcrIcon from "@/assets/icons/integrations/aws.ecr.svg"; import awsCodeArtifactIcon from "@/assets/icons/integrations/aws.codeartifact.svg"; +import azureIcon from "@/assets/icons/integrations/azure.svg"; import rootlyIcon from "@/assets/icons/integrations/rootly.svg"; import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg"; import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; @@ -411,6 +412,7 @@ function CategorySection({ sendgrid: sendgridIcon, render: renderIcon, dockerhub: dockerIcon, + azure: azureIcon, aws: { codeArtifact: awsIcon, lambda: awsLambdaIcon, @@ -484,6 +486,7 @@ function CategorySection({ sendgrid: sendgridIcon, render: renderIcon, dockerhub: dockerIcon, + azure: azureIcon, aws: { codeArtifact: awsCodeArtifactIcon, ecr: awsEcrIcon, From 8fb3824eb089fee391b3f6bd95fc6e129f2a0b51 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Thu, 12 Feb 2026 15:53:20 +0300 Subject: [PATCH 08/10] chore(ui): normalize Azure integration display name Signed-off-by: Tamiru Alemnew --- web_src/src/utils/integrationDisplayName.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web_src/src/utils/integrationDisplayName.ts b/web_src/src/utils/integrationDisplayName.ts index 64487766ea..526a3d6f98 100644 --- a/web_src/src/utils/integrationDisplayName.ts +++ b/web_src/src/utils/integrationDisplayName.ts @@ -17,6 +17,7 @@ const INTEGRATION_TYPE_DISPLAY_NAMES: Record = { daytona: "Daytona", dash0: "Dash0", aws: "AWS", + azure: "Azure", smtp: "SMTP", sendgrid: "SendGrid", dockerhub: "DockerHub", From 6d15ed44807eb369f8af1b3314354b6bbc819439 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Fri, 13 Feb 2026 09:02:27 +0300 Subject: [PATCH 09/10] fix(azure): add webhook authentication and refactor to use helper functions - Add webhook secret verification in HandleWebhook - Refactor trigger to use helper functions from webhook_events.go - Fix Go formatting issues - Note: Event Grid validation response body limitation documented (framework limitation) Signed-off-by: Tamiru Alemnew --- pkg/integrations/azure/events.go | 1 - pkg/integrations/azure/provider_test.go | 1 - .../azure/trigger_on_vm_created.go | 111 +++++++++++++++--- .../azure/trigger_vm_created_test.go | 1 - pkg/integrations/azure/webhook_handler.go | 1 - .../pages/workflowv2/mappers/azure/index.ts | 2 + 6 files changed, 98 insertions(+), 19 deletions(-) diff --git a/pkg/integrations/azure/events.go b/pkg/integrations/azure/events.go index 74c501ccf5..9c3470e0ca 100644 --- a/pkg/integrations/azure/events.go +++ b/pkg/integrations/azure/events.go @@ -185,4 +185,3 @@ const ( // ProvisioningStateDeleting indicates the resource is being deleted ProvisioningStateDeleting = "Deleting" ) - diff --git a/pkg/integrations/azure/provider_test.go b/pkg/integrations/azure/provider_test.go index 10a84ecd3c..5418cbfd9f 100644 --- a/pkg/integrations/azure/provider_test.go +++ b/pkg/integrations/azure/provider_test.go @@ -205,4 +205,3 @@ func TestAzureProvider_GetSubscriptionID(t *testing.T) { subscriptionID := provider.GetSubscriptionID() assert.Equal(t, expectedSubscriptionID, subscriptionID) } - diff --git a/pkg/integrations/azure/trigger_on_vm_created.go b/pkg/integrations/azure/trigger_on_vm_created.go index 75579c40f6..7177b656cd 100644 --- a/pkg/integrations/azure/trigger_on_vm_created.go +++ b/pkg/integrations/azure/trigger_on_vm_created.go @@ -162,6 +162,12 @@ func (t *OnVMCreatedTrigger) Setup(ctx core.TriggerContext) error { // HandleWebhook processes Event Grid webhook requests. func (t *OnVMCreatedTrigger) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + // Authenticate webhook request if secret is configured + if err := t.authenticateWebhook(ctx); err != nil { + ctx.Logger.Warnf("Webhook authentication failed: %v", err) + return http.StatusUnauthorized, err + } + // Decode configuration config := OnVMCreatedConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { @@ -182,6 +188,10 @@ func (t *OnVMCreatedTrigger) HandleWebhook(ctx core.WebhookRequestContext) (int, if err := t.handleSubscriptionValidation(ctx, event); err != nil { return http.StatusInternalServerError, err } + // Note: Azure Event Grid requires {"validationResponse": ""} in response body. + // The framework's HandleWebhook interface only returns (int, error), so we cannot + // write a custom response body. Azure Event Grid will fail validation unless the + // validationUrl approach is used, or the server is modified to support custom response bodies. return http.StatusOK, nil } @@ -197,13 +207,29 @@ func (t *OnVMCreatedTrigger) HandleWebhook(ctx core.WebhookRequestContext) (int, } // handleSubscriptionValidation validates Event Grid subscription setup. +// Note: Azure Event Grid requires {"validationResponse": ""} in the response body. +// Since the framework's HandleWebhook interface only returns (int, error), we cannot write +// a custom response body. If validationUrl is provided, we could use it as an alternative, +// but the standard approach requires the response body. func (t *OnVMCreatedTrigger) handleSubscriptionValidation(ctx core.WebhookRequestContext, event EventGridEvent) error { var validationData SubscriptionValidationEventData if err := mapstructure.Decode(event.Data, &validationData); err != nil { return fmt.Errorf("failed to parse validation data: %w", err) } - ctx.Logger.Infof("Responding to Event Grid subscription validation with code: %s", validationData.ValidationCode) + if validationData.ValidationCode == "" { + return fmt.Errorf("validation code is empty") + } + + ctx.Logger.Infof("Event Grid subscription validation received with code: %s", validationData.ValidationCode) + + // If validationUrl is provided, we could make a GET request to it as an alternative + // to returning the code in the response body. However, the standard approach is to + // return {"validationResponse": ""} in the response body, which requires + // framework support for custom response bodies. + if validationData.ValidationURL != "" { + ctx.Logger.Infof("Validation URL provided: %s (not using - requires framework support for custom response bodies)", validationData.ValidationURL) + } return nil } @@ -214,7 +240,7 @@ func (t *OnVMCreatedTrigger) handleVMCreationEvent( event EventGridEvent, config OnVMCreatedConfiguration, ) error { - if !strings.Contains(event.Subject, ResourceTypeVirtualMachine) { + if !isVirtualMachineEvent(event.Subject) { return nil } @@ -223,18 +249,14 @@ func (t *OnVMCreatedTrigger) handleVMCreationEvent( return fmt.Errorf("failed to parse event data: %w", err) } - if eventData.ProvisioningState != ProvisioningStateSucceeded { + if !isSuccessfulProvisioning(eventData.ProvisioningState) { ctx.Logger.Infof("Skipping VM event with provisioning state: %s", eventData.ProvisioningState) return nil } - resourceGroup := "" - parts := strings.Split(event.Subject, "/") - for i, part := range parts { - if part == "resourceGroups" && i+1 < len(parts) { - resourceGroup = parts[i+1] - break - } + resourceGroup := extractResourceGroup(event.Subject) + if resourceGroup == "" { + ctx.Logger.Warnf("Could not extract resource group from subject: %s", event.Subject) } if config.ResourceGroup != "" && resourceGroup != config.ResourceGroup { @@ -242,16 +264,13 @@ func (t *OnVMCreatedTrigger) handleVMCreationEvent( return nil } - vmName := "" - if len(parts) > 0 { - vmName = parts[len(parts)-1] - } + vmName := extractVMName(event.Subject) payload := map[string]any{ "vmName": vmName, "vmId": event.Subject, "resourceGroup": resourceGroup, - "subscriptionId": eventData.SubscriptionID, + "subscriptionId": extractSubscriptionID(event.Subject), "location": "", "provisioningState": eventData.ProvisioningState, "timestamp": event.EventTime, @@ -268,6 +287,68 @@ func (t *OnVMCreatedTrigger) handleVMCreationEvent( return nil } +// authenticateWebhook verifies the webhook secret if one is configured. +// Azure Event Grid doesn't sign requests by default, but we can secure the webhook +// by requiring a secret in the Authorization header that matches the webhook secret. +// Note: Query parameter authentication is not available since we don't have access +// to the full request URL in the trigger context. +func (t *OnVMCreatedTrigger) authenticateWebhook(ctx core.WebhookRequestContext) error { + if ctx.Webhook == nil { + return nil + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + // If secret retrieval fails, allow the request (backward compatibility) + ctx.Logger.Debugf("Could not retrieve webhook secret: %v", err) + return nil + } + + if len(secret) == 0 { + // No secret configured, allow the request + return nil + } + + // Check for secret in Authorization header (Bearer token format) + authHeader := ctx.Headers.Get("Authorization") + if authHeader == "" { + // Check for Azure Event Grid SAS token header + sasToken := ctx.Headers.Get("aeg-sas-token") + if sasToken != "" { + // For SAS tokens, we'd need to validate the signature, which is more complex + // For now, if a SAS token is present, we'll allow it (this is a basic implementation) + ctx.Logger.Debug("Azure Event Grid SAS token found in header") + return nil + } + + // Check for custom secret header + secretHeader := ctx.Headers.Get("X-Webhook-Secret") + if secretHeader != "" { + if secretHeader != string(secret) { + return fmt.Errorf("invalid webhook secret") + } + return nil + } + + // If a secret is configured but not provided, reject the request + return fmt.Errorf("webhook secret required but not provided in Authorization header or X-Webhook-Secret header") + } + + // Verify Bearer token format + if !strings.HasPrefix(authHeader, "Bearer ") { + return fmt.Errorf("invalid Authorization header format, expected Bearer token") + } + + providedSecret := strings.TrimPrefix(authHeader, "Bearer ") + + // Verify the secret matches + if providedSecret != string(secret) { + return fmt.Errorf("invalid webhook secret") + } + + return nil +} + func (t *OnVMCreatedTrigger) Actions() []core.Action { return []core.Action{} } diff --git a/pkg/integrations/azure/trigger_vm_created_test.go b/pkg/integrations/azure/trigger_vm_created_test.go index 7c25a1f9fe..dc7920ccb7 100644 --- a/pkg/integrations/azure/trigger_vm_created_test.go +++ b/pkg/integrations/azure/trigger_vm_created_test.go @@ -597,4 +597,3 @@ func TestOnVMCreatedTrigger_HandleWebhook_InvalidConfiguration(t *testing.T) { assert.Error(t, err) assert.Equal(t, http.StatusInternalServerError, code) } - diff --git a/pkg/integrations/azure/webhook_handler.go b/pkg/integrations/azure/webhook_handler.go index 63180259be..e82e5a3fda 100644 --- a/pkg/integrations/azure/webhook_handler.go +++ b/pkg/integrations/azure/webhook_handler.go @@ -104,4 +104,3 @@ func decodeAzureWebhookConfiguration(raw any) (AzureWebhookConfiguration, error) return config, nil } - diff --git a/web_src/src/pages/workflowv2/mappers/azure/index.ts b/web_src/src/pages/workflowv2/mappers/azure/index.ts index 8e1ed84aa0..d80caa7407 100644 --- a/web_src/src/pages/workflowv2/mappers/azure/index.ts +++ b/web_src/src/pages/workflowv2/mappers/azure/index.ts @@ -8,3 +8,5 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { createVirtualMachine: buildActionStateRegistry("created"), }; + + From df75717929d88ef8396c575c4cd11649f7dcb275 Mon Sep 17 00:00:00 2001 From: Tamiru Alemnew Date: Fri, 13 Feb 2026 17:22:25 +0300 Subject: [PATCH 10/10] fix(azure): format mapper file Signed-off-by: Tamiru Alemnew --- web_src/src/pages/workflowv2/mappers/azure/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/azure/index.ts b/web_src/src/pages/workflowv2/mappers/azure/index.ts index d80caa7407..8e1ed84aa0 100644 --- a/web_src/src/pages/workflowv2/mappers/azure/index.ts +++ b/web_src/src/pages/workflowv2/mappers/azure/index.ts @@ -8,5 +8,3 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { createVirtualMachine: buildActionStateRegistry("created"), }; - -