Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/coverage/azure/vnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ AzureNetworkMetadata is an OPTIONAL, type-asserted capability. The Azure
| `UpsertAzureNSGRule` | UpsertAzureNSGRule creates or replaces a single custom security rule by |
| `UpsertAzureVNetPeering` | UpsertAzureVNetPeering creates or replaces a single virtualNetworkPeerings |

### AzurePublicIPPrefixes

AzurePublicIPPrefixes is the Azure-only public-IP-prefix surface. Keyed by

| Operation | Description |
| --- | --- |
| `DeleteAzurePublicIPPrefix` | DeleteAzurePublicIPPrefix removes the prefix, reporting whether it existed. |
| `GetAzurePublicIPPrefix` | GetAzurePublicIPPrefix returns the prefix identified by (resourceGroup, name). |
| `ListAzurePublicIPPrefixes` | ListAzurePublicIPPrefixes returns the prefixes in a resource group, or all |
| `PutAzurePublicIPPrefix` | PutAzurePublicIPPrefix creates or replaces a prefix in place (a repeat |

### NetworkInterfaceAttacher

NetworkInterfaceAttacher is the AWS-specific ENI attach surface
Expand Down
22 changes: 22 additions & 0 deletions docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -7348,6 +7348,28 @@
}
]
},
{
"name": "AzurePublicIPPrefixes",
"doc": "AzurePublicIPPrefixes is the Azure-only public-IP-prefix surface. Keyed by",
"operations": [
{
"name": "DeleteAzurePublicIPPrefix",
"doc": "DeleteAzurePublicIPPrefix removes the prefix, reporting whether it existed."
},
{
"name": "GetAzurePublicIPPrefix",
"doc": "GetAzurePublicIPPrefix returns the prefix identified by (resourceGroup, name)."
},
{
"name": "ListAzurePublicIPPrefixes",
"doc": "ListAzurePublicIPPrefixes returns the prefixes in a resource group, or all"
},
{
"name": "PutAzurePublicIPPrefix",
"doc": "PutAzurePublicIPPrefix creates or replaces a prefix in place (a repeat"
}
]
},
{
"name": "ClientVPN",
"doc": "ClientVPN is an OPTIONAL AWS capability (type-asserted).",
Expand Down
136 changes: 136 additions & 0 deletions providers/azure/vnet/public_ip_prefix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package vnet

import (
"context"
"fmt"
"strings"

"github.com/stackshy/cloudemu/v2/services/networking/driver"
)

// Compile-time check that Mock serves the Azure public-IP-prefix surface.
var _ driver.AzurePublicIPPrefixes = (*Mock)(nil)

const (
// defaultPrefixLength is applied when a createOrUpdate omits prefixLength, so a
// prefix always resolves to a concrete CIDR.
defaultPrefixLength = 28
// octetMask masks a byte out of the block counter when composing the CIDR.
octetMask = 0xFF
)

// prefixKey composes the store key from the ARM addressing pair. Resource-group
// names are case-insensitive in Azure, so it is lower-cased; the prefix name is
// preserved as-is, mirroring asgKey.
func prefixKey(resourceGroup, name string) string {
return strings.ToLower(resourceGroup) + "/" + name
}

// PutAzurePublicIPPrefix creates or replaces a prefix in place, keyed by
// (resourceGroup, name). On create it allocates a deterministic CIDR of the
// requested size; on an idempotent re-PUT it preserves the existing IPPrefix and
// PrefixLength (both immutable in real Azure) and only refreshes the mutable
// fields.
//
//nolint:gocritic // hugeParam: prefix mirrors the AzurePublicIPPrefixes driver signature.
func (m *Mock) PutAzurePublicIPPrefix(
_ context.Context, prefix driver.AzurePublicIPPrefix,
) driver.AzurePublicIPPrefix {
m.prefixMu.Lock()
defer m.prefixMu.Unlock()

key := prefixKey(prefix.ResourceGroup, prefix.Name)

stored := clonePrefix(prefix)

if existing, ok := m.azurePrefixes.Get(key); ok {
// Re-PUT: the synthesized CIDR and its length are immutable, so carry them
// forward rather than re-allocating a second block.
stored.IPPrefix = existing.IPPrefix
stored.PrefixLength = existing.PrefixLength
} else {
if stored.PrefixLength <= 0 {
stored.PrefixLength = defaultPrefixLength
}

stored.IPPrefix = m.allocatePrefixCIDR(stored.PrefixLength)
}

m.azurePrefixes.Set(key, stored)

return clonePrefix(stored)
}

// allocatePrefixCIDR hands out the next unused /24 from the 10.0.0.0/8 pool and
// masks it to prefixLength, giving each prefix a distinct, aligned CIDR. The
// counter is monotonic (deterministic, no randomness) and, since Azure IPv4
// public-IP prefixes are /24–/31, the host bits always fit inside the last octet
// so the .0 base is aligned. Caller holds prefixMu.
func (m *Mock) allocatePrefixCIDR(prefixLength int32) string {
block := m.nextPrefixBlock
m.nextPrefixBlock++

return fmt.Sprintf("10.%d.%d.0/%d", (block>>8)&octetMask, block&octetMask, prefixLength)
}

// GetAzurePublicIPPrefix returns the prefix identified by (resourceGroup, name).
func (m *Mock) GetAzurePublicIPPrefix(
_ context.Context, resourceGroup, name string,
) (driver.AzurePublicIPPrefix, bool) {
prefix, ok := m.azurePrefixes.Get(prefixKey(resourceGroup, name))
if !ok {
return driver.AzurePublicIPPrefix{}, false
}

return clonePrefix(prefix), true
}

// DeleteAzurePublicIPPrefix removes the prefix, reporting whether it existed.
func (m *Mock) DeleteAzurePublicIPPrefix(_ context.Context, resourceGroup, name string) bool {
return m.azurePrefixes.Delete(prefixKey(resourceGroup, name))
}

// ListAzurePublicIPPrefixes returns the prefixes in a resource group, or all when
// resourceGroup is empty (subscription-wide list), ordered by key.
func (m *Mock) ListAzurePublicIPPrefixes(
_ context.Context, resourceGroup string,
) []driver.AzurePublicIPPrefix {
out := make([]driver.AzurePublicIPPrefix, 0)

values := m.azurePrefixes.SortedValues()
for i := range values {
if resourceGroup != "" && !strings.EqualFold(values[i].ResourceGroup, resourceGroup) {
continue
}

out = append(out, clonePrefix(values[i]))
}

return out
}

// clonePrefix deep-copies the tag map and zones slice so stored and returned
// values never alias a caller's containers.
//
//nolint:gocritic // hugeParam: prefix mirrors the AzurePublicIPPrefixes driver signature.
func clonePrefix(prefix driver.AzurePublicIPPrefix) driver.AzurePublicIPPrefix {
out := driver.AzurePublicIPPrefix{
Name: prefix.Name,
ResourceGroup: prefix.ResourceGroup,
Location: prefix.Location,
PrefixLength: prefix.PrefixLength,
IPPrefix: prefix.IPPrefix,
SKUName: prefix.SKUName,
SKUTier: prefix.SKUTier,
}

if len(prefix.Tags) > 0 {
out.Tags = copyTags(prefix.Tags)
}

if len(prefix.Zones) > 0 {
out.Zones = append([]string(nil), prefix.Zones...)
}

return out
}
3 changes: 3 additions & 0 deletions providers/azure/vnet/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type vnetSnapshot struct {
AzureRouteTableMeta json.RawMessage `json:"azureRouteTableMeta,omitempty"`
AzureVNetPeerings json.RawMessage `json:"azureVnetPeerings,omitempty"`
AzureASGs json.RawMessage `json:"azureAsgs,omitempty"`
AzurePrefixes json.RawMessage `json:"azurePublicIpPrefixes,omitempty"`
}

// Snapshot captures the mock's entire state as JSON. includeAssets is unused —
Expand Down Expand Up @@ -74,6 +75,7 @@ func (m *Mock) snapshotStores(snap *vnetSnapshot) error {
{&snap.AzureRouteTableMeta, m.azureRouteTableMeta.Snapshot},
{&snap.AzureVNetPeerings, m.azureVNetPeerings.Snapshot},
{&snap.AzureASGs, m.azureASGs.Snapshot},
{&snap.AzurePrefixes, m.azurePrefixes.Snapshot},
}

for _, d := range dumps {
Expand Down Expand Up @@ -123,6 +125,7 @@ func (m *Mock) restoreStores(snap *vnetSnapshot) error {
{snap.AzureRouteTableMeta, m.azureRouteTableMeta.LoadSnapshot},
{snap.AzureVNetPeerings, m.azureVNetPeerings.LoadSnapshot},
{snap.AzureASGs, m.azureASGs.LoadSnapshot},
{snap.AzurePrefixes, m.azurePrefixes.LoadSnapshot},
}

for _, l := range loads {
Expand Down
13 changes: 13 additions & 0 deletions providers/azure/vnet/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) {
Name: "asg1", ResourceGroup: "rg1", Location: "eastus", Tags: map[string]string{"env": "prod"},
})

storedPrefix := src.PutAzurePublicIPPrefix(ctx, driver.AzurePublicIPPrefix{
Name: "pfx1", ResourceGroup: "rg1", Location: "eastus", PrefixLength: 28,
SKUName: "Standard", SKUTier: "Regional", Tags: map[string]string{"team": "net"},
})

data, err := src.Snapshot(ctx, true)
require.NoError(t, err)

Expand Down Expand Up @@ -70,6 +75,14 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) {
asg, ok := dst.GetAzureApplicationSecurityGroup(ctx, "rg1", "asg1")
require.True(t, ok, "ASG must survive snapshot/restore")
assert.Equal(t, "prod", asg.Tags["env"])

// The public IP prefix survives with its synthesized CIDR and sku intact.
pfx, ok := dst.GetAzurePublicIPPrefix(ctx, "rg1", "pfx1")
require.True(t, ok, "public IP prefix must survive snapshot/restore")
assert.Equal(t, storedPrefix.IPPrefix, pfx.IPPrefix, "synthesized ipPrefix must round-trip")
assert.Equal(t, int32(28), pfx.PrefixLength)
assert.Equal(t, "Standard", pfx.SKUName)
assert.Equal(t, "net", pfx.Tags["team"])
}

// TestSnapshotEmpty confirms a fresh mock snapshots and restores without error.
Expand Down
15 changes: 14 additions & 1 deletion providers/azure/vnet/vnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,23 @@ type Mock struct {
// azureASGs holds the Azure-only application security groups (tag-like
// groupings with no cross-cloud equivalent), keyed by (resourceGroup, name).
azureASGs *memstore.Store[driver.AzureApplicationSecurityGroup]
// azurePrefixes holds the Azure-only public IP prefixes (a reserved CIDR range
// with no cross-cloud equivalent), keyed by (resourceGroup, name).
azurePrefixes *memstore.Store[driver.AzurePublicIPPrefix]
// nicMu serializes network-interface create/update, whose private-IP
// allocation is a read-modify-write across the nics store (memstore is
// per-op safe but can't make that sequence atomic).
nicMu sync.RWMutex
opts *config.Options
// prefixMu serializes public-IP-prefix create/update: the CIDR allocation is a
// read-modify-write across nextPrefixBlock and the azurePrefixes store that
// memstore cannot make atomic on its own.
prefixMu sync.Mutex
// nextPrefixBlock is the monotonic /24 block index the CIDR allocator hands out.
// It is intentionally not serialized: each prefix persists its own synthesized
// IPPrefix, so a restore preserves every allocation, and the counter only needs
// to be unique within a running process.
nextPrefixBlock uint32
opts *config.Options
}

// New creates a new Azure Virtual Network mock.
Expand All @@ -110,6 +122,7 @@ func New(opts *config.Options) *Mock {
azureRouteTableMeta: memstore.New[driver.AzureRouteTableMetadata](),
azureVNetPeerings: memstore.New[[]driver.AzureVNetPeering](),
azureASGs: memstore.New[driver.AzureApplicationSecurityGroup](),
azurePrefixes: memstore.New[driver.AzurePublicIPPrefix](),
opts: opts,
}
}
Expand Down
1 change: 1 addition & 0 deletions server/azure/resourcegraph/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ var portableToAzureTypeMap = map[string]string{ //nolint:gochecknoglobals // sta
"iam/Role": "microsoft.authorization/roledefinitions",
"networking/NatGateway": "microsoft.network/natgateways",
"networking/ApplicationSecurityGroup": "microsoft.network/applicationsecuritygroups",
"networking/PublicIPPrefix": "microsoft.network/publicipprefixes",
"networking/RouteTable": "microsoft.network/routetables",
"networking/PeeringConnection": "microsoft.network/virtualnetworks/virtualnetworkpeerings",
"machinelearningservices/Workspace": "microsoft.machinelearningservices/workspaces",
Expand Down
2 changes: 2 additions & 0 deletions server/azure/resourcegraph/kql.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const (
azureTypeRoleDef = "microsoft.authorization/roledefinitions"
azureTypeNATGw = "microsoft.network/natgateways"
azureTypeASG = "microsoft.network/applicationsecuritygroups"
azureTypePubIPPfx = "microsoft.network/publicipprefixes"
azureTypeRouteTbl = "microsoft.network/routetables"
azureTypeVNetPeer = "microsoft.network/virtualnetworks/virtualnetworkpeerings"
azureTypeMLWorkspc = "microsoft.machinelearningservices/workspaces"
Expand Down Expand Up @@ -374,6 +375,7 @@ var azureToPortableType = map[string]portableResourceType{ //nolint:gochecknoglo
azureTypeRoleDef: {portableIAM, "Role"},
azureTypeNATGw: {portableNetworking, "NatGateway"},
azureTypeASG: {portableNetworking, "ApplicationSecurityGroup"},
azureTypePubIPPfx: {portableNetworking, "PublicIPPrefix"},
azureTypeRouteTbl: {portableNetworking, "RouteTable"},
azureTypeVNetPeer: {portableNetworking, "PeeringConnection"},
azureTypeMLWorkspc: {portableAzureML, "Workspace"},
Expand Down
2 changes: 1 addition & 1 deletion server/azure/vnet/application_security_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (h *Handler) asgCap() (netdriver.AzureApplicationSecurityGroups, bool) {
// ApplicationSecurityGroupsClient pollers complete on a synchronous terminal
// 200, so create/get/delete all answer sync-200 (no 202 async plumbing).
//
//nolint:gocritic // rp is a request-scoped value
//nolint:gocritic,dupl // rp is request-scoped; capability-gated dispatch mirrored by routePublicIPPrefix over a distinct type
func (h *Handler) routeASG(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) {
svc, ok := h.asgCap()
if !ok {
Expand Down
25 changes: 24 additions & 1 deletion server/azure/vnet/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func (*Handler) Matches(r *http.Request) bool {
}

switch rp.ResourceType {
case typeVNet, typeNSG, typeRouteTable, typePublicIP, typeNIC, typeNATGateway, typeASG, typeLocations:
case typeVNet, typeNSG, typeRouteTable, typePublicIP, typePublicIPPrefix, typeNIC, typeNATGateway, typeASG, typeLocations:
return true
}

Expand All @@ -126,6 +126,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

h.routeByResourceType(w, r, rp)
}

// routeByResourceType dispatches to the per-type router. Split out of ServeHTTP
// so the dispatch stays under the cyclomatic-complexity gate as resource types
// are added (the same reason serveLocationsOperationStatus is separate).
//
//nolint:gocritic // rp is a request-scoped value
func (h *Handler) routeByResourceType(w http.ResponseWriter, r *http.Request, rp azurearm.ResourcePath) {
switch rp.ResourceType {
case typeVNet:
h.routeVNet(w, r, rp)
Expand All @@ -135,6 +144,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.routeRouteTable(w, r, rp)
case typePublicIP:
h.routePublicIP(w, r, rp)
case typePublicIPPrefix:
h.routePublicIPPrefix(w, r, rp)
case typeNIC:
h.routeNIC(w, r, rp)
case typeNATGateway:
Expand Down Expand Up @@ -629,6 +640,7 @@ func (h *Handler) PurgeResourceGroup(ctx context.Context, _, resourceGroup strin
recordErr(h.purgeNSGs(ctx, resourceGroup))
recordErr(h.purgeRouteTables(ctx, resourceGroup))
h.purgeASGs(ctx, resourceGroup)
h.purgePublicIPPrefixes(ctx, resourceGroup)

return firstErr
}
Expand Down Expand Up @@ -1364,6 +1376,13 @@ func (h *Handler) createPublicIP(w http.ResponseWriter, r *http.Request, rp azur
tags := mergeTags(req.Tags, armPublicIPTag, rp.ResourceName)
tags = mergeTags(tags, armPublicIPRGTag, rp.ResourceGroup)

// A public IP may be drawn from a public IP prefix. The mock only records the
// reference (deferred child-IP allocation); the prefix rebuilds its read-only
// publicIPAddresses[] back-reference by scanning for this internal tag.
if req.Properties.PublicIPPrefix != nil && req.Properties.PublicIPPrefix.ID != "" {
tags = mergeTags(tags, armPublicIPPrefixTag, req.Properties.PublicIPPrefix.ID)
}

cfg := netdriver.ElasticIPConfig{
SKU: sku,
AllocationMethod: req.Properties.PublicIPAllocationMethod,
Expand Down Expand Up @@ -1869,6 +1888,10 @@ func (h *Handler) toPublicIPResponse(

out.Properties.IPConfiguration = h.publicIPConfigurationRef(ctx, rp.Subscription, id)

if prefixID := tagOr(info.Tags, armPublicIPPrefixTag, ""); prefixID != "" {
out.Properties.PublicIPPrefix = &armIDRef{ID: prefixID}
}

return out
}

Expand Down
Loading
Loading