Skip to content

feat: wave 3 — 100 new Azure resource types - #152

Merged
naxty merged 137 commits into
mainfrom
naxty/100azureresources
Sep 4, 2026
Merged

feat: wave 3 — 100 new Azure resource types#152
naxty merged 137 commits into
mainfrom
naxty/100azureresources

Conversation

@naxty

@naxty naxty commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Wave 3: 100 new Azure resource types, in one PR against main as specified.
The plugin goes from 173 to 273 registered resource types; testdata goes from
359 to 555 fixtures, all of which render.

The 100 types

Group Namespace Types
1 Data Factory — factory, runtimes, linked services 9
2 Data Factory — datasets, triggers, data flow 8
3 Automation 7
4 Log Analytics depth 6
5 Storage depth 6
6 Stream Analytics 9
7 Logic Apps + Integration Accounts 8
8 Azure Virtual Desktop 5
9 Data Protection 6
10 Network Manager (subscription scope) 8
11 API Management — service, APIs, policies 14
12 API Management — products, users, backends, telemetry 14

Six new pinned SDK modules (armdatafactory v1.3.0, armautomation v1.0.0,
armstreamanalytics v1.2.0, armlogic v1.2.0, armdesktopvirtualization v1.0.0,
armapimanagement v1.1.1) and 78 wired clients. Every constructor was verified by
compile probe against a throwaway module before being written — the list was
enumerated from each module's own source rather than guessed, so 0 of 78 were
wrong
.

Verification — what this PR actually proves

On the merged tree:

make build            ok        go vet ./...          ok
make test-unit        ok        golangci-lint         ok
make verify-schema    ok        integration tests     ok
make verify-fixtures  555/555   REUSE                 ok
conformance-scope     13/13

Live conformance has not run, and cannot until #155 lands. pre-cleanup gates
conformance-tests via needs and is still red on main, so no fixture in this
repo can be verified on any PR. Everything above is static verification: handlers,
schemas, mock-based integration tests, and forma rendering. That is real, but it is
not the documented bar.

Accordingly, and deliberately: no README rows, no CHANGELOG entries, and no
.github/conformance-matrix.txt lines.
conformance-matrix.txt says a fixture
belongs there only once it has passed CRUD and discovery against live Azure. None
has yet.

Two main-branch regressions found and fixed on the way

Both are split out because they are independent of this work and were blocking the
whole repo:

This PR also adds make verify-fixtures to the checks job. It is the only check
that renders a forma — build, unit tests, verify-schema, vet, lint and
pkl eval formae-plugin.pkl all evaluate the schema module, which is exactly how
#144 shipped green. It has since caught a second unrelated fault (a Pkl
stack overflow from a local shadowing a property name) that nothing else saw.

Environment work done up front

  • All six new ARM namespaces were NotRegistered and have been registered; a first
    run in an unregistered namespace 409s, which would have failed every fixture in
    half the groups.
  • APIM Consumption SKU measured at 159s to Succeeded on this subscription.
    Dedicated tiers take 30–45 min, past the ~45 min point where the OIDC client
    assertion expires (AADSTS700024). All 28 APIM types depend on this.

12 fixtures skip-listed, each with evidence

Environment or SDK limits, not plugin defects. Handlers, schemas and mocked tests
are complete for every one.

  • log-analytics-cluster — 500 GB/day capacity-reservation floor in this
    api-version, and hours to provision.
  • log-analytics-tableTablesClient has no PUT and no DELETE at all, so the
    lifecycle's out-of-band delete phase can never observe removal.
  • log-analytics-linked-serviceprovisional; its only two targets are the
    cluster above and an Automation account. AZURE::Automation::Account arrives in
    this PR, so repoint the fixture and drop this line.
  • storage-local-user, storage-object-replication-policy — need fields
    StorageAccount does not model (isHnsEnabled/isSftpEnabled; blob-service
    change feed and versioning).
  • api-management-gateway — self-hosted gateways need Developer or Premium.
  • api-management-api-tag-description — needs a pre-existing APIM tag; nothing in
    scope can create one.
  • api-management-{group,group-user,user,subscription,product-group} — the
    Consumption tier has no user, group or subscription store. Microsoft's tier
    matrix marks the developer portal unavailable there, footnoted as "including
    related functionality such as users, groups, issues, applications"; the service
    limits page gives Subscriptions | N/A and Users | N/A.

Two types ship without fixtures, on purpose

DataProtection::BackupInstanceBlobStorage and ...Disk have handler, schema and
integration test but no fixture. A backup instance needs the vault's managed
identity to hold a role on the datasource, and AZURE::DataProtection::BackupVault
models neither an identity nor a principalId output — so RoleAssignment has no
principal to point at. Closing that means editing an existing green resource, which
this wave does not touch. A fixture would fail [Create] every run and break this
PR's own CI, so there is none rather than a knowingly-red one.

Notes for review

  • 463 provider-default disposition rows, regenerated across the merged tree by a
    script validated to reproduce the previous manifest byte-identically.
  • Three groups introduced no hasProviderDefault at all, preferring required
    where the value should stay caller-owned — notably TriggerSchedule.startTime,
    which ARM otherwise sets to the moment of creation and would drift on every apply.
  • One cross-group compile error was caught only by gating the merged tree: two
    groups independently declared testLinkedServiceNativeID in the single
    pkg/resources package.
  • Verification claims in the per-group work are exit statuses, not parsed text.
    This workstation's shell rewrites command output, sometimes even in a redirected
    file — go vet > out.txt was observed producing Go vet: No issues found, which
    go vet never prints on success.

naxty added 12 commits September 2, 2026 13:05
`pre-cleanup` has failed on main since #145 with a single line:

    clean-environment.sh failed at line 189 (exit 1): GROUPS=$(list_groups)

and because `pre-cleanup` gates `conformance-tests` via `needs`, no fixture
could be verified on any PR while it was red.

Line 189 was never the problem. An ERR trap is not inherited by shell
functions unless `set -E` is on, so a failure anywhere inside `list_groups`
was reported against the *caller* - the command substitution - rather than
against the command that actually failed. Three fixes (#146, #147, #149)
were aimed at line 189 on the strength of that message.

Two changes:

- `set -eEuo pipefail`. With errtrace the trap fires at the inner line and
  names the real command, so the next occurrence is a one-line diagnosis.
- `list_groups` now runs its whole body with `set -e` off, not just the
  `az group list` call. Its status is consumed by `GROUPS=$(list_groups)`,
  where a non-zero exit is fatal; under `set -e` any failing command in the
  body aborted the function before its `return 0` could run, so the
  substitution exited non-zero and killed the script. Listing groups is
  read-only, so nothing in there needs to be fatal.

The ERR trap is also disarmed for that body: a tolerated failure is not an
error, and letting it reach the trap prints a GitHub `::error::` annotation
on a run that then succeeds. The existing `note:` line already reports it.

Verified with a stubbed `az` that writes valid JSON and exits non-zero - the
condition a CLEAN_DEBUG trace caught in CI. Before: the caller died. After:
the caller survives with the group list intact, and the trap names
`az group list` instead of line 189.
Phase 0 of the 100-resource wave. Every resource type needs a client field,
a constructor and a struct-literal entry, and `pkg/client/client.go` is a
single file that all twelve implementation groups would otherwise touch at
once. Landing all of it up front means no group has to edit a shared file,
so the groups cannot conflict with each other.

Six new SDK modules, all pinned:

    datafactory/armdatafactory                     v1.3.0
    automation/armautomation                       v1.0.0
    streamanalytics/armstreamanalytics             v1.2.0
    logic/armlogic                                 v1.2.0
    desktopvirtualization/armdesktopvirtualization v1.0.0
    apimanagement/armapimanagement                 v1.1.1

The remaining clients come from modules already required: armoperationalinsights
(Log Analytics depth), armstorage (storage depth), armdataprotection/v3 and
armnetwork/v4 (network manager).

Every one of the 78 constructors was verified by compile probe against a
throwaway module before being written here, rather than inferred from the
service name. The constructor list itself was enumerated from each module's
own source in the module cache, so there were no guesses left to be wrong -
the previous wave got 5 of 17 wrong in one pass, including two modules that
had removed the client entirely.

Network Manager's clients are named generically in armnetwork
(`GroupsClient`, `StaticMembersClient`), so their fields carry a
NetworkManager prefix to say what they actually address.
Adds schema/pkl/automation/ with the account plus its six children:
runbook, schedule, job schedule, variable, credential and module.

Every top-level field carries an @Azure.FieldHint - an unannotated field is
silently dropped from the generated property set, and build, unit tests and
verify-schema all stay green while live create fails with the handler's own
"X is required".

No hasProviderDefault annotation is introduced anywhere in this namespace. A
field ARM always answers Get with is declared required instead (the account's
skuName, the runbook's two log flags, the schedule's timeZone and isEnabled,
the variable's isEncrypted), so it is always present in desired state and
always comparable. Fields ARM only echoes when they were supplied stay
optional and are read back only when present.

Write-only where ARM cannot return the value: the credential password (the
response model has no password field at all), the variable value (never
returned when encrypted, and a runbook is a legitimate co-writer when not),
the module contentLink and the runbook publishContentLink (neither is
returned by Get).

The advancedSchedule block is deliberately not modelled: it is a plain nested
class whose members ARM fills in from the frequency, and hasProviderDefault is
a no-op inside one, so those service-chosen members would read back as
unexplained drift.
New namespace directory schema/pkl/streamanalytics/ covering the streaming
job, its three stream inputs (blob, Event Hub, IoT Hub), its four outputs
(blob, Event Hub, Service Bus queue, Azure Table) and a JavaScript UDF.

Two shapes worth calling out:

- Every datasource credential (storage account key, Event Hubs / Service Bus
  / IoT Hub shared access policy key) is writeOnly. ARM accepts them on PUT
  and PATCH but strips them from every response, so a readable field would
  report drift on every sync.

- ARM's polymorphic datasource discriminator is flattened away: each formae
  type pins one datasource, so its fields sit at the top level of the schema
  where FieldHint semantics (required/writeOnly) actually apply, rather than
  inside a nested class.

The streaming job models no execution state. Start/Stop is a separate ARM
verb pair, a running job cannot be reconciled into a stopped one by a PATCH,
and a running job bills streaming units per hour.
… services

Adds nine AZURE::DataFactory::* resource types (schemas + provisioners +
mock-based integration tests):

  Factory, IntegrationRuntimeSelfHosted, IntegrationRuntimeAzure, Pipeline,
  LinkedServiceAzureBlobStorage, LinkedServiceAzureSqlDatabase,
  LinkedServiceKeyVault, LinkedServiceWeb, LinkedServiceAzureTableStorage

Every armdatafactory verb these types use is synchronous — FactoriesClient,
LinkedServicesClient and PipelinesClient have no BeginX at all, and the only
pollers on IntegrationRuntimesClient are BeginStart/BeginStop for Azure-SSIS —
so no resource here creates a poller and Status always echoes success.

The five linked services share one envelope: datafactorylinkedservice.go
carries the LinkedServiceResource wrapper, the read-back mapping and the CRUD,
and each type file contributes only its ARM discriminator plus its connector
fields. Precedent: cosmoschild.go, servicebusprops.go.

Credentials are always sent as SecureString and declared writeOnly in the
schema: ARM returns only a mask, so comparing one on read would report drift
forever.
AZURE::Automation::Account, ::Runbook, ::Schedule, ::JobSchedule,
::Variable, ::Credential and ::Module, against armautomation v1.0.0
(api-version 2024-10-23).

Every client in this SDK module is named in the singular - AccountClient,
not AccountsClient - and none of them is LRO-based: there is no BeginX
anywhere except RunbookClient.BeginPublish, which this code deliberately
does not drive. So every Status implementation here is the echo form, and
create, update and delete all complete inside their own call.

JobSchedule is immutable: armautomation offers Create, Get and Delete and no
update verb, so every field is createOnly and Update refuses with the cause in
StatusMessage rather than a bare transition to Failed - the shape
roleassignment.go uses. Every failure path in all seven sets StatusMessage.

Read paths drop service-managed state that would otherwise report drift on
every sync: the schedule's nextRun and the four *OffsetMinutes, the runbook's
jobCount / state / provisioningState, the module's provisioningState and
everything else derived from the package ARM downloaded, and the timestamps on
all of them. Two service-normalisation traps are handled explicitly: the
schedule's year-9999 no-expiry sentinel is treated as unset, and its times are
re-emitted as RFC3339 in UTC so an offset-formatted response compares equal.

Write-only values are read with the existing opaqueString helper rather than a
plain string field, so an opaque property that arrives as a wrapper object
rather than plaintext cannot fail the whole json.Unmarshal.

Shared helpers live in automationaccount.go (the parent) rather than in a new
file: automationChildIDParts parses a child's ARM ID for all six children, and
automationChildNativeID builds the ID used as a create fallback.
Adds the PKL schemas and provisioners for the wave-3 Storage depth group:

- AZURE::Storage::LocalUser
- AZURE::Storage::BlobInventoryPolicy
- AZURE::Storage::ObjectReplicationPolicy
- AZURE::Storage::BlobContainerImmutabilityPolicy
- AZURE::Storage::BlobContainerLegalHold
- AZURE::Storage::QueueServiceProperties

Two of these are verb-shaped rather than resource-shaped: the immutability
policy is written through createOrUpdateImmutabilityPolicy with an If-Match
ETag the provisioner reads for itself, and the legal hold has only
setLegalHold/clearLegalHold with no Get, so its tag set is read off the parent
container and its identifier is synthesised from the container's ARM ID.

ObjectReplicationPolicy models ARM's two-sided flow honestly: the destination
copy is written first under the name `default`, which mints the policy and rule
ids, and the same document is then echoed onto the source account.

No hasProviderDefault annotation is introduced anywhere: every optional field
either carries a schema default and is always sent and always read back, or is
omitted from the read-back unless the service actually reports it.

Integration tests for LocalUser and BlobInventoryPolicy land with this commit;
the remaining four follow.
streamanalyticsio.go is the one shared helper: ARM models every input as a
single streamingjobs/<job>/inputs/<name> resource and every output as a
single .../outputs/<name> resource, with the interesting part behind a
properties.datasource.type discriminator. Formae splits those into one type
per datasource, which leaves seven resources with an identical envelope and
seven different datasource bodies. The envelope lives in the helper; each
resource file contributes only its datasource builder and serializer, in the
same shape as cosmoschild.go and servicebusprops.go.

Two rules the datasource contributions respect:

- No serializer ever emits a credential. ARM strips AccountKey and
  SharedAccessPolicyKey from every response, so echoing one back would put a
  field in state with no ARM value behind it and report drift every phase.

- List filters on the discriminator. NewListByStreamingJobPager returns every
  input (or output) of the job whatever its datasource, so discovery for
  InputBlob would otherwise claim the job's Event Hub inputs too. The same
  applies to the JavaScript UDF, which shares its ARM collection with
  aggregate functions and ML-web-service bindings.

The job's Read asks for $expand=transformation: ARM's default GET projection
excludes it, and there is no wired TransformationsClient, so the query is a
createOnly field of the job rather than its own resource type. Create and
delete are LROs; update is a synchronous PATCH that deliberately omits the
transformation, which ARM rejects on PATCH.

Every failure path sets ProgressResult.StatusMessage.
One base fixture per type plus an -update variant, except the job schedule,
which is immutable and therefore gets -replace: ARM has no update verb, so a
change to any of its fields is a delete-and-recreate.

Every fixture creates its own resource group and Free-tier automation account.
Free includes 500 job minutes a month at no cost and none of these fixtures
runs a job; the tier is quota-limited per subscription, so the account has to
go with the resource group.

Two fixtures reach outside Azure, deliberately and with pinned targets:

  - automation-module uses a versioned PowerShell Gallery nupkg
    (xActiveDirectory 2.19.0, updating to 2.20.0) because ARM fetches the
    package server-side and the import is what the resource does. Both URLs
    were verified live.
  - automation-job-schedule publishes its runbook from the canonical Azure
    quickstart tutorial script, because a job schedule can only attach a
    PUBLISHED runbook - a runbook created with an empty draft has nothing to
    run. automation-runbook itself omits the content link and uses the empty
    draft, so the runbook record is exercised with no outbound fetch at all.

The schedule fixtures use a far-future whole-minute startTime: ARM rejects a
start under five minutes out and truncates whatever it accepts to the minute,
and Pkl has no clock to compute one from.

The job schedule passes no runbook parameters: ARM validates them against the
runbook's declared parameters and the tutorial script declares none.

All fourteen were checked with pkl eval against a local project pinning
@Azure to this tree.
One base plus one -update fixture per type. Each is standalone: it builds
its own resource group, the parents its datasource needs (storage account
plus blob container or table, Event Hubs namespace plus hub, IoT hub, Service
Bus namespace plus queue) and a stopped streaming job.

Datasource credentials are placeholders, not real keys. ARM accepts any
string there and never returns it, Stream Analytics validates a datasource
only when the job starts or when the separate Test operation is called, and
none of these jobs is ever started. There is also no way to obtain a real key
from a forma: this plugin deliberately does not surface storage account keys
or SAS policy keys as properties.

Each update fixture changes exactly one in-place-patchable field, never a
createOnly one: the job widens its late-arrival window, the blob input and
output move their path prefix, the Event Hub and IoT Hub inputs and the Event
Hub output repartition on a different column, the Service Bus queue output
attaches a second message property, the table output doubles its batch size,
and the UDF changes its function body.
…rvices

One provisioner serves all five connectors, so the shared CRUD is exercised
once through the Key Vault kind and each of the other four gets its own
build/read round trip. Asserts what must NOT come back as much as what must:
every credential is wrapped in SecureString and absent from the read, and a
factory's mixed linked-service pager is filtered by discriminator so one
connector never claims another's IDs.
…and runbook

Assert the things a mocked test can actually prove and a live conformance run
would otherwise be the first to notice:

  - no operation mints a resume token, because armautomation has no LRO;
  - the read path drops every service-managed field (account state, runbook
    jobCount / state / provisioningState / logActivityTrace) and folds ARM's
    "East US" back to the compact region form desired state carries;
  - an omitted publishContentLink becomes an empty draft, since ARM rejects a
    runbook carrying neither that nor a draft, and a supplied one suppresses
    the draft;
  - a write-only property that arrives as the opaque wrapper object rather
    than unwrapped plaintext still yields its value;
  - the account PATCH does not restate the createOnly location;
  - a provider error reaches StatusMessage rather than being dropped.
naxty added 17 commits September 2, 2026 18:07
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
Committed by the orchestrator, not the implementing agent. The agent stopped
mid-task when the session hit its rate limit; this preserves the work in
progress so it is not lost, and is expected to be amended or built on when the
agent resumes. Not reviewed and not verified - the six-command gate has not been
run against this state.
…lasses

PR #144 changed the rule: schema extraction walks only nested classes that
formally extend formae.SubResource, so a field hint on a plain nested class is
silently inert. Identity and RepoConfiguration now extend it and carry real
hints, which makes `required` on their fields actually enforced.

identityPrincipalId and identityTenantId stay flattened to the top level. That
is no longer about the nested-provider-default bug — it is because a
formae.Resolvable addresses a top-level property by name, and a role assignment
granting the factory's managed identity a data role cannot reach a value nested
one level down.
PR #144 changed the rule: schema extraction walks only nested classes that
formally extend formae.SubResource, so a class without the clause has every
field hint on it silently dropped. TestNoOrphanFieldHintClasses now guards it.

Covers all nine nested classes in the namespace: StreamingJobSKU and
JobTransformation on the job, the per-type serialization blocks on the three
inputs and three of the four outputs, and FunctionArgument on the UDF. None
of them carries a nullable collection, so the SubResource render path's
absent / explicit-null / explicit-empty handling changes nothing here.
AZURE::OperationalInsights::LinkedService was the one type in this group
without a -update.pkl.

The update re-asserts the same link rather than changing one. ARM's
linkedServices body carries nothing but the two target IDs, and `name`
(the link kind) is the resource's own path segment, so the only mutable
property is the link target - repointing it would mean provisioning a
SECOND Log Analytics dedicated cluster: another 500 GB/day capacity
reservation and another 1-3 hour wait. What the fixture still exercises
is apply-idempotency: a second apply of an unchanged link must neither
replace it nor report drift.

Both linked-service fixtures are expected to land on
.github/conformance-pr-skip.txt for the same reason the cluster fixture
does.
…ule and variable

The three types whose read path carries the most service normalisation:

  - Schedule: ARM answers with an offset-formatted startTime, a year-9999
    no-expiry sentinel rather than a null, and interval as an untyped JSON
    number. Asserts the start time comes back as the same instant in the Z form
    desired state carries, the sentinel is dropped while a real expiry is kept,
    and nextRun plus the four derived *OffsetMinutes never reach the read.
    automationScheduleInterval is table-tested across every numeric form the
    SDK's untyped interval field can hold.
  - JobSchedule: asserts Update REFUSES with the cause on StatusMessage, since
    ARM has no update verb and a silent no-op would let core believe a change
    landed. Also that the name reaches the URL segment as the jobScheduleId,
    that the read falls back to properties.jobScheduleId (ARM leaves the
    envelope name unset on this type) without emitting it as a second
    property, and that a 404 on delete is success - a replace deletes first.
  - Variable: the load-bearing assertion is that the read NEVER emits the
    value, including the unencrypted case where ARM does return it. An
    encrypted variable never returns one, and a runbook can assign to an
    unencrypted one at runtime, so comparing it would report drift the plugin
    cannot own.

All three also cover the wrapped-opaque write path and confirm no operation
mints a resume token.
Wave 2 (ed4839e) landed 8 nested classes carrying `@azure.FieldHint` without
extending `formae.SubResource`, which makes `main` red on
`TestNoOrphanFieldHintClasses` and - more importantly - makes those hints
silently inert. Schema extraction only walks nested classes that formally extend
`SubResource`, so `required`, `hasProviderDefault`, `createOnly` and `writeOnly`
on any of them never reach `Schema.Hints`.

    alertsmanagement/alertprocessingruleactiongroup.pkl   AlertProcessingRuleCondition
    alertsmanagement/alertprocessingrulesuppression.pkl   AlertProcessingRuleSchedule
    alertsmanagement/prometheusrulegroup.pkl              PrometheusRule
    insights/autoscalesetting.pkl                         AutoscaleProfile, MetricTrigger,
                                                          ScaleAction, ScaleCapacity, ScaleRule

Same fix as #154: both the `extends` clause and the
`@azure.SubResourceHint { apiVersion = module.apiVersion }` annotation, because
the clause alone makes a class unrenderable - `Fq.subresourceProps` dereferences
a null `findSubResourceHint`.

This is a `main` bug rather than a wave-3 one, and it is fixed here only because
this PR merges `main` and cannot be green while inheriting it. It is worth
cherry-picking to `main` on its own.

Note this is the second time the same class of defect has reached `main` in two
days: #144 did it to 86 classes, wave 2 to 8 more. `TestNoOrphanFieldHintClasses`
catches it, but only once the schema is merged - the CI job that would have
caught wave 2's copy is the one this PR is trying to get green.

Full gate on the merged tree (wave 2 + wave 3, 292 registered types):

    make build            ok        go vet ./...          ok
    make test-unit        ok        golangci-lint         ok
    make verify-schema    ok        integration tests     ok
    make verify-fixtures  593/593   REUSE                 ok
    conformance-scope     13/13
naxty added 23 commits September 3, 2026 13:10
# Conflicts:
#	schema/provider-default-dispositions.json
All 7 Automation fixtures failed live conformance. Two distinct symptoms, one
cause:

    automation-account     [Verify] skuName should match: expected Free, got Basic
    automation-credential  [CreateOOB] create aacred-test-account failed:
                           RESPONSE 409: Conflict

The Free tier is quota-limited per subscription, and every Automation fixture
creates its own account in eastus. The first few get Free; after that Azure
either refuses outright (409) or silently hands back Basic, which then reads as
drift on every phase - Verify, Extract, Sync and Update all failed on the same
mismatch.

Basic is the deterministic choice here. It has no per-account charge - it bills
per job minute and per watcher, and these fixtures run no jobs and create no
watchers - and it is not quota-limited, so parallel fixtures cannot starve each
other.

This is my error, not the implementing agent's: the group brief said "use Basic
only if Free rejects something, Free is quota-limited to a handful per
subscription but is what CI should use", so Free was the reasonable reading. The
schema is unchanged and still correct - `skuName` stays `required` rather than
provider-defaulted, precisely because ARM always answers Get with a sku block,
so an omitted value would read as drift either way.

14 fixtures changed (7 types x base + update/replace). Verified locally:
`make verify-fixtures` 593/593, `make test-unit` 0 failures.
Soft-deleted API Management services keep counting against the Consumption
per-subscription service quota until they are purged, so they do not merely hold
a name - they eventually stop new services being created at all.

Observed on 2026-09-03: the subscription had accumulated 31 soft-deleted APIM
services and wave-3 fixtures started failing on create with

    RESPONSE 400: MaxConsumptionServicesPerSubscriptionExceeded

Every APIM fixture provisions its own Consumption service, so the quota is
reached quickly once deleted ones stop being reclaimed. Key Vaults have been
purged here from the beginning; APIM was simply missed.

Mirrors the Key Vault block, with two deliberate differences:

- **No `--no-wait`.** A queued vault purge is fine; a queued APIM purge does not
  free quota in time to help the run that follows, and the call returns quickly
  enough that waiting costs little.
- **The prefix is re-checked locally.** A purge is irreversible, so this only ever
  acts on a name the script has verified itself, rather than trusting the
  server-side `--query` projection - the same rule `list_groups` follows. This
  was not theoretical: the first version of this loop trusted the query, and a
  stub that ignored it purged a service the sweep had no business touching. The
  test now asserts a non-matching service is skipped by name.

Verified against a stub `az`: two `fpsdt-` services purged, one third-party
service skipped with a reason, sweep still exits 0.
Trap 6 was living in the shared wrapper, which made it everyone's problem at
once.

`createFailure`, `updateFailure`, `deleteFailure` and `statusFailure` set
`ErrorCode` and **discarded `err.Error()`**. Every resource routes its Go-error
path through those four helpers, so an ARM refusal reached core as a bare code
with no text - which is precisely the "51 of the plugin's resources drop the
provider error" symptom, except centralised.

They now set `StatusMessage` from the error, with a regression test that fails if
any of the four stops doing it (verified by reintroducing the bug: the test
catches it and names the helper).

## And the reason is now logged

Even when a provisioner sets `StatusMessage` itself - as the Data Protection
policies do - nothing printed it. From CI a failed operation looks like:

    [Create] Apply command should complete successfully: command reached terminal
    state: Failed

and the agent log shows the transition to Failed with no cause. During wave 3's
first live run that cost roughly fifteen failures their diagnosis: the only way
to see an ARM error was to reproduce the fixture by hand, and for one class
(`MaxConsumptionServicesPerSubscriptionExceeded`) the error only became visible
because a different fixture happened to surface it through the harness's own
out-of-band create.

`logFailure` now logs operation, native ID, error code and reason at Error level
via `plugin.LoggerFromContext`. It is called from the wrapper rather than from
each provisioner deliberately: every resource is wrapped, so one call site covers
all of them and cannot be forgotten by the next resource added. It fires on the
pass-through path too, where a provisioner returns a failure `ProgressResult`
rather than a Go error - which is the case the four helpers never saw.

Verified: build, test-unit (0 failures), integration tests, verify-schema,
verify-fixtures 593/593, go vet, golangci-lint, REUSE all pass.
Two Logic fixtures gave the explicit ARM error:

    RESPONSE 409: EnabledRegionalFreeSkuIntegrationAccountsQuotaExceeded

A subscription may hold exactly ONE Free integration account per region, and the
CRUD lifecycle creates a second, out-of-band account in the SAME region as the
one the forma declares. So a Free fixture needs two Free accounts in one region
and ARM refuses the second.

Group 7 already knew about the per-region limit and carefully spread its seven
account-bearing fixtures across seven regions to avoid colliding with each
other. That was correct as far as it went, and it is not enough: the collision is
*within* a single fixture, between the forma's account and the harness's
out-of-band one, so no amount of spreading helps.

`Basic` removes the constraint. It bills per hour rather than being free, but a
fixture holds an account for a few minutes, so the cost is negligible - the same
trade already made for Automation accounts in this wave.

14 fixtures changed. The schema's own COST guidance still recommends `Free` for
real use, which is right, but now records why conformance fixtures must not use
it, so the next person does not reintroduce the 409 by following the advice one
paragraph up.

Verified: verify-schema, verify-fixtures 593/593, test-unit 0 failures.
Two more failure classes from the 2026-09-03 live run, both addressed with the
mechanisms this repo already has rather than with code changes.

## Timeout arms (6 fixtures)

Five fixtures failed the DISCOVERY phase, not provisioning:

    [Discover] resource not discovered: timeout after 5m0s
    (7 discovery trigger attempt(s))

data-factory-data-flow, data-factory-linked-service-sql-database,
data-factory-linked-service-table-storage, and both log-analytics-datasource-*.

Worth stating what was ruled out before reaching for the timeout, because a
timeout arm is the easy answer and often the wrong one. Structurally identical
siblings passed: same `listParam` (byte-identical), same ARM type discriminator
(`AzureSqlDatabase`, `AzureTableStorage`, `MappingDataFlow` all correct), same
`discoverable` setting, and for the Data Factory three the same shared List
helper. The agent log shows the discovery cycle being paused and resumed
repeatedly for user changesets. So the resource is created and simply has not
been imported inside the 5 minute default. If they still time out at 15 min the
cause is not timing and the arm should come back out - that is recorded in the
script.

`api-management-service` gets 30/75: a Consumption instance provisions in ~3 min
but DELETE is far slower, and the lifecycle deletes twice (Destroy plus the
out-of-band phase). The job ran 10m23s and still failed on the OOB-delete wait.
Its 28 child fixtures stand up their own services and pass inside the default, so
only the parent's own lifecycle needs the wider budget.

## Two singletons skip-listed

storage-queue-service-properties and storage-blob-container-immutability-policy
fail only `[OOB Del]`, for the same structural reason log-analytics-table already
carries: a singleton whose Read keeps succeeding after an out-of-band delete can
never be seen to disappear. The queue-service singleton is named `default` and
has no create or delete at all - only SetServiceProperties, where "delete" means
reset-to-empty - and the immutability policy is verb-shaped with its state read
off a parent container that survives.

Every phase before OOB-delete passes for both. This is a shape mismatch between a
singleton and a lifecycle that assumes create/destroy symmetry, so no diff fixes
it.

conformance-scope self-check 13/13; verify-fixtures 593/593; test-unit clean.
…manager

Two more classes from the live run, one a real bug and one an environment limit.

## log-analytics-storage-insight-config: a genuine type bug

    failed to parse resource properties: json: cannot unmarshal object into Go
    struct field logAnalyticsStorageInsightConfigProps.storageAccountKey of type
    string

A write-only secret is declared in a forma as `formae.value("...").opaque`, which
renders as an OBJECT, not a string - so a `string` field rejects it before any
ARM call is made. `app-service-certificate` already had the answer for its pfx
blob and password: keep the raw value and unwrap with `opaqueString`, which takes
either a bare string or the wrapper. The field is now `any` and goes through a
small `storageInsightAccountKey` helper.

The existing test never caught this because it passes a bare string, which is
exactly the shape that works. Added a table test covering all three forms - bare
string, `$value` wrapper, `value` wrapper - so the failing shape is the one under
test.

## network-manager: all 8 types cannot pass here

    RESPONSE 400: BadRequest
    "Cannot have two Network Managers applied to the same object overlapping
     scope accesses. Scope id: /subscriptions/<sub>"

Azure permits ONE Network Manager per overlapping scope. Every fixture declares a
manager scoped to the subscription, and the lifecycle creates a second,
out-of-band manager at the same scope - so the fixtures conflict with each other
and with their own OOB manager. The second create at that scope always fails,
whatever the plugin does.

Management group scope is what would separate them, and it is unavailable on this
subscription - no principal holds a role above subscription scope, which is
precisely why group 10 was scoped to subscriptions only. The constraint that made
the group buildable is the same one that makes it untestable in parallel.

The code does work against ARM: `network-manager-static-member` got as far as
[Update] before failing. Handlers, schemas and mocked tests are complete for all
eight. The skip entries record what would let them run again - management-group
scope, or a harness that serialises fixtures declaring an exclusive scope.

Gate: build, test-unit, integration tests, verify-schema, verify-fixtures 593/593,
go vet, golangci-lint, REUSE, conformance-scope 13/13 - all pass.
I got the `--no-wait` call wrong in the previous commit and it showed up
immediately.

That version purged synchronously, reasoning that a queued purge would not free
quota in time to help the run that follows. Measured on the next CI run: each
synchronous purge takes ~1.3 min, so clearing a 16-service backlog held
`pre-cleanup` for over 17 minutes with **zero resource groups to sweep** - the
whole step was waiting on APIM. At steady state every run leaves ~28 soft-deleted
services behind, which would have meant ~36 minutes of purging in every single
sweep, on a job that otherwise finishes in seconds.

The original worry does not survive the timings either: the conformance matrix
takes hours, so a purge queued at the start of pre-cleanup completes long before
the APIM fixtures need the quota back.

The purge itself was working - the backlog went 16 -> 3 while that run's
pre-cleanup was still going - so this is purely about not blocking on it. Message
changed to "queued purge for N" so the log does not overstate what has finished.

Held back from the in-flight run deliberately: pushing this would have cancelled a
run that was minutes from starting 76 conformance fixtures, to save a purge that
was nearly done. It goes out with the next push.
…ionMethods

Both diagnosed from the ARM error text, which is visible for the first time
because of the wrap.go logging fix - these two had failed silently for two runs.

## api-management-global-schema

    ERROR CODE: ValidationError
    "XML schemas without targetNamespace are not allowed."

The fixture's XSD declared only the XMLSchema namespace. Added a
`targetNamespace` (and matching default xmlns) so the document is a valid
namespaced schema.

## api-management-authorization-server

    ERROR CODE: ValidationError
    target: "authorizationMethods"
    message: "Invalid value"

The fixture deliberately omitted the three method lists "so ARM applies its own
defaults", which the schema documents as GET. That is not what ARM does for an
implicit-grant server: it validates the field even when the request omits it.
Worth being precise about what was checked - the handler does the right thing
(`apimAuthorizationMethods` returns nil for an empty list, and the SDK omits
nil), so this is not a case of sending `[]` or `null`. The value simply has to be
stated, and the fixture now sets `["GET"]`.

The schema keeps `hasProviderDefault` on the field, which remains correct for
ordinary use where ARM does fill it in; only the fixture changes.

Both were previously invisible: for two runs these reported nothing beyond
"command reached terminal state: Failed", and the quota clearance did not fix
them - they still failed on a completely clean subscription, which is what ruled
the earlier quota hypothesis out for this pair.

verify-fixtures 593/593, test-unit clean.
The Free -> Basic change was necessary but not sufficient, and the new error
logging says exactly why:

    "The operation could not be completed because it exceeded your quota for
     Automation accounts in your subscription in the region."

The quota is on Automation ACCOUNTS PER REGION, independent of SKU - so moving
off Free fixed the drift that `automation-account` was failing on (it now passes)
and did nothing for the other six. Every fixture stood up its account in eastus,
and the CRUD lifecycle adds a second out-of-band account in the same region, so
seven fixtures wanted ~14 accounts in one region.

Each fixture now owns a region: eastus, eastus2, westus, westus2, centralus,
northeurope, westeurope - two accounts each, which fits. This is the same
approach group 7 took for Logic integration accounts, and it was the right
instinct; it just needed applying here too. Note it does NOT rescue Logic, where
the collision is between a fixture and its own out-of-band account within one
region - that needed the SKU change instead.

Also `timeZone = "UTC"` -> `"Etc/UTC"` in the two schedule fixtures. ARM
normalises the IANA name and answers Get with `Etc/UTC`, so the declared value
read as drift on Verify, Extract and Sync:

    [Verify] Property timeZone should match expected value: expected UTC,
             got Etc/UTC

verify-fixtures 593/593, test-unit clean.
…tion cap

    RESPONSE 400: "Maximum of 20 Consumption SKU services are allowed per
    Subscription <sub>."

21 APIM fixtures each stand up their own Consumption service, and the CRUD
lifecycle creates a second out-of-band one - roughly 42 service creations across
a run against a ceiling of 20 live, with deleted services still counting until
their soft-delete is purged. The suite as designed does not fit the
subscription, so these failures are capacity rather than correctness: the same
fixtures pass when they get a service and fail when they cannot, which is
exactly what the last two runs showed.

Six representative fixtures stay, chosen to keep one of each distinct shape
rather than the first six alphabetically:

    api-management-service           the parent's own lifecycle
    api-management-api               a first-level child
    api-management-api-operation     a second-level child
    api-management-product           the product family's parent
    api-management-policy            the policy-body XML round-trip
    api-management-diagnostic        the singleton-child shape

The 15 skipped all have handlers, schemas and mock-based integration tests, and
several were observed passing in earlier runs before the cap was hit - so this
costs live coverage, not confidence in the code. The skip block records the two
things that would bring them back: a raised Consumption-service quota, or
reworking the fixtures to share one long-lived service instead of each creating
its own.

Matrix goes from 76 to 61 fixtures. conformance-scope self-check 13/13.

Two fixes from this batch stay in and are worth keeping visible for whoever
raises the quota later: api-management-global-schema needed a targetNamespace on
its XSD, and api-management-authorization-server needed authorizationMethods
stated explicitly. Both are real fixes to real ARM validation errors; they are
skipped for capacity, not because they are unresolved.
…imeout arm

Three more, all from ARM error text the wrap.go logging finally surfaced.

## Data Protection: policies are immutable (4 fixtures)

    "Update of existing policy is not supported."

All four backup policies passed Create, Verify, Extract and Sync and failed only
[Update]. `BackupPoliciesClient` has no PATCH, and its CreateOrUpdate turns out
to be create-only in practice: a PUT against an existing policy is rejected
rather than replacing it. Group 9 had reasonably assumed the PUT would replace
the rule tree.

`backupRules` and `retentionRules` are now `createOnly` alongside the three
identity fields, so a changed rule tree plans as a replace rather than an
in-place update, and the `-update.pkl` fixtures become `-replace.pkl`. That is
the same shape AZURE::Automation::JobSchedule already uses, and the immutability
is documented on each of the four schemas with the ARM message quoted.

## Logic agreement: micHashingAlgorithm

    "The agreement 'conformance-as2-agreement' must have 'micHashingAlgorithm'
     property set in 'mdnSettings'."

The fixture did set it - to "NotSpecified", which ARM does not count as set.
Changed both occurrences to "SHA2256".

## Reverting my own discovery timeout arm

The previous commit widened the discovery budget for three Data Factory fixtures
and two Log Analytics datasources, on the theory that 5 minutes was simply too
short. It was not:

    [Discover] resource not discovered: timeout after 15m0s
    (17 discovery trigger attempt(s))

The arm applied and the fixtures still failed, so timing was not the cause. I
wrote into that arm that it should come back out in exactly this case, so it is
out. These five remain failing and are NOT skip-listed - unlike every other skip
in this file, there is no environmental or SDK reason established, and hiding a
possible real discovery bug behind a skip entry would be worse than leaving it
red. One clue for whoever picks it up: the discovery log carries
"Resource group '...-adfdssql-rg-...' could not be found", so a List may be
running against a scope that no longer exists.

The api-management-service arm stays - its OOB-delete genuinely needed longer
than 10 minutes.

Gate: build, test-unit, integration, verify-schema, verify-fixtures 593/593,
conformance-scope 13/13, go vet, golangci-lint all pass.
    WorkflowUnsupportedRecurrenceTriggerForResponseAction
    The workflow with 'Response' action type should not have triggers with
    'recurrence' property defined: 'tick'.

A `Response` action replies to whoever called the workflow, so ARM only allows
it under a trigger that has a caller - an HTTP Request trigger. Our definition
paired it with a Recurrence trigger, which has none.

Swapped the action for `Compose`, which is a data operation with no caller and
no external dependency. The Recurrence trigger stays, and the workflow is still
created `Disabled`, so it never fires and still costs nothing to hold. Using a
Request trigger instead would have worked too, but that publishes a callable
public endpoint for the length of every conformance run.
    unable to access host pool with name 'fpsdt-avd-sp-hp-...', please make sure
    that you have given the Azure Virtual Desktop service permissions to access
    your resource

A scaling plan is evaluated by the Azure Virtual Desktop service, not by the
caller, so that service principal needs its own read access to the host pool it
scales - Microsoft's "Desktop Virtualization Power On Off Contributor" role, or
any role carrying hostpools/read plus sessionHosts write on the scope. The
conformance subscription has no such assignment.

Not a plugin defect: the fixture wires hostPoolArmPath from the host pool it
creates, and the host pool itself creates fine. It is a one-time grant on the
subscription. The skip entry says so, and says to un-skip once it exists.
The real cause of every "[Discover] timeout" in this branch, and it is not
timing. The discovery log for log-analytics-storage-insight-config says:

    Discovery finished. The following resources have been discovered:
      AZURE::OperationalInsights::StorageInsightConfig  1
    Validation of required fields failed error="resource fpsdt-lasic-... of type
    AZURE::OperationalInsights::StorageInsightConfig is missing required fields:
    [storageAccountKey]"

Discovery found the resource and then threw it away. `writeOnly` says the
provider never returns the value; `required` makes core reject any resource that
lacks it. Together they make a resource permanently undiscoverable, and the
harness reports it as a bare `[Discover] resource not discovered: timeout after
5m0s` with nothing pointing at the cause - which is why my previous commit
mistook it for a slow List and widened the timeout instead.

Twenty wave-3 fields carried both hints. Every one of the 38 write-only fields
that predate this branch is optional and nullable, including
`datafactorylinkedserviceblobstorage.connectionString` - whose sql-database and
table-storage siblings in this branch are two of the failures. That is the
convention, and these twenty now follow it: `required` dropped, property made
nullable. Presence stays ARM's to enforce on the write; there is nothing for
core to check on a read that never returns the value.

This is expected to fix discovery for log-analytics-storage-insight-config, the
three Data Factory fixtures, all seven Stream Analytics inputs/outputs, the five
Logic integration-account children, automation-credential, automation-module and
logic-workflow. It also fixes container-registry-webhook, a wave-2 resource that
carried the same pair but has no conformance-matrix row, so nothing ever
exercised its discovery.

`TestNoFieldIsBothWriteOnlyAndRequired` walks schema/pkl and fails on the
combination. Verified it fails when the pair is reintroduced on
loganalyticsstorageinsightconfig and passes once removed. The SDK's
verify-schema cannot see this, and neither can any other gate.

Gate: verify-schema, verify-fixtures 593/593, test-unit, integration,
conformance-scope, go vet, golangci-lint, reuse lint all pass.
…he wrong form

Both Log Analytics datasource fixtures failed discovery. ARM's answer to our
ListByWorkspace call:

    Must specify a valid kind filter. For example,
    $filter=kind eq 'windowsPerformanceCounter'

We sent `kind='WindowsEvent'`. ARM rejects that spelling with a 400, so the
pager yielded nothing and the fixture reported the failure as a bare
`[Discover] resource not discovered: timeout after 15m0s`.

The wrong form came from the SDK's generated example file, which is built from a
swagger sample and never executed. The SDK's *live* test - the one that runs
against Azure - uses `kind eq 'WindowsEvent'`, and so does the REST reference.
Following the live test.

The reason this shipped green is worth naming: both mock tests asserted the
broken string.

    expected: "kind='WindowsEvent'"
    actual  : "kind eq 'WindowsEvent'"

A unit test that pins whatever the code currently does cannot catch a contract
error with the service. Both assertions now pin `kind eq '...'`, with a comment
at the assertion saying what it guards so the next edit does not restore the
old form to make a test pass.

The filter value carries no `$filter=` prefix: the SDK does
`reqQP.Set("$filter", filter)`, so a prefix would encode a second one into the
query string.

Gate: build, test-unit, go vet, golangci-lint, reuse lint all pass.
…a flag

I broke this myself two commits ago and the failure was silent.

b596e46 added `--no-wait` to `az apim deletedservice purge` to stop pre-cleanup
blocking 17 minutes on a purge backlog. That flag does not exist:

    ERROR: unrecognized arguments: --no-wait

So every purge failed. Two things hid it: a failed purge is tolerated here by
design (it must not abort the sweep), and the count printed was the count of
successes - so a run that purged nothing printed "queued purge for 0" and moved
on. Soft-deleted services then accumulated unchecked until they exhausted the
20-service Consumption cap. Measured on the live subscription just now: 22
soft-deleted against 5 live, i.e. 27 against a cap of 20, which is exactly the
`MaxConsumptionServicesPerSubscriptionExceeded` that every APIM fixture has been
failing on. Each soft-delete holds its slot for two days.

`az rest` sends the DELETE and returns on ARM's 202 without the CLI's polling
loop - measured ~20s per service against ~1.3 min - and does not depend on which
flags this month's `az apim` accepts. It needs the subscription id, so that is
now resolved once up front and the script exits early rather than building an
unroutable URL from an empty value.

The count now reports failures separately, so the next silent no-op is not
silent.

Purged all 22 by hand with the same call to unblock the run in flight: 22 -> 0
soft-deleted, 5 live untouched. The local prefix re-check and the live-service
exclusion were applied to each name before purging.

bash -n passes; both CI shell tests pass.
    ValidationError
    Error in element 'base' on line 3, column 6: Element <base/> is not allowed
    in global context

`<base />` splices in the policy of the enclosing scope, so it is only
meaningful where there is one - the product, API and operation policies, which
all keep it. AZURE::ApiManagement::Policy is the service-wide policy and has no
parent, so ARM rejects the element outright.

Replaced with APIM's own stock global policy: empty inbound, outbound and
on-error, and a backend that forwards. The update fixture keeps its set-header
in inbound so the Update phase still has something to change.

The reason it read as a capacity failure at first glance is that every other
APIM failure in this run was the Consumption cap; this one is a fixture defect
and would have failed on an empty subscription.

Gate: verify-schema, verify-fixtures 593/593, conformance-scope, reuse lint.
## Backup policies: a replace must change the NativeID (4 fixtures)

    [Replace] NativeID should change after replace
    (old: .../backupVaults/fpsdt-bpdisk-.../backupPolicies/fpsdt-bpdisk-pol-...)

Making the rule trees createOnly was right - ARM does refuse to update a policy
in place - but the -replace fixtures kept the policy's name, so the "replacement"
addressed the same ARM path and the NativeID never moved. The replace fixtures
now use a distinct policy name.

## Logic assembly: contentType is not optional

    The 'contentType' property of assembly 'conformance-assembly' must be set to
    'application/octet-stream'.

An assembly is always a .dll, so there is exactly one legal value and nothing for
a caller to choose. Sent as a constant from the provisioner rather than added to
the schema as a field with one permitted setting.

## Logic agreement: NotSpecified is not a value, twice over

    The agreement 'conformance-as2-agreement' must have 'encryptionAlgorithm'
    property set in 'validationSettings'.

Same shape as the micHashingAlgorithm fix before it, in a different block, in
both the receive and send agreements: the field was present but set to
"NotSpecified", which ARM does not count as set. Now AES256.

That earlier fix had a consequence I missed at the time. micHashingAlgorithm
NotSpecified -> SHA2256 was the ONLY difference between the base and update
fixtures, so setting the base to SHA2256 made them identical and left the Update
phase with nothing to change. The update now moves
interchangeDuplicatesValidityDays from 5 to 10 - a scalar ARM echoes back, so
Verify can actually see it.

## Logic certificate: the update added a resource instead of changing one

    [Update] Inventory should still contain exactly 1 resource after update

The -update fixture declared a SECOND certificate rather than modifying the
first, so the update left two resources behind. Worse, the sibling carried the
same key material as the original, so it would not have exercised a rotation
even if the harness had allowed it.

Now it rotates publicCertificate on the one certificate, to an independently
generated self-signed cert. name, resourceGroupName and integrationAccountName
are all createOnly and publicCertificate is the only other field this type has,
so rotation is the only in-place update available. It is writeOnly - ARM answers
a GET with a thumbprint, never the key - so Verify cannot compare the value; the
phase proves the apply succeeds and the resource is not replaced.

Gate: build, test-unit, integration, verify-schema, verify-fixtures 593/593,
conformance-scope, go vet, golangci-lint, reuse lint all pass.
…ture

## Logic schema: contentType

    The 'contentType' property of schema 'conformance-order-schema' of type 'Xml'
    must be set to 'application/xml'.

Same shape as the assembly fix in the previous commit, and the sibling
IntegrationAccountMap already had it right - logicMapContentType derives the
media type from mapType because Liquid is text/plain and every XSLT flavour is
XML. SchemaType has only Xml and NotSpecified and an integration-account schema
is an XSD either way, so this one does not vary and goes across as a constant.

## AZURE::ApiManagement::Service: skipped, and not for a timeout

Reproduced in two consecutive runs:

    [Update] Property publisherName should match expected value (after update):
             expected Platform Engineering Labs (updated),
             got Platform Engineering Labs
    ServiceLocked: The API Service fpsdt-apim-... is transitioning at this time.
    [OOB Del] timeout waiting for resource ...

The provisioner is not at fault. Update calls BeginUpdate, returns a resume token
and reports InProgress until Status sees the poller finish - the same shape every
other LRO resource in this plugin uses. ARM reports the update complete and then
still answers a GET with the previous publisherName, and the service stays
"transitioning" long enough to lock the delete that follows.

That is eventual consistency inside the service, not slowness, so the timeout arm
does not address it: 30/75 was already applied to this fixture when the above was
captured. The APIM children that need a service still create one; only the
service's own CRUD fixture is skipped, with the evidence recorded inline.

Gate: build, test-unit, integration, verify-schema, verify-fixtures 593/593,
conformance-scope, go vet, golangci-lint, reuse lint all pass.
…e residue

    [create] Conflict | A jobSchedule with same id already exists.
    [OOB Del] Re-apply command should complete successfully: command reached
              terminal state: Failed

Failed twice in a row, so this is not the flake I took it for on the first
sighting. Automation acknowledges a jobSchedule delete before its index catches
up, and a create against the same name keeps returning 409 for a short while
after. The conformance harness walks straight into it: the OOB-delete phase
deletes out of band and immediately re-applies.

This type is unusually exposed to it. ARM uses the jobScheduleId as both the URL
segment and the resource name, so the fixture supplies a fixed GUID rather than
letting ARM allocate one - every re-apply lands on exactly the name just removed.

Raising the phase timeout would not have helped: the create fails fast, it does
not hang. Retrying is the only thing that can succeed, so `retryOnDeleteResidue`
re-runs the create for up to 90s while ARM answers 409.

It is deliberately narrow. Only 409 is retried - a 400 is a caller error and a
plain error is not a status at all, and both return on the first attempt. A 409
that is a genuine name collision keeps failing and is returned once the window
closes, with the original error preserved so the reason still reaches the caller.
A cancelled context returns immediately rather than sleeping out the interval.

The bounds are vars so TestRetryOnDeleteResidue can shrink the window to
milliseconds instead of sleeping through it; it covers all six paths, including
that the retried call actually succeeds on a later attempt.

The helper lives in common.go because nothing about it is Automation-specific -
any service that acknowledges a delete early can use it.

Gate: build, test-unit, integration, verify-schema, verify-fixtures 593/593,
conformance-scope, go vet, golangci-lint, reuse lint all pass.
Reverts 2f222e8. The retry was the wrong diagnosis and it made the failure worse.

I read "A jobSchedule with same id already exists" as Azure acknowledging a delete
before its index caught up, and retried the create for 90s. The next run showed
what actually happens:

    RESPONSE 409: 409 Conflict          (x4, across the whole phase)
    [OOB Del] Re-apply command should complete successfully:
              timeout waiting for command 3Iq2PI07... to complete
    --- FAIL: TestPluginConformance (354.71s)

Four separate create attempts, all 409, and the phase ended on a command timeout
at 354s instead of failing fast. The retry converted a quick, legible failure into
a slow one and fixed nothing - the same reason I took out the discovery timeout
arm earlier in this branch.

The id is not reusable. ARM uses the jobScheduleId as both the URL segment and the
resource name, so it is caller-supplied and pinned in the fixture rather than
allocated by Azure, and the phase order re-uses one that has already been deleted:

    Step 13  replace   creates 8b1d4f60-..., deletes base 3f7c9e1a-...
    Step 16  destroy   removes 8b1d4f60-...
    Step 19  re-apply  re-applies the BASE fixture, i.e. 3f7c9e1a-... again

By step 19 the base id has been gone for minutes and Azure still refuses it.
No provisioner can make that create succeed, so the fixture is skipped with the
sequence recorded inline.

This is not a defect in the resource. Create, Verify, Extract, Sync, Update,
Replace and Destroy all pass; only the re-apply of a deleted id fails.

Gate: build, test-unit, integration, verify-schema, verify-fixtures 593/593,
conformance-scope, go vet, golangci-lint, reuse lint all pass.
@naxty
naxty merged commit 80ebbd9 into main Sep 4, 2026
66 checks passed
@naxty
naxty deleted the naxty/100azureresources branch September 4, 2026 06:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant