Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/coverage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json).
| `datastream` | - | - | [Datastream](./gcp/datastream.md) | - | 11 |
| `devcenter` | - | [DevCenter](./azure/devcenter.md) | - | - | 8 |
| `digitaltwins` | - | [DigitalTwins](./azure/digitaltwins.md) | - | - | 8 |
| `disks` | - | [Disks](./azure/disks.md) | - | - | 7 |
| `disks` | - | [Disks](./azure/disks.md) | - | - | 8 |
| `dns` | [Route53](./aws/route53.md) | [DNS](./azure/dns.md) | [CloudDNS](./gcp/clouddns.md) | - | 16 |
| `ecs` | [ECS](./aws/ecs.md) | - | - | - | 37 |
| `efs` | [EFS](./aws/efs.md) | - | - | - | 27 |
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage/azure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Services cloudemu emulates for Azure, by native name. Back to the [cross-provide
| [Databricks](./databricks.md) | `databricks` | 46 |
| [DevCenter](./devcenter.md) | (provider-native) | 8 |
| [DigitalTwins](./digitaltwins.md) | (provider-native) | 8 |
| [Disks](./disks.md) | (provider-native) | 7 |
| [Disks](./disks.md) | (provider-native) | 8 |
| [ElasticSan](./elasticsan.md) | (provider-native) | 8 |
| [EventGrid](./eventgrid.md) | `eventbus` | 16 |
| [Eventhub](./eventhub.md) | (provider-native) | 14 |
Expand Down
3 changes: 2 additions & 1 deletion docs/coverage/azure/disks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

provider-native `disks` wire service (Azure-only) · no portable driver · [Azure index](./README.md)

## Operations (7)
## Operations (8)

| Operation | Description |
| --- | --- |
Expand All @@ -14,6 +14,7 @@ provider-native `disks` wire service (Azure-only) · no portable driver · [Azur
| `List` | |
| `ListByResourceGroup` | |
| `RevokeAccess` | |
| `Update` | |

## Not in scope

Expand Down
8 changes: 8 additions & 0 deletions docs/coverage/azure/virtualmachines.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ AzureDiskDeleteOptioner is an optional Azure-only capability that records a
| --- | --- |
| `SetDiskDeleteOnTermination` | SetDiskDeleteOnTermination records whether the volume is deleted (true) or |

### AzureDiskPatcher

AzureDiskPatcher is an optional Azure-only capability for a partial managed

| Operation | Description |
| --- | --- |
| `PatchVolume` | PatchVolume applies patch to the volume id and returns the stored |

### AzureDiskUpdater

AzureDiskUpdater is an optional Azure-only capability for an in-place managed
Expand Down
13 changes: 13 additions & 0 deletions docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -4018,6 +4018,16 @@
}
]
},
{
"name": "AzureDiskPatcher",
"doc": "AzureDiskPatcher is an optional Azure-only capability for a partial managed",
"operations": [
{
"name": "PatchVolume",
"doc": "PatchVolume applies patch to the volume id and returns the stored"
}
]
},
{
"name": "AzureDiskUpdater",
"doc": "AzureDiskUpdater is an optional Azure-only capability for an in-place managed",
Expand Down Expand Up @@ -5906,6 +5916,9 @@
},
{
"name": "RevokeAccess"
},
{
"name": "Update"
}
],
"providers": {
Expand Down
4 changes: 2 additions & 2 deletions docs/sdk-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,13 @@ All handlers speak ARM JSON over HTTPS unless noted.
| Service | ARM provider / operations |
|---------|--------------------------|
| **Virtual Machines** | `Microsoft.Compute/virtualMachines`: CreateOrUpdate, Get, List, Delete, start, powerOff, restart |
| **Disks / Snapshots / Images / SSH Public Keys** | `Microsoft.Compute/{disks,snapshots,images,sshPublicKeys}`: full CRUD |
| **Disks / Snapshots / Images / SSH Public Keys** | `Microsoft.Compute/{disks,snapshots,images,sshPublicKeys}`: full CRUD. Disks also serve Update (PATCH, 202 + Azure-AsyncOperation, so `armcompute` `DisksClient.BeginUpdate` pollers complete): tags, `sku`, `tier`, `diskSizeGB` (grow only; a shrink is a 400) and `diskIOPSReadWrite`/`diskMBpsReadWrite` (UltraSSD_LRS / PremiumV2_LRS only), plus beginGetAccess/endGetAccess. |
| **Blob Storage** *(data plane)* | Containers + Blobs: Create/Delete/List, PutBlob, GetBlob, DeleteBlob, CopyBlob |
| **Cosmos DB** *(data plane)* | Databases, Containers, Documents: full CRUD with `x-ms-documentdb-*` headers |
| **Cosmos DB (SQL ARM control plane)** | `Microsoft.DocumentDB/databaseAccounts/{acct}/sqlDatabases[/containers]`: SQL databases (CreateUpdate/Get/List/Delete, cascading container delete), containers (CreateUpdate/Get/List/Delete with partitionKey, defaultTtl, uniqueKeyPolicy, indexingPolicy), and `throughputSettings/default` at database and container level (Get/Update manual RU/s or autoscale maxThroughput + migrateToAutoscale/migrateToManualThroughput). Real `armcosmos` `SQLResources` clients round-trip end-to-end, including the LRO pollers, so Terraform/Bicep/`az cosmosdb sql` can manage the data model. Shares state with the Cosmos data plane above; a control-plane database/container/throughput is visible to data-plane clients and vice versa. |
| **Virtual Network** | `Microsoft.Network/{virtualNetworks,networkSecurityGroups,publicIPAddresses,networkInterfaces}`: CRUD + nested subnets; NICs bind a subnet and get a private IP |
| **Azure Monitor** | `microsoft.insights/metricAlerts` and metric data ingest/read |
| **Functions** | `Microsoft.Web/sites` (Function Apps): CreateOrUpdate, Get, List, Delete + non-ARM `/api/{name}` invoke |
| **Functions** | `Microsoft.Web/sites` (Function Apps): CreateOrUpdate, Get, List, Update (PATCH), Delete + non-ARM `/api/{name}` invoke. `Microsoft.Web/serverfarms` (App Service plans): CreateOrUpdate, Get, List, ListWebApps, Update (PATCH: `kind`, `sku`, tags, `reserved`, `perSiteScaling`, `zoneRedundant`, `maximumElasticWorkerCount`), Delete. |
| **Service Bus** | `Microsoft.ServiceBus/namespaces[/queues]` ARM CRUD + raw-HTTP REST data plane (`POST /{ns}/{queue}/messages`, `DELETE /messages/head`) |
| **SQL Database** | `Microsoft.Sql/servers[/databases]`: servers and databases, full CRUD lifecycle |
| **Managed Cassandra** | `Microsoft.DocumentDB/cassandraClusters[/dataCenters]`: clusters (CreateOrUpdate, Get, ListByResourceGroup, ListBySubscription, Update, Delete, deallocate, start, invokeCommand, status) and datacenters (CreateOrUpdate, Get, List, Update, Delete). Real `armcosmos` `CassandraClusters`/`CassandraDataCenters` clients round-trip end-to-end, including the LRO pollers. |
Expand Down
1 change: 1 addition & 0 deletions internal/coveragegen/wireops.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var nativeWireOperations = map[string][]string{ //nolint:gochecknoglobals // gen
},
"azure/disks": {
"CreateOrUpdate", "Delete", "Get", "GrantAccess", "List", "ListByResourceGroup", "RevokeAccess",
"Update", //nolint:goconst // SDK operation names are table data; the file already repeats them (pre-existing goconst hits)
},
"azure/snapshots": {
"CreateOrUpdate", "Delete", "Get", "List", "ListByResourceGroup",
Expand Down
135 changes: 131 additions & 4 deletions providers/azure/functions/app_service_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ type AppServicePlan struct {
Kind string // app / functionapp / linux
Capacity int
Tags map[string]string

// Plan properties a caller can change after create (armappservice
// PlanProperties / PlanPatchResourceProperties).
Reserved bool // true for a Linux plan
PerSiteScaling bool
ZoneRedundant bool
MaximumElasticWorkerCount int // Elastic Premium burst ceiling
}

// App Service plan pricing tiers, the tier real Azure derives from a SKU name
Expand Down Expand Up @@ -138,13 +145,48 @@ func (m *Mock) CreateAppServicePlan(_ context.Context, p AppServicePlan) (*AppSe
p.Capacity = 1
}

stored := p
if p.Capacity < 0 {
return nil, cerrors.Newf(cerrors.InvalidArgument, "Invalid sku.capacity %d: it must be at least 1.", p.Capacity)
}

return m.putAppServicePlan(&p)
}

m.plans.Set(planKey(p.Subscription, p.ResourceGroup, p.Name), &stored)
// putAppServicePlan stores p, creating the plan or replacing an existing one.
// A replacement (an ARM re-PUT) is subject to the same checks as a PATCH, run
// under the store lock: in particular it cannot flip the plan's OS.
func (m *Mock) putAppServicePlan(p *AppServicePlan) (*AppServicePlan, error) {
key := planKey(p.Subscription, p.ResourceGroup, p.Name)

out := stored
for {
var putErr error

return &out, nil
replaced := m.plans.Update(key, func(cur *AppServicePlan) *AppServicePlan {
if err := checkPlanUpdate(cur, p); err != nil {
putErr = err
return cur
}

stored := *p

return &stored
})

if putErr != nil {
return nil, putErr
}

if !replaced {
stored := *p
if !m.plans.SetIfAbsent(key, &stored) {
continue // created concurrently: replace it instead
}
}

out := *p

return &out, nil
}
}

// GetAppServicePlan returns one App Service plan scoped to the given
Expand Down Expand Up @@ -224,3 +266,88 @@ func (m *Mock) ListAppServicePlans(_ context.Context, subscription, resourceGrou

return out, nil
}

// AppServicePlanPatch is a partial App Service plan update (ARM PATCH
// .../serverfarms/{name}). A nil field leaves the stored value untouched; a
// non-nil Tags map replaces the plan's tags wholesale.
type AppServicePlanPatch struct {
Kind *string
SKUName *string
SKUTier *string
Capacity *int
Tags map[string]string
Reserved *bool
PerSiteScaling *bool
ZoneRedundant *bool
MaximumElasticWorkerCount *int
}

// PatchAppServicePlan applies a partial update to one App Service plan scoped
// to the given subscription and resource group, returning the stored result or
// NotFound. The read-modify-write and the checks in checkPlanUpdate (no OS
// change, a known SKU in the same plan family, a matching tier, a capacity in
// range) run under the store lock. A SKU name change re-derives the tier.
func (m *Mock) PatchAppServicePlan(
_ context.Context, subscription, resourceGroup, name string, patch AppServicePlanPatch,
) (*AppServicePlan, error) {
var out AppServicePlan

var patchErr error

found := m.plans.Update(planKey(subscription, resourceGroup, name), func(p *AppServicePlan) *AppServicePlan {
next := *p
applyPlanPatch(&next, &patch)

if err := checkPlanUpdate(p, &next); err != nil {
patchErr = err
return p
}

out = next

return &next
})
if !found {
return nil, cerrors.Newf(cerrors.NotFound, "app service plan %s not found", name)
}

if patchErr != nil {
return nil, patchErr
}

return &out, nil
}

func applyPlanPatch(p *AppServicePlan, patch *AppServicePlanPatch) {
setIf(&p.Kind, patch.Kind)
setIf(&p.Capacity, patch.Capacity)
setIf(&p.Reserved, patch.Reserved)
setIf(&p.PerSiteScaling, patch.PerSiteScaling)
setIf(&p.ZoneRedundant, patch.ZoneRedundant)
setIf(&p.MaximumElasticWorkerCount, patch.MaximumElasticWorkerCount)

if patch.SKUName != nil && *patch.SKUName != "" && !strings.EqualFold(*patch.SKUName, p.SKUName) {
p.SKUName = *patch.SKUName
p.SKUTier = deriveSKUTier(p.SKUName)
}

if patch.SKUTier != nil && *patch.SKUTier != "" {
p.SKUTier = *patch.SKUTier
}

if patch.Tags != nil {
tags := make(map[string]string, len(patch.Tags))
for k, v := range patch.Tags {
tags[k] = v
}

p.Tags = tags
}
}

// setIf assigns *src to *dst when src is non-nil.
func setIf[T any](dst, src *T) {
if src != nil {
*dst = *src
}
}
141 changes: 141 additions & 0 deletions providers/azure/functions/app_service_plan_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package functions

import (
"regexp"
"strings"

cerrors "github.com/stackshy/cloudemu/v2/errors"
)

// codeBadRequest is the ARM error code real Azure answers an OS change on an
// existing plan with (Azure/bicep#5724: 400 BadRequest, "You cannot change the
// OS hosting your app at this time. Please recreate your app with the desired
// OS.").
const codeBadRequest = "BadRequest"

// PlanError is an App Service plan refusal that real Azure reports with a
// specific ARM error code. It unwraps to the canonical cloudemu error, so a
// caller that only checks the code still sees it; the wire layer echoes Code.
type PlanError struct {
Code string
Err error
}

// Error implements the error interface.
func (e *PlanError) Error() string { return e.Err.Error() }

// Unwrap returns the canonical cloudemu error this one stands for.
func (e *PlanError) Unwrap() error { return e.Err }

// knownPlanSKU matches the App Service plan SKU names a plan can be moved to
// and whose tier deriveSKUTier maps correctly: Free, Shared, Basic, Standard,
// Premium (v1/v2/v3), Isolated (v1/v2), Consumption, Elastic Premium and
// Workflow Standard.
var knownPlanSKU = regexp.MustCompile(
`^(F1|D1|B[1-3]|S[1-3]|P[1-3]|P[1-3]V2|P[0-3]V3|I[1-3]|I[1-6]V2|Y1|EP[1-3]|WS[1-3])$`)

// maxPlanCapacity is the largest sku.capacity (instance count) of a dedicated
// tier, from the App Service limits (Azure subscription and service limits):
// Free and Shared 1, Basic 3, Standard 10, Premium 30, Isolated 100. Other
// tiers are not capped here.
var maxPlanCapacity = map[string]int{ //nolint:gochecknoglobals // lookup table
tierFree: 1, tierShared: 1, tierBasic: 3, tierStandard: 10,
tierPremium: 30, tierPremiumV2: 30, tierPremiumV3: 30,
tierIsolated: 100, tierIsolatedV2: 100,
}

// checkPlanUpdate validates replacing plan cur with next (a PATCH or a
// re-PUT). Only what changes is checked, so a plan created with an unusual
// SKU can still be re-PUT unchanged.
func checkPlanUpdate(cur, next *AppServicePlan) error {
if err := checkPlanOS(cur, next); err != nil {
return err
}

skuChanged := !strings.EqualFold(cur.SKUName, next.SKUName)

if skuChanged {
if err := checkPlanSKUMove(cur, next); err != nil {
return err
}
}

if skuChanged || !strings.EqualFold(cur.SKUTier, next.SKUTier) {
if want := deriveSKUTier(next.SKUName); !strings.EqualFold(next.SKUTier, want) {
return cerrors.Newf(cerrors.InvalidArgument,
"The sku tier '%s' does not match sku name '%s' (tier '%s').", next.SKUTier, next.SKUName, want)
}
}

if skuChanged || cur.Capacity != next.Capacity {
return checkPlanCapacity(next)
}

return nil
}

// checkPlanOS refuses a change of the plan's OS: reserved (true for Linux),
// or a kind that moves between Linux and Windows. An omitted kind (a re-PUT
// without one) is not a change of OS.
func checkPlanOS(cur, next *AppServicePlan) error {
if cur.Reserved == next.Reserved && (next.Kind == "" || linuxKind(cur.Kind) == linuxKind(next.Kind)) {
return nil
}

return &PlanError{Code: codeBadRequest, Err: cerrors.New(cerrors.InvalidArgument,
"You cannot change the OS hosting your app at this time. Please recreate your app with the desired OS.")}
}

// checkPlanSKUMove rejects an unknown SKU, and a move between the
// Consumption, Elastic Premium, Workflow Standard and dedicated families: a
// plan's hosting model is fixed at create.
func checkPlanSKUMove(cur, next *AppServicePlan) error {
if !knownPlanSKU.MatchString(strings.ToUpper(next.SKUName)) {
return cerrors.Newf(cerrors.InvalidArgument, "The pricing tier '%s' is not allowed for this App Service plan.",
next.SKUName)
}

from, to := planFamily(cur.SKUTier), planFamily(deriveSKUTier(next.SKUName))
if from != to {
return cerrors.Newf(cerrors.InvalidArgument,
"Cannot change the App Service plan from %s (%s) to %s (%s). Create a new plan instead.",
cur.SKUName, cur.SKUTier, next.SKUName, deriveSKUTier(next.SKUName))
}

return nil
}

// checkPlanCapacity rejects an instance count below 1 or above the tier's
// maximum.
func checkPlanCapacity(p *AppServicePlan) error {
if p.Capacity < 1 {
return cerrors.Newf(cerrors.InvalidArgument, "Invalid sku.capacity %d: it must be at least 1.", p.Capacity)
}

if limit, ok := maxPlanCapacity[p.SKUTier]; ok && p.Capacity > limit {
return cerrors.Newf(cerrors.InvalidArgument,
"Invalid sku.capacity %d: the %s tier allows at most %d instances.", p.Capacity, p.SKUTier, limit)
}

return nil
}

// planFamily groups plan tiers by hosting model.
func planFamily(tier string) string {
switch {
case strings.EqualFold(tier, tierDynamic):
return tierDynamic
case strings.EqualFold(tier, tierElasticPremium):
return tierElasticPremium
case strings.EqualFold(tier, tierWorkflowStandard):
return tierWorkflowStandard
default:
return "Dedicated"
}
}

// linuxKind reports whether a plan kind names a Linux plan ("linux",
// "functionapp,linux", ...).
func linuxKind(kind string) bool {
return strings.Contains(strings.ToLower(kind), "linux")
}
Loading
Loading