feat(azure): Microsoft.Web (App Service) — 7 resource types - #126
Merged
Conversation
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
force-pushed
the
naxty/wave1-plumbing
branch
from
August 28, 2026 14:44
9d658c6 to
61383de
Compare
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 Serviceclients that commit adds to
pkg/client/client.go.Resource types
AZURE::Web::ServicePlanMicrosoft.Web/serverfarmsPlansClientAZURE::Web::WebAppMicrosoft.Web/sitesWebAppsClientAZURE::Web::FunctionAppMicrosoft.Web/sites(kind=functionapp*)WebAppsClientAZURE::Web::WebAppSlotMicrosoft.Web/sites/{site}/slots/{slot}WebAppsClientslot methodsAZURE::Web::CertificateMicrosoft.Web/certificatesCertificatesClientAZURE::Web::CustomHostnameBindingMicrosoft.Web/sites/{site}/hostNameBindings/{host}WebAppsClientbinding methodsAZURE::Web::StaticSiteMicrosoft.Web/staticSitesStaticSitesClientNew schema modules live in
schema/pkl/web/; Go provisioners inpkg/resources/. No shared file is touched — resources self-register viainit().The non-obvious decisions
The site GET returns an empty
siteConfig. ARM says so explicitly ("thisproperty is not returned in response to normal create and read requests since it
may contain sensitive information"), so
WebApp,FunctionAppandWebAppSlotall make a second call to
.../config/webon the read path and merge theresult in. A missing config resource is tolerated and the block is omitted.
appSettingsis a top-level write-only field, not nested insiteConfig.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.WebAppandFunctionAppare the sameARM type, so
WebApp.Listdrops sites whose kind carriesfunctionappandFunctionApp.Listkeeps only those. Without the split every site would bereported twice, once per formae type. Both sides are tested over one mixed
listing.
A non-functionapp
kindon 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
hasProviderDefaultis annotated but documented as not working. Azurefills in
sku.tier,sku.capacityand everysiteConfigfield whether or notthe 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'sNamespaceSKUalready documents.Secret-bearing fields (
pfxBlob,password,repositoryToken, and anyappSettingsvalue) arewriteOnly = trueand typed(formae.Value|String)sothey can be wrapped with
formae.value(...).opaque.pfxBlobcarries base-64text 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:
armIDPartsfor every ID parse,canonicalizeEnumon every enum read (ARM handssslStateback lower-cased,sku.nameasfree),normalizeAzureLocationonlocation("East US 2"→eastus2),isDeleteSuccessErrorso a 404 delete is success, and an in-flightLRO 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
-updatechanges only non-createOnlyfields.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 ARMrejects the create otherwise);
static-siteuses the Free SKU ineastus2(Static Web Apps ships in few regions) and is created detached — no
repositoryUrl, so no GitHub PAT and nothing billed.function-appwires storagewith the identity-based connection form
(
AzureWebJobsStorage__accountName+__credential = managedidentity) ratherthan 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-domainandcdn-secretalready carry in.github/conformance-matrix.txt, and both fixtures say so in a header comment:custom-hostname-binding— Azure verifies domain ownership before itaccepts 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 DNSzone. 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 theplaceholder cannot accidentally bind a real domain.
app-service-certificate— Azure validates the certificate against thehostnames 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.
pfxBlobis a syntactically valid but meaninglessbase-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 worthstating plainly: the
siteConfiground-trip has not been exercised againstlive ARM. Every field Azure fills in is spelled out in the fixtures
(
netFrameworkVersion = "v4.0"even on Linux,functionAppScaleLimit = 0on adedicated plan) based on what ARM documents and
az webapp config showreturns,but one live run may still turn up a field that needs adding or dropping from the
fixture.
healthCheckPathis deliberately left out of the fixtures: Azure returnsit empty when unset and the plugin skips empty values, so an omitted path is
drift-free.
Verified
All 14 new fixtures were additionally evaluated with
pkl evalagainst the localschema project (in a scratch project so no
PklProjectwas added totestdata/)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'sexpirationDate/issueDate— read-only timefields whose formatting is a drift risk with no upside.
SwapSlot) — an imperative operation, not declarative state.own ARM child types, out of scope for this batch.
AZURE::Web::WebAppSlotdoes not modelidentity,publicNetworkAccessorvirtualNetworkSubnetId; the read path explicitly does not emit them, so statecan never hold a property no forma can express.
Follow-up for the orchestrator
.github/conformance-matrix.txt: addapp-service-plan,web-app,function-app,web-app-slot,static-site; leavecustom-hostname-bindingandapp-service-certificateout with the reasonsabove.