Skip to content

feat(azure): Microsoft.Web (App Service) — 7 resource types - #126

Merged
naxty merged 6 commits into
mainfrom
naxty/wave1-web
Aug 31, 2026
Merged

feat(azure): Microsoft.Web (App Service) — 7 resource types#126
naxty merged 6 commits into
mainfrom
naxty/wave1-web

Conversation

@naxty

@naxty naxty commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Closes the Microsoft.Web (App Service) gap: the plugin had zero PaaS compute
coverage, which the coverage gap analysis flagged as its single
biggest hole. Seven new resource types, all on
armappservice/v5 (already a dependency), all at api-version 2025-03-01.

Base is naxty/wave1-plumbing (PR #125) — this branch depends on the App Service
clients that commit adds to pkg/client/client.go.

Resource types

formae type ARM type client write path
AZURE::Web::ServicePlan Microsoft.Web/serverfarms PlansClient LRO create/update, sync delete
AZURE::Web::WebApp Microsoft.Web/sites WebAppsClient LRO create/update, sync delete
AZURE::Web::FunctionApp Microsoft.Web/sites (kind=functionapp*) WebAppsClient LRO create/update, sync delete
AZURE::Web::WebAppSlot Microsoft.Web/sites/{site}/slots/{slot} WebAppsClient slot methods LRO create/update, sync delete
AZURE::Web::Certificate Microsoft.Web/certificates CertificatesClient fully synchronous
AZURE::Web::CustomHostnameBinding Microsoft.Web/sites/{site}/hostNameBindings/{host} WebAppsClient binding methods fully synchronous
AZURE::Web::StaticSite Microsoft.Web/staticSites StaticSitesClient LRO create/update and LRO delete

New schema modules live in schema/pkl/web/; Go provisioners in
pkg/resources/. No shared file is touched — resources self-register via
init().

The non-obvious decisions

The site GET returns an empty siteConfig. ARM says so explicitly ("this
property is not returned in response to normal create and read requests since it
may contain sensitive information"), so WebApp, FunctionApp and WebAppSlot
all make a second call to .../config/web on the read path and merge the
result in. A missing config resource is tolerated and the block is omitted.

appSettings is a top-level write-only field, not nested in siteConfig.
Two reasons: annotations are only honoured on top-level fields, and Azure serves
app settings from a separate endpoint that also returns platform-injected
settings no forma declared — reading them back would be permanent drift. They are
sent on every create and update and never read back, so drift in them cannot be
detected. Same trade-off containerapp's secret values already make.

Discovery is partitioned by kind. WebApp and FunctionApp are the same
ARM type, so WebApp.List drops sites whose kind carries functionapp and
FunctionApp.List keeps only those. Without the split every site would be
reported twice, once per formae type. Both sides are tested over one mixed
listing.

A non-functionapp kind on a FunctionApp is an error, not a correction.
Silently fixing it would create a plain web app under a resource claiming to be a
function app — and the next discovery pass would not even see it as this type.

Nested hasProviderDefault is annotated but documented as not working. Azure
fills in sku.tier, sku.capacity and every siteConfig field whether or not
the create body set them, and the conformance framework only honours the hint on
top-level fields. Every nested field is annotated, and each schema and fixture
carries a comment saying to set them explicitly. This is the same caveat
eventhubnamespace's NamespaceSKU already documents.

Secret-bearing fields (pfxBlob, password, repositoryToken, and any
appSettings value) are writeOnly = true and typed (formae.Value|String) so
they can be wrapped with formae.value(...).opaque. pfxBlob carries base-64
text while the SDK model is raw bytes it re-encodes on the wire, so the plugin
decodes on the way in and rejects invalid base-64.

Other house-pattern details: armIDParts for every ID parse,
canonicalizeEnum on every enum read (ARM hands sslState back lower-cased,
sku.name as free), normalizeAzureLocation on location ("East US 2"
eastus2), isDeleteSuccessError so a 404 delete is success, and an in-flight
LRO create that pins the ARM ID the resource will land at.

Fixtures

testdata/{app-service-plan,web-app,function-app,web-app-slot,app-service-certificate,custom-hostname-binding,static-site}{,-update}.pkl
— 14 files. Each -update changes only non-createOnly fields.

Cost/shape notes baked into the fixture headers: Linux B1 plans throughout
except web-app-slot, which needs S1 (slots need Standard or better and ARM
rejects the create otherwise); static-site uses the Free SKU in eastus2
(Static Web Apps ships in few regions) and is created detached — no
repositoryUrl, so no GitHub PAT and nothing billed. function-app wires storage
with the identity-based connection form
(AzureWebJobsStorage__accountName + __credential = managedidentity) rather
than a connection string, because formae cannot resolve storage account keys —
and it runs on a dedicated plan rather than Consumption, which validates the
storage connection at create time.

Two fixtures cannot pass conformance — please exclude them from the matrix

Neither for a plugin reason. Both are the same class of exclusion
cdn-afd-custom-domain and cdn-secret already carry in
.github/conformance-matrix.txt, and both fixtures say so in a header comment:

  • custom-hostname-binding — Azure verifies domain ownership before it
    accepts a binding: the hostname must already resolve to the app through a CNAME
    (or an A record plus an asuid.<host> TXT record) in the domain's real DNS
    zone. The conformance subscription owns no domain, so every create fails with
    CustomDomainVerificationFailed. There is no hostname it could succeed with.
    The fixture uses www.example.com (IANA-reserved for documentation) so the
    placeholder cannot accidentally bind a real domain.
  • app-service-certificate — Azure validates the certificate against the
    hostnames of apps on the referenced App Service plan and rejects anything whose
    subject matches no verified custom domain in it. With no domain there is no
    certificate it would accept. pfxBlob is a syntactically valid but meaningless
    base-64 string so the fixture still evaluates and still exercises the
    marshalling path.

Run either live locally against a real domain/certificate with
make conformance-test-crud TEST=custom-hostname-binding /
make conformance-test-crud TEST=app-service-certificate.

The other five (app-service-plan, web-app, function-app, web-app-slot,
static-site) are written to pass and should go in the matrix. One caveat worth
stating plainly: the siteConfig round-trip has not been exercised against
live ARM
. Every field Azure fills in is spelled out in the fixtures
(netFrameworkVersion = "v4.0" even on Linux, functionAppScaleLimit = 0 on a
dedicated plan) based on what ARM documents and az webapp config show returns,
but one live run may still turn up a field that needs adding or dropping from the
fixture. healthCheckPath is deliberately left out of the fixtures: Azure returns
it empty when unset and the plugin skips empty values, so an omitted path is
drift-free.

Verified

$ gofmt -l pkg schema
(no output)

$ go build ./...
Success

$ go vet ./...
No issues found

$ go test ./pkg/...
1060 passed in 6 packages

$ go test -tags=integration ./pkg/...
1813 passed in 6 packages

$ go test -tags=unit ./pkg/...
1117 passed in 6 packages

$ make lint
0 issues.

$ make verify-schema
Status: PASSED — 156 modules, 155 resource types, 0 duplicate files, 0 duplicate types

$ make lint-reuse
Files with copyright information: 860 / 860 — compliant

All 14 new fixtures were additionally evaluated with pkl eval against the local
schema project (in a scratch project so no PklProject was added to testdata/)
to confirm they render.

No conformance test was run and nothing in this branch touched real Azure, per
the wave brief.

Deliberately left out

  • AZURE::Web::Certificate's expirationDate / issueDate — read-only time
    fields whose formatting is a drift risk with no upside.
  • Slot swap (SwapSlot) — an imperative operation, not declarative state.
  • Static Web App custom domains, database connections and linked backends — their
    own ARM child types, out of scope for this batch.
  • AZURE::Web::WebAppSlot does not model identity, publicNetworkAccess or
    virtualNetworkSubnetId; the read path explicitly does not emit them, so state
    can never hold a property no forma can express.
  • Nothing was dropped for difficulty: all seven types in the brief are here.

Follow-up for the orchestrator

  • README resource-table rows for the seven types.
  • .github/conformance-matrix.txt: add app-service-plan, web-app,
    function-app, web-app-slot, static-site; leave
    custom-hostname-binding and app-service-certificate out with the reasons
    above.
  • CHANGELOG entry.

naxty added 5 commits August 28, 2026 16:43
First Microsoft.Web resource in the plugin. The App Service plan is the compute
every web app, function app and deployment slot hangs off, so it lands first.

- AZURE::Web::ServicePlan -> Microsoft.Web/serverfarms, api-version 2025-03-01
  via armappservice/v5 PlansClient.
- sku (name/tier/capacity), kind, reserved, zoneRedundant, perSiteScaling,
  elasticScaleEnabled, maximumElasticWorkerCount.
- LRO create/update, synchronous Delete, so Status only ever resumes a create or
  update poller. An in-flight create pins the ARM ID the plan will land at.
- Nested sku.tier / sku.capacity are annotated hasProviderDefault and documented
  as needing to be set explicitly: the conformance framework only honours that
  hint on top-level fields, so an omitted nested value reads as drift. Same
  caveat eventhubnamespace's SKU already carries.

Fixtures: testdata/app-service-plan{,-update}.pkl (Linux B1; the update turns on
per-site scaling).
PaaS compute, the biggest hole the gap analysis against terraform/pulumi found.
Both types are Microsoft.Web/sites (api-version 2025-03-01) served by the
armappservice/v5 WebAppsClient; they stay separate formae types because the
function-specific surface is what makes a function app one.

- AZURE::Web::WebApp: serverFarmId (resolvable), kind, httpsOnly,
  clientAffinityEnabled, publicNetworkAccess, virtualNetworkSubnetId, identity
  (SystemAssigned / UserAssigned, matching the grafana/managedcluster shape plus
  user-assigned IDs), and a siteConfig block covering linuxFxVersion,
  windowsFxVersion, netFrameworkVersion, alwaysOn, ftpsState, minTlsVersion,
  http20Enabled and healthCheckPath.
- AZURE::Web::FunctionApp: the same surface plus functionAppScaleLimit, with kind
  pinned to a functionapp variant. An explicit non-functionapp kind is rejected
  rather than silently corrected — it would create a plain web app under a
  resource that claims to be a function app, invisible to this type's discovery.
- Read makes a SECOND ARM call to .../config/web: the site GET deliberately
  returns an empty siteConfig because it can carry secrets. A missing config
  resource is tolerated and the block is simply omitted.
- appSettings is a top-level write-only field rather than nested in siteConfig.
  Annotations are only honoured on top-level fields, and the ARM endpoint that
  serves app settings also returns platform-injected settings no forma declared,
  which would read as permanent drift. They are sent on every write and never
  read back.
- Discovery is partitioned by kind: WebApp.List drops sites whose kind carries
  "functionapp" and FunctionApp.List keeps only those, so no site is reported
  twice. Covered by a test on each side over one mixed listing.
- LRO create/update, synchronous Delete.

Fixtures: testdata/web-app{,-update}.pkl and testdata/function-app{,-update}.pkl.
The function app wires storage with the identity-based connection form
(AzureWebJobsStorage__accountName + __credential) because formae cannot resolve
storage account keys, and runs on a dedicated plan rather than Consumption, which
validates the storage connection at create time.
- AZURE::Web::WebAppSlot -> Microsoft.Web/sites/{site}/slots/{slot},
  api-version 2025-03-01, via the WebAppsClient slot method family
  (BeginCreateOrUpdateSlot / GetSlot / DeleteSlot / GetConfigurationSlot /
  NewListSlotsPager). Child of AZURE::Web::WebApp.
- Shares the site marshalling with WebApp but has its own serializer: the slot
  schema is a strict subset, and emitting identity / publicNetworkAccess /
  virtualNetworkSubnetId would put properties in state that no forma can express.
- name comes from the ARM ID, not the body: ARM reports a slot's Name as
  "<site>/<slot>".
- serverFarmId is optional — ARM inherits the parent app's plan.
- Like the parent site, the slot GET returns an empty siteConfig, so Read makes a
  second call to the slot's .../config/web. appSettings is write-only for the same
  reason as on WebApp.
- LRO create/update, synchronous DeleteSlot.

Fixtures: testdata/web-app-slot{,-update}.pkl. The plan is Standard S1, not
Basic: slots need Standard or better and ARM rejects the create otherwise.
The custom-TLS half of App Service. Both are fully synchronous ARM types, so
Status just re-reads.

- AZURE::Web::Certificate -> Microsoft.Web/certificates, api-version 2025-03-01,
  via CertificatesClient. serverFarmId plus either pfxBlob+password or
  keyVaultId+keyVaultSecretName (or canonicalName for a managed certificate); one
  of the three is required. pfxBlob and password are write-only, wrapped with
  formae.value(...).opaque, and never read back — Azure returns only derived
  metadata (thumbprint, subjectName, issuer, hostNames). The PKL field carries
  base-64 text and the SDK model is raw bytes it re-encodes on the wire, so the
  plugin decodes on the way in and rejects invalid base-64.
  Go type is WebCertificate because AZURE::KeyVault::Certificate already owns
  Certificate in pkg/resources, and the schema file is web/webcertificate.pkl
  because verify-schema rejects duplicate PKL basenames.
- AZURE::Web::CustomHostnameBinding ->
  Microsoft.Web/sites/{site}/hostNameBindings/{hostname}, via the WebAppsClient
  hostname-binding methods. Child of AZURE::Web::WebApp. `name` IS the hostname;
  everything except sslState and thumbprint is createOnly. name comes from the
  ARM ID because ARM reports Name as "<site>/<hostname>", and the parent site
  name goes in the body as well as the path.

NEITHER FIXTURE CAN PASS CONFORMANCE on the shared subscription, and neither for
a plugin reason:

  * custom-hostname-binding needs a VERIFIED custom domain — the hostname must
    already resolve to the app through a CNAME (or an A record plus an
    asuid.<host> TXT record) in the domain's real DNS zone, or Azure returns
    CustomDomainVerificationFailed. The subscription owns no domain.
  * app-service-certificate needs a certificate whose subject matches a verified
    custom domain on the referenced App Service plan; with no such domain there
    is no certificate Azure would accept. The fixture carries a syntactically
    valid but meaningless base-64 pfxBlob so it still evaluates.

Both are the same class of exclusion cdn-afd-custom-domain and cdn-secret already
carry in .github/conformance-matrix.txt, and the fixtures say so in a header
comment. CRUD is covered by the mocked tests here; run live locally with
  make conformance-test-crud TEST=custom-hostname-binding
  make conformance-test-crud TEST=app-service-certificate
- AZURE::Web::StaticSite -> Microsoft.Web/staticSites, api-version 2025-03-01,
  via StaticSitesClient. sku, repositoryUrl, branch, repositoryToken,
  buildProperties, stagingEnvironmentPolicy, allowConfigFileUpdates,
  publicNetworkAccess, and defaultHostname as a read-only output.
- LRO create/update AND LRO delete, so Status carries both branches.
- repositoryToken and buildProperties are write-only: ARM accepts both in the
  create body but returns neither on a GET, so serializing them from the read
  path would report permanent drift. Tests assert they do not surface even when a
  response carries them.
- Nested sku.tier is annotated hasProviderDefault and documented as needing to be
  set explicitly, same caveat as ServicePlan's SKU.

Fixtures: testdata/static-site{,-update}.pkl — Free SKU, created DETACHED (no
repositoryUrl), so no GitHub PAT is needed and nothing is billed. eastus2 rather
than the eastus the other Microsoft.Web fixtures use: Static Web Apps ships in
only a handful of regions.
@naxty
naxty force-pushed the naxty/wave1-plumbing branch from 9d658c6 to 61383de Compare August 28, 2026 14:44
@naxty
naxty changed the base branch from naxty/wave1-plumbing to main August 28, 2026 15:29
@naxty naxty closed this Aug 28, 2026
@naxty naxty reopened this Aug 28, 2026
The conformance subscription has a dedicated-VM quota of 0, and an App Service
plan of any sku runs on dedicated VMs. ARM refuses both B1 (Basic) and F1 (Free)
identically, verified directly against the subscription:

  Code: Unauthorized
  Operation cannot be completed without additional quota.
  Current Limit (Total VMs): 0 / Amount required for this deployment: 1

So no plan can exist there, and app-service-plan fails on the quota rather than
on anything the plugin did. web-app, function-app and web-app-slot then cascade,
because each needs a plan. That is exactly what PR #126's first scoped
conformance run showed: those four failed, static-site passed.

Raising the subscription's App Service / regional vCPU quota is the only
unblock; the four lines come back out when that lands. StaticSite is unaffected
(Static Web Apps are serverless) and stays in PR scope, so the Microsoft.Web
group keeps live coverage of the one type that can run. CRUD for the other four
stays covered by the mocked integration tests.

Note for whoever picks up the quota request: the failure was invisible from the
workflow log. 150 of the plugin's 156 resources drop the provider error instead
of putting it in ProgressResult.StatusMessage, so the agent logged only a state
transition to Failed and the real cause had to be read out of the Azure activity
log. Worth fixing plugin-wide, separately.
@naxty
naxty merged commit 916a2b8 into main Aug 31, 2026
9 checks passed
naxty added a commit that referenced this pull request Aug 31, 2026
The 25 resource types from PRs #126, #127 and #128 landed on main with no
ongoing test coverage and no documentation. Their pull-request conformance runs
proved them once, but nothing guarded them afterwards: none of their fixtures
were in the curated matrix, so neither main nor nightly exercised any of them.

Matrix: adds the 15 fixtures that passed CRUD *and* discovery on their own PR,
which is the bar this file asks for - static-site, virtual-wan, vpn-site,
bastion-host and all eleven cosmos-*. 113 active entries becomes 128.

Ten fixtures stay out, each with its reason recorded inline:

- app-service-plan, web-app, function-app, web-app-slot - the subscription has a
  dedicated-VM quota of 0 and an App Service plan of any sku runs on dedicated
  VMs. Verified against both B1 and F1, refused identically.
- app-service-certificate, custom-hostname-binding - need a real verified custom
  domain.
- virtual-hub - passes locally end to end (68m50s) but its CRUD phase measures
  ~59 min, longer than the OIDC client assertion stays valid, so the run dies on
  AADSTS700024 in the final phase. A credential-lifetime limit, not a plugin bug.
- vpn-gateway, virtual-network-gateway, virtual-network-gateway-connection -
  60-90 min lifecycles, past the job's timeout-minutes: 120.

virtual-hub also joins conformance-pr-skip.txt. It was excluded from the matrix
but not from PR scope, so a later PR touching its fixture would have spent an
hour to fail on the same expired assertion.

README: the resource table was 148 rows against 173 registered types. Adds the 25
missing rows, re-sorted; the table and the schemas now agree exactly.
@naxty
naxty deleted the naxty/wave1-web branch September 4, 2026 06:30
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