Infrastructure¶
This is deployment code, not evidence of a successful live deployment. It
supports the repository's existing bearer-authenticated agent, private
ClusterIP service, loopback port-forward, Workload Identity model calls, and
Path A metadata-only external-agent registration. No APIM, ingress
controller, public application service, additional agent tools, or scheduled
evaluation service is created. The foundation grants the Foundry project
identity Foundry User on the account for model inference and the scoped
Application Insights roles used by one-off trace evaluation.
Ownership and stages¶
All directly managed resources live in one existing, demo-exclusive resource group in one subscription. Do not point these templates at shared resources. Names are explicit; there is no tenant-wide discovery or Microsoft Graph lookup.
| Stage | Resources |
|---|---|
foundation.bicep |
AIServices S0 account and project, pinned runtime and GPT-5 judge deployments, Basic ACR (admin disabled), three distinct user-assigned identities, 30-day PerGB2018 workspace, workspace-backed Application Insights, project's AppInsights connection, scoped RBAC prerequisites |
cluster.bicep |
AKS Free tier, two 4-vCPU system nodes by default, precreated control-plane and kubelet identities, Entra managed authentication/Azure RBAC, disabled local accounts, OIDC/Workload Identity, Azure CNI overlay, pod federation, operator bootstrap RBAC |
AKS creates and owns a second, separately named node resource group. Do not precreate it. The AKS FAQ explicitly excludes an existing resource group as the node resource group. The managed identity documentation documents AKS's control-plane Contributor permission on its node resource group. We let AKS establish that permission. We do not grant Contributor/Network Contributor on the demo RG or subscription, nor give the kubelet network rights. This design has no bring-your-own VNet or other network resources outside the AKS-managed group.
The runtime model keeps one capacity unit for the low-volume request path. The
dedicated gpt-5-mini judge uses ten capacity units because Foundry evaluation
and Insights analyze multiple traces concurrently.
The precreated kubelet prerequisite is implemented before AKS creation: the control-plane UAI receives Managed Identity Operator on the kubelet UAI. This is a distinct supported bootstrap path, not a claim that changing identities cures an Azure provisioning incident.
The API server is public and Entra/RBAC protected, optionally restricted to operator CIDRs. Only the application is private. ACR, model and telemetry endpoints use public Azure endpoints; the AKS standard load balancer/public IP is for outbound access, not an application ingress rule. Private Link, private-cluster networking, Azure Monitor containers add-ons, and associated extra costs are intentionally out of scope.
Prerequisites and preflight¶
- Azure CLI, Bicep 0.45.15, Python 3.11–3.13, the locked project dependencies
(
uv sync --locked --all-extras),kubectlandkubelogin. The infrastructure entrypoint uses only Python's standard library; runtime rendering additionally uses the existing PyYAML development dependency. - Log in to the intended Azure tenant. Provide a subscription ID/name and the
operator's Entra object ID, not an application/client ID. For automation
use
operatorPrincipalType: ServicePrincipal; groups are also supported. - The deploying principal needs resource creation/update and role-assignment permissions at the demo RG, and permission for AKS to create its managed RG (normally appropriate subscription-level bootstrap authority). Reader plus Foundry User alone is not sufficient. No deployment automation identity or subscription-wide role assignment is created by this code.
- Providers must already be registered:
Microsoft.ContainerService,Microsoft.Compute,Microsoft.Network,Microsoft.ManagedIdentity,Microsoft.ContainerRegistry,Microsoft.CognitiveServices,Microsoft.OperationalInsights,Microsoft.Insights. Provider registration and quota requests are explicit operator actions, not script side effects. - Confirm regional Kubernetes availability, VM restrictions including zones, regional total and VM-family vCPU quota, and model/SKU capacity. For example, these are read-only:
powershell
$sub = '<subscription-id-or-name>'
$location = 'eastus2'
az aks get-versions --subscription $sub --location $location --output table
az vm list-skus --subscription $sub --location $location --size Standard_D4s_v6 --all --output json
az vm list-usage --subscription $sub --location $location --output table
az cognitiveservices usage list --subscription $sub --location $location --output table
Defaults are East US 2 in the example parameter files, Kubernetes 1.34.10,
Standard_D4s_v6, and no explicit zones. Select an available compatible SKU
and zone list locally; absence of a restriction does not guarantee capacity.
Keep two system nodes, each at least 4 vCPUs/4 GiB, as required by
system pool guidance.
Reserve at least 8 vCPUs initially and 12 for the one-node upgrade surge.
Account for other consumption in both regional and family quota. If using a
different SKU, retain AMD64 compatibility with the serving image/manifests.
Check CIDRs do not overlap networks used by your environment.
Local parameters and safe adoption¶
Run from the repository root. Keep real identifiers, kubeconfig and operator
state under the already ignored .local/ directory:
New-Item -ItemType Directory -Force .local | Out-Null
Copy-Item infra/foundation.parameters.example.json .local/foundation.parameters.json
Copy-Item infra/cluster.parameters.example.json .local/cluster.parameters.json
$rg = '<dedicated-demo-rg>'
Edit these copies before proceeding:
- Every
REPLACE_WITH_*value must be replaced. The account and registry names must satisfy their Azure naming/global-uniqueness constraints. Set the real existing names when adopting dedicated resources. - Match all three identity names, location, operator object ID/type between
files. The identities must be distinct. The node RG must differ from
$rg.cluster.bicepreferences precreated identities only in$rg. - Match the existing AppInsights connection name, model deployment name,
model/version/SKU/capacity, workspace and component names. Defaults are
gpt-4.1-mini,2025-04-14,GlobalStandard, capacity1. - Account custom subdomain defaults to its account name; endpoints in this demo use Azure public cloud DNS. Do not adopt an account with a different immutable subdomain/location. The template intentionally disables account key authentication; model calls must use Entra Workload Identity.
apiServerAuthorizedIPRangesaccepts e.g.["<operator-egress-ip>/32"]. An empty list permits public API connectivity, not unauthenticated access. Account for a changing operator/VPN egress address before restricting it.- Normal ARM incremental deployment reconciles named resources: it is not
an
existing/read-only adoption mode. Check current properties first; tags and declared properties converge to these templates. Do not use complete deployment mode. Do not run foundation/model reconciliation during a serving test unless an intentional update is acceptable. - Taggable resources inherit the existing demo RG's tags, including lifecycle
tags such as
expiresOn, merged with durable demo labels. Optionaltagsobjects in each private parameter file override individual values. The RG itself is never retagged by either template. Keep temporary dates, budgets and other deployment-specific values in private parameters, not durable examples or documentation.
Optional supplied SSH public key and node SSH disabling¶
To eliminate reliance on AKS-side key generation for a fresh bootstrap, supply
sshPublicKey and optionally linuxAdminUsername (default azureuser) in the
private cluster parameter file. An empty public key omits linuxProfile; it
does not assert that server-side key generation or the SSH daemon is disabled.
This option removes a bootstrap dependency; it is not a confirmed diagnosis
of a Creating operation.
Generate a new demo-scoped RSA key, explicitly outside the repository in a
private session directory. Never use the operator's ~/.ssh keys. For example:
ssh-keygen -t rsa -b 3072 -f '<private-session-directory>/aks-bootstrap-rsa' -C foundry-aks-agent-bootstrap
Let ssh-keygen prompt for a passphrase; do not put it in command arguments.
Copy only the single-line .pub contents into the local sshPublicKey parameter.
Never put the private key in parameters, Bicep, outputs, logs, or the repository.
The scripts never generate keys, read a key path, or inspect ~/.ssh; the
entrypoint rejects private-key/multiline/non-ssh-rsa values. Keep the public
key value unchanged on ordinary reruns, and protect/delete the scoped private
key according to the operator's retention policy.
Node-pool SSH disabling is supported by the selected stable 2025-10-01 API
as agentPoolProfiles[].securityProfile.sshAccess = Disabled, but the current
AKS SSH guidance
still lists DisableSSHPreview registration as a prerequisite. Consequently,
disableNodeSsh defaults to false rather than adding an undeclared
subscription-level dependency.
Prefer disableNodeSsh: true once the operator has confirmed the feature is
available/registered and refreshed the ContainerService provider registration:
az feature show --subscription $sub --namespace Microsoft.ContainerService `
--name DisableSSHPreview --query properties.state --output tsv
Feature/provider registration, if necessary, is an explicit parent/operator
action, never a helper side effect. The deployment helper checks the feature
state before submission when this option is enabled. Supply the public key
even with SSH disabled to eliminate automatic key generation independently
of SSH service configuration. Disabling SSH prevents node SSH troubleshooting;
it does not disable managed Entra authentication, Azure RBAC, kubectl, or
loopback application port-forwarding. No SSH ingress rule, public node IP, VM
login role, or private-key output is added.
Existing role assignments¶
Azure rejects a duplicate role assignment with a new name even if its principal, scope and role match. Before first adoption, inspect the existing assignments at each exact resource scope:
az role assignment list --subscription $sub --scope '<full-resource-id>' `
--fill-principal-name false --fill-role-definition-name false `
--query '[].{name:name,principalId:principalId,roleDefinitionId:roleDefinitionId,scope:scope}' `
--output json
Add the matching assignment's GUID name as a JSON parameter override. Do not pass the full assignment resource ID or a different principal's assignment. Leave the override empty/absent only when no identical assignment exists.
| Parameter | Principal | Exact scope | Role ID |
|---|---|---|---|
modelUserAssignmentName |
Workload UAI | AIServices account | 5e0bd9bd-7b93-4f28-af87-19fc36ad61bd |
acrPullAssignmentName |
Kubelet UAI | ACR | 7f951dda-4ed3-4680-a7ca-43fe172d538d |
identityOperatorAssignmentName |
Control-plane UAI | Kubelet UAI | f1a07417-d97a-45cb-824c-7a7467783830 |
foundryUserAssignmentName |
Operator | Foundry project | 53ca6127-db72-4b80-b1b0-d745d6d5456d |
operatorMonitoringReaderAssignmentName |
Operator | Application Insights component | 43d0d8ad-25c7-4714-9337-8ba259a9fe05 |
operatorPrivilegedMonitoringDataReaderAssignmentName |
Operator | Application Insights component | dbc9c667-e97f-4491-aee6-90b9cf960190 |
projectFoundryUserAssignmentName |
Project system-assigned identity | Foundry account | 53ca6127-db72-4b80-b1b0-d745d6d5456d |
projectInsightsReaderAssignmentName |
Project system-assigned identity | Application Insights component | acdd72a7-3385-48ef-bd42-f606fba81ae7 |
projectMonitoringReaderAssignmentName |
Project system-assigned identity | Application Insights component | 43d0d8ad-25c7-4714-9337-8ba259a9fe05 |
projectLogAnalyticsReaderAssignmentName |
Project system-assigned identity | Application Insights component | 73c42c96-874c-492b-b04d-ab87d138a893 |
projectPrivilegedMonitoringDataReaderAssignmentName |
Project system-assigned identity | Application Insights component | dbc9c667-e97f-4491-aee6-90b9cf960190 |
clusterAdminAssignmentName (cluster file) |
Operator | AKS | b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b |
New assignments use stable, deterministic GUIDs. If an identity is deleted and recreated under the same name, its principal ID changes: remove the stale role assignments deliberately before rerunning, or use new assignment GUID overrides. Never broaden roles to address propagation delays.
Validate, plan, submit, resume¶
The demo RG must already exist. For a new demo only, explicitly create that one group after confirming the name is unused:
az group create --subscription $sub --name $rg --location $location `
--tags workload=foundry-aks-agent purpose=exclusive-demo --output none
The entrypoint never creates/deletes RGs, changes the active subscription, registers providers, or requests quota. Offline compilation requires no login:
python -m infra.deploy validate foundation --subscription $sub --resource-group $rg --parameters .local/foundation.parameters.json
python -m infra.deploy validate cluster --subscription $sub --resource-group $rg --parameters .local/cluster.parameters.json
python -m infra.deploy plan foundation --subscription $sub --resource-group $rg --parameters .local/foundation.parameters.json
python -m infra.deploy deploy foundation --subscription $sub --resource-group $rg --parameters .local/foundation.parameters.json --confirm-resource-group $rg
python -m infra.deploy status foundation --subscription $sub --resource-group $rg --parameters .local/foundation.parameters.json
validate compiles locally and checks required/unknown parameter names; ARM
performs semantic/type/availability checks during planning/deployment.
plan is Azure ARM what-if, not provisioning. It deliberately displays only
resource IDs/change types, not resolved property payloads: full what-if output
can reveal the AppInsights connection. Review declared nonsecret properties
separately in Bicep/current resource views. Do not enable CLI --debug, shell
transcription, or public artifact collection of deployment responses.
Wait for foundation Succeeded, verify the scoped identity assignments,
and allow for Entra/RBAC propagation before submitting the cluster stage:
AKS identity guidance notes role propagation can take up to 60 minutes. The separate stages let the operator allow that propagation window without starting AKS prematurely. No automatic sleep, retry, cancellation, or cluster creation follows foundation completion; submit the cluster stage explicitly when ready.
python -m infra.deploy plan cluster --subscription $sub --resource-group $rg --parameters .local/cluster.parameters.json
python -m infra.deploy deploy cluster --subscription $sub --resource-group $rg --parameters .local/cluster.parameters.json --confirm-resource-group $rg
python -m infra.deploy status cluster --subscription $sub --resource-group $rg --parameters .local/cluster.parameters.json
deploy submits with --no-wait using stable deployment names
foundry-aks-agent-foundation and foundry-aks-agent-cluster, and requires the
explicit RG confirmation. It blocks resubmission while that deployment, or the
named AKS cluster, is active. This is a single-operator workflow, not a
distributed lock. Use the same parameter files on reruns.
A local deadline, CLI disconnect, ARM deployment failure, or
ControlPlaneNotFound does not establish that an AKS operation is terminal.
Inspect both deployment and AKS provisioningState. Do not automatically retry,
delete/recreate, or launch alternative regions while a cluster is
Creating/Updating/InProgress. Preserve operation IDs and inspect detailed
errors securely in Azure. The helper suppresses raw CLI errors because those
can contain connection credentials. On terminal failure, resolve the cause
before resubmitting; a resubmission reconciles existing resources.
Diagnose a public-IP feature gate¶
When an AKS operation remains InProgress and credential retrieval returns
ControlPlaneNotFound, inspect failed events in the managed node RG, not
only the cluster resource or a capped list of mixed read/write activity:
$nodeRg = '<the-nodeResourceGroupName-parameter>'
az monitor activity-log list --subscription $sub --resource-group $nodeRg `
--offset 6h --status Failed --max-events 100 `
--query '[].{time:eventTimestamp,resourceId:resourceId,operation:operationName.value,message:properties.statusMessage}' `
--output json
Review detailed errors privately; do not publish tenant identifiers or
unredacted request payloads. A capped query is not an exhaustive event count.
One relevant Microsoft.Network/publicIPAddresses/write error is:
SubscriptionNotRegisteredForFeature
Microsoft.Network/AllowBringYourOwnPublicIpAddress
Microsoft.Network being Registered does not establish that this separate
feature is registered. The feature name also does not establish that the
template requests customer-owned IP space: an
official Azure CLI test recording
shows the same error for a Standard public IP carrying a FirstPartyUsage
IP tag, without a supplied IP address or prefix.
An inherited policy that appends FirstPartyUsage IP tags can introduce this
feature prerequisite. Inspect successful
Microsoft.Authorization/policies/append/action events and their fields and
policies metadata. An empty RG-scoped policy-assignment listing does not rule
out a management-group assignment. Do not remove or exempt a governance policy
to bypass this prerequisite.
When the exact error is present, obtain authorization for the subscription-level feature change before following Microsoft's documented registration sequence:
az feature register --subscription $sub --namespace Microsoft.Network `
--name AllowBringYourOwnPublicIpAddress --output none
if ($LASTEXITCODE -ne 0) { throw 'Feature registration request failed' }
$featureState = az feature show --subscription $sub --namespace Microsoft.Network `
--name AllowBringYourOwnPublicIpAddress --query properties.state --output tsv
if ($LASTEXITCODE -ne 0 -or $featureState -ne 'Registered') {
throw 'Feature is not Registered. Inspect its state; Pending may require approval.'
}
az provider register --subscription $sub --namespace Microsoft.Network --wait
if ($LASTEXITCODE -ne 0) { throw 'Network provider refresh failed' }
Some registrations require approval; a
Microsoft Q&A answer for this exact feature
describes that requirement for internal subscriptions. Do not treat Pending
as success or create a custom IP prefix to work around the gate. This registration
addresses only the named feature prerequisite. The RG-scoped templates and
helpers do not apply it.
Verify the existing operation after registration. Require successful public-IP writes, actual node resources, cluster readiness, and the request/trace evidence. Registration alone does not establish deployment completion. Do not submit a competing cluster update while reconciliation remains active.
Apply the private runtime¶
After cluster Succeeded, confirm two Ready nodes, OIDC issuer and federation,
then obtain user credentials (never --admin; local accounts are disabled).
The operator also needs permission to read cluster user credentials. The
deploying Contributor normally has that; a separate non-deploying operator
needs an appropriate AKS Cluster User role in addition to data-plane RBAC.
$cluster = '<the-clusterName-parameter>'
$context = 'foundry-aks-agent-demo'
$env:KUBECONFIG = Join-Path (Get-Location) '.local/demo.kubeconfig'
az aks get-credentials --subscription $sub --resource-group $rg --name $cluster `
--file $env:KUBECONFIG --context $context --format exec --overwrite-existing
kubelogin convert-kubeconfig --login azurecli
kubectl --context $context get nodes
Build/push the image from a clean, committed source tree with the existing locked Dockerfile workflow, or reuse a verified image for that source commit. The kubelet has AcrPull; the operator building/pushing needs separate ACR build or push permissions. The infra scripts do not grant those or build/push images.
Use an ACR digest, not a mutable tag, and its corresponding full source commit
SHA. runtime.py verifies the registry host and refuses an all-zero digest.
It cannot prove the image's source provenance; retain build evidence separately.
Choose a strong random bearer token (at least 32 characters) in a password
manager, and reuse it for an ordinary rerun. The runtime helper securely prompts
if AGENT_API_TOKEN is unset; set it without command-line arguments if you also
need it for the client in the same shell:
$token = Read-Host 'Paste the demo bearer token' -AsSecureString
$env:AGENT_API_TOKEN = [System.Net.NetworkCredential]::new('', $token).Password
Remove-Variable token
uv run --locked --all-extras python -m infra.runtime `
--subscription $sub --resource-group $rg `
--foundation-parameters .local/foundation.parameters.json `
--cluster-parameters .local/cluster.parameters.json --context $context `
--image '<registry>.azurecr.io/boring-agent@sha256:<verified-digest>' `
--release-id '<40-character-source-commit-sha>' `
--agent-name boring-aks-agent --agent-id boring-aks-agent-demo
The helper checks the explicit kubeconfig context's server against the selected
AKS FQDN before reading secrets or writing Kubernetes resources. It reads the
UAI client ID and Application Insights connection string through Azure CLI,
captures them in memory, and renders the existing deploy/*.yaml files without
modifying them. It applies the Secret over stdin with server-side apply (no
client-side last-applied annotation), then restarts/waits for the workload so
token/config-only updates also take effect. It prints neither the bearer token
nor connection string and writes neither to local files. This is not a Key Vault
design: Kubernetes stores a normal Secret; cluster admins can read it.
No --force-conflicts is used. If adopting manifests owned by another field
manager, deliberately reconcile ownership/conflicts rather than overriding
blindly. Partial applies are resumable. A rollout timeout requires inspection,
not automatically deleting the namespace. Reapply uses the supplied token;
token rotation is explicit and requires a client update.
In a separate terminal with the same dedicated kubeconfig:
kubectl --context $context --namespace boring-agent port-forward `
--address 127.0.0.1 service/boring-agent 8000:8000
Keep the existing authenticated client and never put the bearer token in
curl --header, command arguments, logs or screenshots. Health probes are
unauthenticated; a successful readiness probe alone does not prove real model
access.
Path A registration and telemetry¶
The project connection uses
Microsoft.CognitiveServices/accounts/projects/connections@2026-05-01,
ApiKey/AppInsights, with both target and credentials.key equal to the
component's connection string and metadata.ApiType=Azure /
metadata.ResourceId=<component-id>. No Bicep output exposes it.
Register using the existing admin extra/entrypoint after the operator's project role has propagated:
$env:FOUNDRY_PROJECT_ENDPOINT = 'https://<account>.services.ai.azure.com/api/projects/<project>'
$env:AGENT_NAME = 'boring-aks-agent'
$env:OTEL_AGENT_ID = 'boring-aks-agent-demo'
uv run --locked --all-extras aks-agent-register
Keep the registration's name/otel_agent_id identical to runtime.py arguments.
The stable ID maps to serving gen_ai.agent.id spans; the release ID changes
with each source/image release. Registration is metadata only, not routing,
hosting, an authenticated endpoint declaration, or proof of invocation. Verify
the project's telemetry.get_application_insights_connection_string() resolves
the same component without printing the value, make a real authenticated
model request, and query the connected Application Insights for the stable
agent ID and expected release. The operator owns these verification steps.
Rerun and cleanup¶
- Rerun the appropriate stage with the same names/overrides only after its operation is terminal. No resources are deleted by incremental deployment. A new node RG name cannot be applied to an existing cluster.
- When bootstrap administration is no longer needed, explicitly remove the
operator's AKS RBAC Cluster Admin assignment and, if no longer required, its
project role. Set
grantOperatorClusterAdmin/grantOperatorFoundryUsertofalsein local parameters to avoid re-adding them. Setting these booleans alone does not revoke existing assignments. - Stop the loopback port-forward; clear
AGENT_API_TOKENfrom your shell withRemove-Item Env:AGENT_API_TOKEN, remove the demo kubeconfig, and remove any locally retained tokens using your credential manager. - For full teardown, inventory the demo RG and the separately named node RG first. After deliberate operator confirmation, delete AKS and wait for AKS to delete its managed group; then delete the demo-exclusive RG. Check deletion status rather than assuming a timeout means completion. Never bulk-delete a shared RG or provider-created group belonging to another cluster.
- Foundry/account and monitoring soft-delete/retention behavior can retain data or reserve names after RG deletion. Handle purge/recovery deliberately if required; this entrypoint does not purge. Keep no production data here.
Offline validation¶
CI installs Bicep 0.45.15 without Azure login and runs compiled-template contracts with the normal locked tests. Locally, with Bicep already installed:
$env:BICEP_TESTS = '1'
uv run --locked --all-extras pytest -q
uv run --locked ruff check .
uv run --locked ruff format --check .
Template tests assert resource-group containment, exact scoped roles, identity
wiring, two-node AKS/auth/network configuration, model pinning, federation
alignment with the existing ServiceAccount, the verified telemetry connection
schema and nonsecret outputs. Operator tests cover adoption guards, active
operation handling, source-preserving runtime rendering, context isolation and
secret redaction. Without BICEP_TESTS=1, compiled-template tests explicitly
skip; the remaining operator tests still run. These checks never provision Azure
resources and do not replace regional preflight or live deployment validation.