Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2420e84
test: align private hook fixtures with ACR pool guard
placerda Oct 9, 2026
91da94f
test: align private hook fixtures with ACR pool guard
placerda Oct 9, 2026
d1bfb5f
fix: align custom application hooks with definition contracts
placerda Oct 9, 2026
2e0eed6
Merge validated private-hook fixture prerequisite
placerda Oct 9, 2026
3997408
docs: record remaining Azure acceptance blockers
placerda Oct 9, 2026
aeec7ea
fix: respect Foundry local auth and resolve panel database settings
placerda Oct 9, 2026
8854895
fix: validate incremental model quota for existing deployments
placerda Oct 9, 2026
03393f2
docs: record private pinned image build recovery
placerda Oct 9, 2026
4b2a58c
docs: record private hosted greeting and OAuth readiness blockers
placerda Oct 9, 2026
87ce0cd
docs: record healthy private application and enforced OAuth boundary
placerda Oct 9, 2026
aae0a3c
docs: record private hosted authorization rejection and acceptance bl…
placerda Oct 9, 2026
5141cf8
docs: record sample private endpoint recovery evidence
placerda Oct 9, 2026
29c52eb
docs: record completed sample foundations and private runner probes
placerda Oct 9, 2026
d1029d1
docs: record full private configuration and recovered sample builds
placerda Oct 9, 2026
5f3613b
docs: record actual private sample runtime responses
placerda Oct 9, 2026
13832c8
fix: publish provisioned component identity client IDs
placerda Oct 9, 2026
a9214de
fix: deploy custom services with immutable private build results
placerda Oct 9, 2026
c2a6866
test: record real private custom build and runtime evidence
placerda Oct 9, 2026
0647cf1
fix: reconcile custom hosted profiles and foundation upgrade metadata
placerda Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions config/aifoundry/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,30 @@ def add_ai_foundry_account_api_key_to_key_vault(
account_name: str,
vault_uri: str,
secret_name: str
) -> None:
"""Fetches the AI Foundry Account API key and stores it in Azure Key Vault."""
) -> bool:
"""Store an evaluation API key only when the account allows local authentication."""
try:
account = mgmt_client.accounts.get(resource_group, account_name)
if account.properties.disable_local_auth is True:
logging.info(
"Local authentication is disabled for account %s; "
"evaluation API-key injection is not applicable. Use Microsoft Entra ID.",
account_name,
)
return False
logging.info("🔑 Fetching AI Foundry Account API key for account %s ...", account_name)
keys = mgmt_client.accounts.list_keys(resource_group, account_name)
api_key = keys.key1
if not api_key:
raise ValueError("The Foundry account returned an empty evaluation API key.")
logging.info("🔒 Storing API key in Key Vault at %s ...", vault_uri)
kv_client = KeyVaultClient(vault_uri)
kv_client.set_secret(secret_name, api_key)
logging.info("✅ Secret %s set successfully in Key Vault.", secret_name)
return True
except Exception as e:
logging.error("❗️ Failed to set secret in Key Vault: %s", e)
sys.exit(1)

# ── Blocklist Logic ──────────────────────────────────
def configure_blocklist(client: CognitiveServicesManagementClient, resource_group: str, account_name: str, bl_def: Dict[str, Any], bl_name: str) -> None:
Expand Down Expand Up @@ -316,14 +328,16 @@ def main() -> None:
# Store API key in Key Vault
logging.info("🔑 Adding AI Foundry Account API Key to Key Vault …")
key_vault_uri = cfg(app_conf, "KEY_VAULT_URI")
add_ai_foundry_account_api_key_to_key_vault(
secret_stored = add_ai_foundry_account_api_key_to_key_vault(
client,
resource_group,
account_name,
key_vault_uri,
SECRET_NAME
)
logging.info("✅ RAI blocklist, policy, deployment association, and secret injection complete.")
logging.info("✅ RAI blocklist, policy, and deployment association complete.")
if secret_stored:
logging.info("✅ Evaluation API-key injection complete.")

if __name__ == "__main__":
main()
103 changes: 90 additions & 13 deletions config/appdefinition/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

Runs in ``postProvision`` through ``python -m config.appdefinition
--assign-roles``. For every ``containerapp`` component of the selected
definition (``azure.ai.agent`` identities are owned by
:mod:`config.deployment.hosted_access`), it expands the component profiles with
definition, it expands the component profiles with
:func:`~config.appdefinition.profiles.expand_profiles`, resolves the component
Container App identity by its ``azd-service-name`` tag, resolves each scope to
the single matching resource in ``AZURE_RESOURCE_GROUP``, and creates only the
assignments that do not already exist (directly or inherited). Re-runs converge.
Custom hosted components use the same profile reconciliation after deployment,
with an instance identity verified by :mod:`config.deployment.hosted_access`.
The bundled hosted orchestrator retains its feature-owned access bootstrap.

Every Azure call goes through ``az`` (:func:`config.deployment.appconfig._run_az`)
so unit tests replace one boundary.
Expand Down Expand Up @@ -137,8 +139,7 @@ def resolve_scope(scope: str, environment: Mapping[str, str], resource_group: st
return str(items[0]["id"])


def resolve_principal(service: str, resource_group: str, run_az: RunAz) -> str:
"""Return the single managed identity principal of the service's Container App."""
def _resolve_identity(service: str, resource_group: str, run_az: RunAz) -> tuple[str, str, str]:
apps = _json(run_az, ["containerapp", "list", "--resource-group", resource_group, "--query",
f"[?tags.\"azd-service-name\"=='{service}'].{{name:name,identity:identity}}"])
apps = [app for app in apps or [] if isinstance(app, dict)]
Expand All @@ -148,18 +149,60 @@ def resolve_principal(service: str, resource_group: str, run_az: RunAz) -> str:
f"{resource_group}; found {len(apps)}. Run azd provision for this application first."
)
identity = apps[0].get("identity") or {}
principals = [str(identity["principalId"])] if identity.get("principalId") else []
principals += [
str(value["principalId"])
for value in (identity.get("userAssignedIdentities") or {}).values()
identities = [(str(identity["principalId"]), "", "")] if identity.get("principalId") else []
identities += [
(str(value["principalId"]), str(value.get("clientId") or ""), str(resource_id))
for resource_id, value in (identity.get("userAssignedIdentities") or {}).items()
if isinstance(value, dict) and value.get("principalId")
]
if len(principals) != 1:
if len(identities) != 1:
raise RoleAssignmentError(
f"Container App {apps[0].get('name')} must have exactly one managed identity to receive "
f"profile roles; found {len(principals)}."
f"profile roles; found {len(identities)}."
)
return principals[0]
return identities[0]


def resolve_principal(service: str, resource_group: str, run_az: RunAz) -> str:
"""Return the single managed identity principal of the service's Container App."""
return _resolve_identity(service, resource_group, run_az)[0]


def discover_component_identities(
definition: AppDefinition,
environment: Mapping[str, str],
*,
hosted_orchestration: bool,
run_az: RunAz | None = None,
) -> dict[str, str]:
"""Read Container App client IDs for platform outputs, without role writes."""
components = [
component
for component in effective_components(definition, hosted_orchestration=hosted_orchestration)
if component.get("kind") == KIND_CONTAINER_APP
]
if not components:
return {}
resource_group = (environment.get("AZURE_RESOURCE_GROUP") or "").strip()
if not resource_group:
raise RoleAssignmentError("AZURE_RESOURCE_GROUP is required to discover component identities.")
if run_az is None:
from config.deployment.appconfig import _run_az as run_az
identities: dict[str, str] = {}
for component in components:
service = component_service_name(definition, component)
principal, client, resource_id = _resolve_identity(service, resource_group, run_az)
if not client:
arguments = (
["identity", "show", "--ids", resource_id, "--query", "clientId"]
if resource_id else
["ad", "sp", "show", "--id", principal, "--query", "appId"]
)
client = _json(run_az, arguments)
if not isinstance(client, str) or not client.strip():
raise RoleAssignmentError(f"Cannot resolve the managed identity client ID for service {service}.")
identities[str(component["name"])] = client.strip()
return identities


def _ensure_rbac(principal: str, role_guid: str, scope_id: str, run_az: RunAz) -> bool:
Expand Down Expand Up @@ -199,6 +242,41 @@ def assign_roles(
) -> list[PlannedAssignment]:
"""Create missing profile role assignments; return the ones created."""
planned = plan_assignments(definition, hosted_orchestration=hosted_orchestration)
return _assign_planned(planned, environment, run_az=run_az, role_guids=role_guids)


def assign_hosted_roles(
component: Mapping[str, Any],
principal: str,
environment: Mapping[str, str],
*,
run_az: RunAz | None = None,
role_guids: Mapping[str, str] | None = None,
) -> list[PlannedAssignment]:
"""Apply the selected component profiles to its verified hosted identity."""
from uuid import UUID

if UUID(principal).int == 0:
raise RoleAssignmentError("Hosted runtime principal must be a nonzero GUID.")
name = str(component["name"])
planned = [
PlannedAssignment(name, name, assignment)
for assignment in expand_profiles(component.get("profiles") or ())
]
return _assign_planned(
planned, environment, run_az=run_az, role_guids=role_guids,
principals={name: principal},
)


def _assign_planned(
planned: list[PlannedAssignment],
environment: Mapping[str, str],
*,
run_az: RunAz | None,
role_guids: Mapping[str, str] | None,
principals: Mapping[str, str] | None = None,
) -> list[PlannedAssignment]:
if not planned:
return []
resource_group = (environment.get("AZURE_RESOURCE_GROUP") or "").strip()
Expand All @@ -212,7 +290,7 @@ def assign_roles(
raise RoleAssignmentError(f"Role {', '.join(unknown)} is not defined in {ROLES_RELATIVE_PATH.as_posix()}.")
# Resolve every identity and scope before the first write so an ambiguous
# or missing resource fails with no partial assignment.
principals = {
principals = principals if principals is not None else {
service: resolve_principal(service, resource_group, run_az)
for service in dict.fromkeys(item.service for item in planned)
}
Expand All @@ -232,4 +310,3 @@ def assign_roles(
if changed:
created.append(item)
return created

183 changes: 183 additions & 0 deletions config/deployment/custom_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Deploy one custom service with an immutable image and private ACR builds."""

from __future__ import annotations

import argparse
import copy
import logging
import os
from pathlib import Path
import re
import subprocess
from typing import Any, Mapping
from uuid import uuid4

import yaml

from config.appdefinition import AppDefinitionError, load_definition, validate_definition
from config.appdefinition.loader import KIND_HOSTED_AGENT
from config.appdefinition.roles import assign_hosted_roles
from config.deployment.composition import is_truthy
from config.deployment.hosted_image import (
build_source_image,
validate_acr_agent_pool,
validate_digest,
)
from config.deployment.private_network import check_stage
from config.deployment.hosted_access import discover_instance
from util.azure_cli import resolve_az_command

LOGGER = logging.getLogger("config.deployment.custom_application")


def _expand(value: str, environment: Mapping[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
name = match[1]
result = (environment.get(name) or "").strip()
if not result:
raise ValueError(f"Image requires environment variable {name}.")
return result

return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)


def prepare_image(
component: Mapping[str, Any],
service: Mapping[str, Any],
folder: Path,
environment: Mapping[str, str],
) -> str | None:
"""Keep normal azd builds, except pins and isolated Docker source builds."""
source = component["source"]
pin = source.get("imageDigest")
isolated = is_truthy(environment.get("NETWORK_ISOLATION"))
if pin:
digest = validate_digest(pin, name="source.imageDigest")
image = _expand(str(service.get("image") or ""), environment)
if not re.fullmatch(
r"[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+(?::[A-Za-z0-9_.-]+|@sha256:[0-9a-f]{64})?",
image,
):
raise ValueError("source.imageDigest requires a qualified service image in azure.yaml.")
repository = image.split("@", 1)[0]
last_slash = repository.rfind("/")
if ":" in repository[last_slash:]:
repository = repository[:repository.rfind(":")]
return f"{repository}@{digest}"
source_dir = (folder / component["path"]).resolve()
actual = subprocess.run(
["git", "-C", str(source_dir), "rev-parse", "HEAD"],
check=True, capture_output=True, text=True,
).stdout.strip()
if actual.lower() != source["commit"].lower():
raise ValueError("Custom source HEAD does not match source.commit.")
if not isolated:
return None
if service.get("language") != "docker":
raise ValueError(
"Isolated custom source builds require language: docker or source.imageDigest."
)
docker = service.get("docker") or {}
unsupported = set(docker) - {"remoteBuild", "path", "context"}
if unsupported:
raise ValueError(
"Isolated custom builds cannot preserve these docker options: "
+ ", ".join(sorted(unsupported))
+ ". Supply a prebuilt source.imageDigest instead."
)
project_dir = (folder / str(service.get("project") or ".")).resolve()
if source_dir != project_dir:
raise ValueError("The service project must match the pinned component path.")
if docker.get("path", "Dockerfile") != "Dockerfile" or docker.get("context", ".") != ".":
raise ValueError(
"Isolated custom builds require Dockerfile and context '.' in the component path; "
"supply a prebuilt source.imageDigest for another layout."
)
registry_endpoint = (
environment.get("AZURE_CONTAINER_REGISTRY_ENDPOINT")
or environment.get("CONTAINER_REGISTRY_LOGIN_SERVER") or ""
).strip()
pool = (environment.get("ACR_TASK_AGENT_POOL") or "").strip()
group = (environment.get("AZURE_RESOURCE_GROUP") or "").strip()
if not registry_endpoint or not pool or not group:
raise ValueError(
"Isolated custom builds require the registry endpoint, "
"ACR_TASK_AGENT_POOL and AZURE_RESOURCE_GROUP."
)
registry = registry_endpoint.split(".", 1)[0]
azure_cli = resolve_az_command()
check_stage(environment, "hosted-build")
validate_acr_agent_pool(
registry=registry, resource_group=group, agent_pool=pool, azure_cli=azure_cli,
)
image_name = f"agent-landing-zone/custom/{component['name']}"
digest = build_source_image(
registry=registry, source_dir=source_dir, image_name=image_name,
image_tag=f"{actual[:12]}-{uuid4().hex[:12]}",
agent_pool=pool, azure_cli=azure_cli,
)
return f"{registry_endpoint}/{image_name}@{digest}"


def deploy_service(definition_path: str, name: str, environment: Mapping[str, str]) -> str | None:
definition = validate_definition(load_definition(definition_path))
if definition.bundled:
raise ValueError("Custom deployment cannot deploy a bundled component.")
components = [item for item in definition.document["components"] if item["name"] == name]
if len(components) != 1:
raise ValueError(f"No unique custom component named {name}.")
env_name = (environment.get("AZURE_ENV_NAME") or "").strip()
if not env_name:
raise ValueError("AZURE_ENV_NAME is required for custom deployment.")
path = definition.folder / "azure.yaml"
original = path.read_bytes()
project = yaml.safe_load(original.decode("utf-8-sig"))
service = project["services"][name]
agent_name = service.get("name")
if components[0]["kind"] == KIND_HOSTED_AGENT and (
not isinstance(agent_name, str) or not agent_name.strip()
):
raise ValueError("Custom hosted service requires an explicit agent name.")
image = prepare_image(components[0], service, definition.folder, environment)
try:
if image:
rendered = copy.deepcopy(project)
rendered_service = rendered["services"][name]
rendered_service["image"] = image
rendered_service["docker"] = {"imagePassthrough": True}
path.write_text(yaml.safe_dump(rendered, sort_keys=False), encoding="utf-8")
LOGGER.info("Deploying custom service %s with immutable image %s", name, image)
subprocess.run(
["azd", "deploy", name, "--environment", env_name, "--no-prompt"],
cwd=definition.folder, env={**os.environ, **environment}, check=True,
)
finally:
if image:
path.write_bytes(original)
if components[0]["kind"] == KIND_HOSTED_AGENT:
instance = discover_instance(environment, name=agent_name)
assign_hosted_roles(components[0], instance.principal_id, environment)
LOGGER.info(
"Verified capability-profile assignments for hosted service %s version %s.",
name, instance.agent_version,
)
return image


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--definition", required=True)
parser.add_argument("--service", required=True)
parser.add_argument("--environment", required=True)
args = parser.parse_args(argv)
try:
deploy_service(args.definition, args.service, {**os.environ, "AZURE_ENV_NAME": args.environment})
except (AppDefinitionError, ValueError, RuntimeError, OSError, subprocess.CalledProcessError,
yaml.YAMLError) as error:
LOGGER.error("Custom service deployment failed: %s", error)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading