Cloudflare CCF 3.1.1: add per-instance storage deployment mode for multi-instance CCF connectors - #14792
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a35de7b3-6bf5-4412-b87e-48b6fbce713d
The per-instance storage nested deployment generated by the V3 packaging
tool omitted expressionEvaluationOptions, so ARM defaulted to OUTER scope.
Under outer scope, resourceId() inside the nested template is evaluated
against the PARENT deployment's subscription/resource group rather than the
storage account's. Every intra-template dependsOn therefore resolved to an
identifier that does not exist in the nested deployment and ARM failed with:
Deployment template validation failed: 'The resource
'Microsoft.Storage/storageAccounts/<acct>/queueServices/default/queues/
cf-<hash>-notification' is not defined in the template.'
This only reproduces when the storage account lives in a different resource
group or subscription from the Sentinel workspace - i.e. the multi-instance
scenario - which is why it passed packaging validation but failed on deploy.
Changes to Tools/Create-Azure-Sentinel-Solution/common/perInstanceStorageDeploymentTemplate.ps1:
- Set expressionEvaluationOptions.scope = "inner" on the nested deployment.
- Make the inner template self-contained: 14 declared parameters plus a
matching properties.parameters value-passing block; all variables('x')
and outer parameters('x') references rewritten to inner parameters('x').
- Add outer variable createEventGridSystemTopic so arm-ttk does not type
compare the bool against the securestring EGSystemTopicName.
- Declare inner principalId as securestring to match the parent type.
- Replace the deprecated
Microsoft.Storage/storageAccounts/queueServices/queues/providers/roleAssignments
form with Microsoft.Authorization/roleAssignments + scope and a bare GUID name.
- Refresh apiVersions: queues 2021-04-01 -> 2025-06-01, EventGrid
systemTopics 2022-06-15 -> 2025-02-15, eventSubscriptions
2023-12-15-preview -> 2025-02-15.
- eventSubscription dependsOn now uses resourceId() instead of format().
Adds a regression guard to Solutions/Cloudflare CCF/Tests/validate_cloudflare_package.py
that asserts the nested deployment is inner-scoped and self-contained
(declared params == passed params, no variables() leakage, no undeclared
parameters). Verified to fail when the fix is reverted.
Validation: arm-ttk 48 passed / 0 failed (baseline had 1 pre-existing
failure); build-and-validate.ps1 BUILD SUCCESSFUL, 12 passed / 0 failed.
Repackaged as Cloudflare CCF 3.1.1.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c1fae0f8-8534-46d8-8630-384001b1dda7
|
🚧 DO NOT MERGE — WIP. Converting to draft while we finish corrections. Will mark ready for review once validated. |
CloudflareAccountId and CloudflareAccountName were declared on the DCR input
stream and listed in the transform's project operator, but nothing ever assigned
them. Cloudflare blob payloads do not carry either field, and addOnAttributes is
stored on the connector ARM resource only - it is never injected into the DCR or
the destination table (PIF-2026-0042). Both columns therefore ingested as null,
which is exactly what Sean Stark observed in his lab on 10 June.
The connector grid was unaffected because it reads addOnAttributes off the ARM
resource directly.
Because each connection owns its own DCR, the values are now baked into that
DCR's transform at packaging time:
- CloudflareLog_DCR.json: drop both columns from streamDeclarations (they are
synthesized, not ingested) and seed them as empty placeholders in the
transform so the authoritative source stays valid standalone.
- CloudflareLog_PollerConfig.json: declare connectionDeployment.attributionColumns
mapping each column to the connection parameter that supplies it.
- perInstanceStorageDeploymentTemplate.ps1: rewrite each placeholder into an ARM
concat() that injects the parameter value. Single quotes and backslashes are
stripped from the value because they are the only characters that can escape
or terminate a KQL string literal. Packaging now fails if a mapped parameter
or table column is missing, or if a placeholder is absent or duplicated.
The regression suite only asserted that the columns were declared and projected,
so it passed while both were null. It now asserts they are absent from the input
stream, present as placeholders in the source transform, and injected with
scrubbing in the packaged template.
Blob lifecycle management remains deliberately deferred: managementPolicies is a
single per-storage-account resource, so a per-connection rule would overwrite any
existing policy on a shared storage account.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 12bd8fa3-5c6b-4c24-b27f-2e4c3273bbc4
The nested storage deployment sets subscriptionId + resourceGroup to the storage account's scope, so resourceGroup().location resolves to the storage RG (where the storage account lives). The prior reference(storageAccountId, ..., 'Full').location call was rejected by ARM validation with 'template function reference is not expected at this location' inside a scope='inner' cross-subscription nested deployment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05a71fba-60ed-42e9-8e00-d987b81955e6
Two deployment-blocking defects found during end-to-end validation in a
live subscription (storage in eastus, workspace in westus2 — exercising
the cross-subscription/cross-resource-group path this solution targets).
1. Event Grid system topic location
The storage-side nested deployment is inner-scoped
(expressionEvaluationOptions.scope = inner) and targets the storage
account's subscription and resource group. It set the system topic's
location via reference(storageAccountId, ..., 'Full').location, which
ARM rejects during validation:
'The template function reference is not expected at this location.'
Because the nested deployment already runs in the storage account's
resource group, resourceGroup().location yields the same region
without a runtime reference. (Committed earlier as 28e70cd.)
2. Empty location parameter from the portal UI
The 'location' parameter is documented as unused — it exists solely to
satisfy the arm-ttk Location-Should-Not-Be-Hardcoded check — yet it
declared minLength: 1. When createUiDefinition's getLAWorkspace filter
did not resolve, the portal submitted an empty string and ARM failed:
'The provided value for the template parameter location is not
valid. Length of the value should be greater than or equal to 1.'
minLength is removed, since arm-ttk only inspects defaultValue.
The load-bearing 'workspace-location' parameter had the same exposure:
it defaults to an empty string and was consumed directly by 57
resource location fields, so an empty value would have produced
resources with no region. Those now resolve through a new
workspaceLocationEffective variable that falls back to
resourceGroup().location when the parameter is empty. The
createUiDefinition location output also falls back to the selected
workspace's region.
Validation:
- az deployment group validate with location='' and
workspace-location='' (the exact failing input) now succeeds.
- az deployment group create with normal parameters succeeds.
- Full end-to-end ingestion confirmed: 250 Cloudflare Logpush events
flowed blob -> Event Grid -> Storage Queue -> DCE/DCR ->
CloudflareV2_CL, with CloudflareAccountId/CloudflareAccountName
attribution populated, zero dead-letter messages and no DCRLogErrors.
- All 10 checks in Tests/validate_cloudflare_package.py pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 05a71fba-60ed-42e9-8e00-d987b81955e6
Validation update — v3.1.3Deployed and validated end-to-end in a live subscription, deliberately exercising the cross-subscription / cross-resource-group path this change targets (storage account in Two deployment-blocking defects found and fixed1. Event Grid system topic location Since the nested deployment already runs in the storage account's resource group, 2. Empty
The load-bearing Evidence
Pipeline proven: Still marked draft / do not merge pending review. |
|
@MitchellGulledge3 please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds an opt-in per-connector-instance (“PerInstance”) storage-side deployment generator for CCF StorageAccountBlobContainer pollers to fix cross-scope nested deployment dependency resolution, and adopts it in the Cloudflare CCF solution.
Changes:
- Introduces a new PowerShell generator for per-instance storage/Event Grid/RBAC topology with inner-scoped nested deployments.
- Updates
createCCPConnector.ps1to dispatch to the new generator whenconnectionDeployment.mode == "PerInstance"and skips generating the shared DCR in that mode. - Updates the Cloudflare CCF solution artifacts (poller config, table/DCR, UI definition, release notes) and adds targeted Python package validation.
Reviewed changes
Copilot reviewed 11 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| Tools/Create-Azure-Sentinel-Solution/common/perInstanceStorageDeploymentTemplate.ps1 | New per-instance storage deployment generator that emits inner-scoped nested deployments and deterministic per-instance resources. |
| Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1 | Adds PerInstance dispatch, fixes ContextPane parameter traversal, and adjusts parameter merge / shared DCR behavior. |
| Solutions/Cloudflare CCF/Tests/validate_cloudflare_package.py | Adds targeted regression/structure checks for the generated package (inner-scope nested deployment, deterministic naming, attribution injection). |
| Solutions/Cloudflare CCF/ReleaseNotes.md | Bumps solution version and documents fixes/behavior changes. |
| Solutions/Cloudflare CCF/Package/testParameters.json | Removes unused parameters from test parameters. |
| Solutions/Cloudflare CCF/Package/createUiDefinition.json | Updates solution description and changes the location output expression. |
| Solutions/Cloudflare CCF/Data/Solution_Cloudflare.json | Updates solution description and version. |
| Solutions/Cloudflare CCF/Data Connectors/CloudflareLog_CCF/CloudflareLog_Table.json | Adds Cloudflare account attribution columns to the table schema. |
| Solutions/Cloudflare CCF/Data Connectors/CloudflareLog_CCF/CloudflareLog_PollerConfig.json | Opts into PerInstance mode and switches to deterministic connector naming / per-instance DCR wiring. |
| Solutions/Cloudflare CCF/Data Connectors/CloudflareLog_CCF/CloudflareLog_DCR.json | Adds attribution placeholders to the authoritative transform. |
| Solutions/Cloudflare CCF/Data Connectors/CloudflareLog_CCF/CloudflareLog_ConnectorDefinition.json | Updates connector UX text/instructions to align with multi-instance/per-instance behavior. |
| type = "Microsoft.Resources/deploymentScripts" | ||
| apiVersion = "2023-08-01" | ||
| name = "[[variables('rbacPropagationScriptName')]" | ||
| location = "[parameters('workspace-location')]" | ||
| kind = "AzurePowerShell" | ||
| dependsOn = @( | ||
| "[[variables('storageNestedDeploymentId')]" | ||
| ) | ||
| properties = [ordered]@{ | ||
| azPowerShellVersion = "11.5" | ||
| forceUpdateTag = "[[parameters('forceUpdateTag')]" | ||
| retentionInterval = "PT1H" | ||
| cleanupPreference = "Always" | ||
| timeout = "PT5M" | ||
| scriptContent = $scriptContent | ||
| } | ||
| } |
| "outputs": { | ||
| "workspace-location": "[first(map(filter(basics('getLAWorkspace').value, (filter) => and(contains(toLower(filter.id), toLower(resourceGroup().name)),equals(filter.name,basics('workspace')))), (item) => item.location))]", | ||
| "location": "[location()]", | ||
| "location": "[if(empty(location()), first(map(filter(basics('getLAWorkspace').value, (filter) => and(contains(toLower(filter.id), toLower(resourceGroup().name)),equals(filter.name,basics('workspace')))), (item) => item.location)), location())]", |
TL;DR
A CCF connector that uses a
StorageAccountBlobContainerpoller generates an ARMnested deployment to create its storage-side plumbing (queues, Event Grid
subscription, role assignments). That nested deployment is generated with ARM's
default "outer" expression scope, and its internal
dependsOnIDs are builtwith the 3-argument
resourceId(resourceGroupName, type, …)overload.That overload overrides only the resource group and silently inherits the
subscription of the deployment currently running. So when a customer points the
connector at a storage account in a different subscription, the
dependsOnIDsname a resource that does not exist in the template, and ARM rejects the whole
deployment before creating anything:
This PR adds a new per-connector-instance storage generator that does not have
this defect (inner expression scope + explicitly-scoped resource IDs), and adopts
it in the Cloudflare CCF solution. I have reproduced the failure and verified the
fix against real Azure ARM — see Evidence.
Decisions requested from maintainers
connectionDeployment.mode: "PerInstance"an acceptable opt-in contractfor a solution's connector definition, or would you prefer a different
configuration surface?
existing
storageAccountDeploymentTemplate.ps1be fixed in place andparameterised?
this PR, or tracked separately? (See Known related issue.
I have deliberately not touched it here.)
traversal, earlier parameter merge), or should they be split out? They are not
opt-in and do change behaviour for other solutions — see the change matrix.
Glossary (this PR assumes a lot; here is the vocabulary)
Tools/Create-Azure-Sentinel-Solutionthat turns that JSON into ARM.StorageAccountBlobContainerpollerMicrosoft.Resources/deploymentsresource embedded inside a template, used here to deploy into a different resource group/subscription than the parent.expressionEvaluationOptions. Outer (the default) evaluates the nested template's expressions in the parent's context. Inner evaluates them in the nested deployment's own context, so it only sees parameters explicitly passed to it.Customer scenario
An organisation with more than one upstream account at a log source (for example,
several Cloudflare accounts, each exporting to its own storage account) wants each
one ingested independently into one Sentinel workspace.
Today the generator emits one storage-side topology per solution, so a second
account cannot be wired up without colliding with the first. This PR makes the
generated connector deployable once per upstream account, with deterministically
derived per-instance names for the queues, Event Grid resources, DCR/DCE and role
assignments.
While building that, the nested-deployment scope defect described above surfaced,
because per-instance deployments are precisely the case where the storage account
tends to live somewhere other than the Sentinel workspace.
Root cause, minimally
Parent template computes the dependency ID:
The nested deployment then targets a different subscription, but has no
expressionEvaluationOptions, so it defaults to outer scope and itsdependsOnuses that parent-computed (wrong-subscription) ID:
{ "type": "Microsoft.Resources/deployments", "subscriptionId": "[parameters('StorageAccountSubscription')]", "resourceGroup": "[parameters('StorageAccountResourceGroupName')]", "properties": { // no expressionEvaluationOptions -> outer scope "template": { "resources": [ { "dependsOn": [ "[variables('notificationQueueResourceId')]" ] } ]} } }subscriptionId/resourceGroupcontrol where resources are placed. They neverchange expression context. So the queue is created in subscription B while the
dependsOnstring names subscription A. No resource in the template has that ID →InvalidTemplate.Two things are required to fix it, and only doing one is not enough (I verified
this the hard way — see Evidence):
expressionEvaluationOptions: { "scope": "inner" }and pass primitives(names, GUIDs) as parameters, then recompute the resource IDs inside the
nested template so they bind to the nested deployment's own subscription/RG.
Passing the parent's already-computed ID in as a parameter still fails — it is
the same wrong string.
resourceId(subscriptionId, resourceGroup, …)and
subscriptionResourceId(subscriptionId, …)overloads wherever astorage-side ID is built.
Both are implemented in
perInstanceStorageDeploymentTemplate.ps1.Evidence
Verified against live Azure ARM using the actual packaged artifacts from
Solutions/Cloudflare CCF/Package/*.zip— not a hand-written model. The connector'sgenerated
contentTemplate.mainTemplatewas extracted from each package and deployedfor real into a Sentinel workspace's resource group, with the storage account in a
different resource group.
az deployment group createresultInvalidTemplate— nothing created{"scope": "inner"}Exact error from the pre-fix artifact:
This matches the failure signature reported from the field, including the derived
cf-<hash>-notificationqueue-name shape.Important for anyone trying to reproduce this:
az deployment group validatepasses on the broken template. ARM does not expand the nested deployment during
validation, so the defect only surfaces on an actual
create. Any CI or pre-flightcheck based on
validatewill not catch it.With this PR's artifact, the same deployment creates the queues
(
cf-<hash>-notification,cf-<hash>-dlq) and theirStorage Queue Data Contributorrole assignments at correct fully-qualified scopes. The only residual errors in my run
were artifacts of my test harness (I passed a User object ID where the template
correctly requires
principalType: ServicePrincipal, and my harness stubbed theEvent Grid system-topic condition) — neither is a template defect.
Scope of the underlying ARM behaviour
Separately, minimal templates were used to isolate the mechanism. The failure occurs
when the
dependsOnexpression and the nested resource's own implicit resource IDresolve to different scopes. For the existing single-instance generator's pattern
(3-argument
resourceId(resourceGroupName, type, …), which overrides only the resourcegroup and inherits the running deployment's subscription), the mismatch appears only
across subscriptions; different-resource-group-same-subscription resolves
consistently and works.
Also run:
arm-ttkvia the repo's.arm-ttk, the solution package build, and thesolution's Python package tests (10/10), including a structural regression check
asserting the nested deployment is inner-scoped and self-contained.
Change matrix — what is opt-in and what is not
Please read this instead of a blanket "nothing else changes" claim.
perInstanceStorageDeploymentTemplate.ps1(new)connectionDeployment.mode = "PerInstance"createCCPConnector.ps1elsebranch is the untouched existing pathcreateCCPConnector.ps1instructionSteps, not just the firstcreateCCPConnector.ps1createCCPConnector.ps1if (-not …exists)), so existing entries are left intact, but ordering changed.The existing
storageAccountDeploymentTemplate.ps1is not modified by this PR.Known related issue
storageAccountDeploymentTemplate.ps1(the single-instance generator, unchangedhere) exhibits the same two ingredients: its nested deployment has no
expressionEvaluationOptions, and its parent buildsnotificationQueueResourceIdwith the 3-argument
resourceIdoverload. My reproduction template was modelleddirectly on it, and that template fails cross-subscription.
I have not changed it in this PR, to keep the blast radius small and because the
right fix (patch in place vs. converge on the new generator) is a maintainer call —
see Decision 3 above. Happy to send a follow-up PR, or fold it in here if you prefer.
Files changed
Tools/Create-Azure-Sentinel-Solution/common/perInstanceStorageDeploymentTemplate.ps1— new, +454Tools/Create-Azure-Sentinel-Solution/common/createCCPConnector.ps1— +42/−4Solutions/Cloudflare CCF/**— adoptsconnectionDeployment.mode: "PerInstance", regenerated package, version bump, testsThe large line counts are dominated by regenerated package artifacts
(
mainTemplate.json,createUiDefinition.json). The hand-authored change is the twotooling files above plus the Cloudflare connector definition and tests.