diff --git a/.github/PLAN.md b/.github/PLAN.md index cf40da4..e45930c 100644 --- a/.github/PLAN.md +++ b/.github/PLAN.md @@ -287,7 +287,7 @@ Legend: โœ… complete ยท ๐ŸŸก stub/partial ยท โŒ not started ยท โ€“ not applicab | Model deployment | โœ… | โœ… | โœ… | `fundamentals/03-deploy-models.md` โ€” full body (azd + portal paths) | | Prompt Agent creation | โœ… | โ€“ | โœ… | `fundamentals/04-create-prompt-agent.md` โ€” references baseline artifact | | Hosted Agent source (`src/` + `src.original/`) | โ€“ | โœ… | โœ… | main.py, agent.yaml, Dockerfile, instructions/, scripts/ ported + rebranded | -| Hosted Agent deploy lab | โ€“ | โœ… | โœ… | `fundamentals/05-deploy-hosted-agent.md` โ€” full body | +| Hosted Agent deploy lab | โ€“ | โœ… | โœ… | `fundamentals/05-deploy-hosted-agent.md` โ€” portal linker normalizes project name and persists ID + endpoint metadata before deploy | | Bicep infra (`infra/`) | โ€“ | โœ… | โœ… | main.bicep, params, core/{ai,host,monitor,search,storage} ported + rebranded | | Fundamentals verify lab | โ€“ | โ€“ | โœ… | `fundamentals/06-verify.md` โ€” canonical question, both agents | | Core: observability in portal | โœ… | partial | โœ… | `core/01-observe-portal.md` โ€” playground + live evaluators + traces | diff --git a/labs/fundamentals/05-deploy-hosted-agent.md b/labs/fundamentals/05-deploy-hosted-agent.md index 9ab7876..7aca0e0 100644 --- a/labs/fundamentals/05-deploy-hosted-agent.md +++ b/labs/fundamentals/05-deploy-hosted-agent.md @@ -56,7 +56,19 @@ host). You'll enable both below. > ``` > > The script auto-discovers the RG, Foundry account, and project, then binds -> them into an `azd env` named `contoso-travel`. Idempotent โ€” safe to re-run. +> them into an `azd env` named `contoso-travel`. Verify the project metadata +> before provisioning: +> +> ```bash +> azd env get-values | grep -E \ +> "AZURE_AI_PROJECT_(NAME|ID|ENDPOINT)|FOUNDRY_PROJECT_ENDPOINT" +> azd ai project show +> ``` +> +> `AZURE_AI_PROJECT_NAME` must contain only the child name (for example, +> `contoso-travel-project`), not `account/project`. The project ID and both +> endpoint values must also be present, and `azd ai project show` must resolve +> the same endpoint. The linker is idempotent and safe to re-run. > **Skip this if you did Lab 01 (`azd`) already.** 1. **Make sure the hosted-agents extension is installed.** diff --git a/scripts/link-portal-rg.sh b/scripts/link-portal-rg.sh index a0231da..2de7dd3 100755 --- a/scripts/link-portal-rg.sh +++ b/scripts/link-portal-rg.sh @@ -28,7 +28,7 @@ az account show >/dev/null 2>&1 || { echo "โŒ 'az' is not logged in. Run: az login --use-device-code"; exit 1; } # --- discover the RG + Foundry resources ----------------------------------- -LOC=$(az group show -n "$RG" --query location -o tsv 2>/dev/null) || { +RG_LOC=$(az group show -n "$RG" --query location -o tsv 2>/dev/null) || { echo "โŒ Resource group '$RG' not found in the current subscription." echo " Pass a different name: ./scripts/link-portal-rg.sh " exit 1 @@ -44,10 +44,17 @@ if [[ -z "$ACCT" || "$ACCT" == "null" ]]; then exit 1 fi -# Project name โ€” best-effort. Older API versions omit the 'project list' verb; -# in that case the workshop uses the default pattern 'ai-project-'. -PROJ=$(az cognitiveservices account project list \ - -g "$RG" --name "$ACCT" --query "[0].name" -o tsv 2>/dev/null || true) +# Project metadata โ€” best-effort. Azure returns the name as 'account/project', +# while Bicep expects only the child project name. +PROJ_JSON=$(az cognitiveservices account project list \ + -g "$RG" --name "$ACCT" --query "[0]" -o json 2>/dev/null || true) +[[ -n "$PROJ_JSON" ]] || PROJ_JSON='{}' +PROJ_QUALIFIED=$(jq -r '.name // empty' <<<"$PROJ_JSON") +PROJ="${PROJ_QUALIFIED##*/}" +PROJ_ID=$(jq -r '.id // empty' <<<"$PROJ_JSON") +PROJ_ENDPOINT=$(jq -r '.properties.endpoints["AI Foundry API"] // empty' <<<"$PROJ_JSON") +PROJ_LOC=$(jq -r '.location // empty' <<<"$PROJ_JSON") +LOC="${PROJ_LOC:-$RG_LOC}" # --- bind the azd env ------------------------------------------------------ if azd env list -o json 2>/dev/null | jq -e ".[] | select(.Name==\"$ENV\")" >/dev/null; then @@ -61,6 +68,14 @@ azd env set AZURE_RESOURCE_GROUP "$RG" azd env set AZURE_LOCATION "$LOC" azd env set AZURE_AI_ACCOUNT_NAME "$ACCT" [[ -n "$PROJ" && "$PROJ" != "null" ]] && azd env set AZURE_AI_PROJECT_NAME "$PROJ" +if [[ -n "$PROJ_ID" && "$PROJ_ID" != "null" ]]; then + azd env set AZURE_AI_PROJECT_ID "$PROJ_ID" + azd env set AZURE_AI_FOUNDRY_PROJECT_ID "$PROJ_ID" +fi +if [[ -n "$PROJ_ENDPOINT" && "$PROJ_ENDPOINT" != "null" ]]; then + azd env set AZURE_AI_PROJECT_ENDPOINT "$PROJ_ENDPOINT" + azd env set FOUNDRY_PROJECT_ENDPOINT "$PROJ_ENDPOINT" +fi azd env set USE_EXISTING_AI_PROJECT true azd env set AI_PROJECT_DEPLOYMENTS "[]" # sidestep the JSON-escape gotcha in Lab 05 @@ -71,7 +86,7 @@ echo " Foundry account: $ACCT" [[ -n "$PROJ" && "$PROJ" != "null" ]] && echo " Foundry project: $PROJ" echo "" echo "Values now set (subscription id truncated for readability):" -azd env get-values | grep -E "^(AZURE_(SUBSCRIPTION_ID|RESOURCE_GROUP|LOCATION|AI_ACCOUNT_NAME|AI_PROJECT_NAME))|^USE_EXISTING_AI_PROJECT|^AI_PROJECT_DEPLOYMENTS" \ +azd env get-values | grep -E "^(AZURE_(SUBSCRIPTION_ID|RESOURCE_GROUP|LOCATION|AI_ACCOUNT_NAME|AI_PROJECT_NAME|AI_PROJECT_ID|AI_FOUNDRY_PROJECT_ID|AI_PROJECT_ENDPOINT)|FOUNDRY_PROJECT_ENDPOINT)|^USE_EXISTING_AI_PROJECT|^AI_PROJECT_DEPLOYMENTS" \ | sed -E 's|(AZURE_SUBSCRIPTION_ID=")([^"]{4})[^"]*(")|\1\2โ€ฆ\3|' echo "" echo "Next: run Lab 05 Step 2 (azd provision) โ€” it will reuse '$RG' instead of" diff --git a/specs/course.yaml b/specs/course.yaml index 099c6b6..6406a39 100644 --- a/specs/course.yaml +++ b/specs/course.yaml @@ -60,7 +60,9 @@ phases: loop_node: deploy time_min: 15 prereqs: ["fundamentals/03-deploy-models"] - produces: [{type: config, name: hosted-agent-endpoint}] + produces: + - {type: config, name: portal-azd-project-link} + - {type: config, name: hosted-agent-endpoint} consumes: [] - id: "06-verify" title: Smoke-test both agents diff --git a/tests/test_link_portal_rg.py b/tests/test_link_portal_rg.py new file mode 100644 index 0000000..3ba664c --- /dev/null +++ b/tests/test_link_portal_rg.py @@ -0,0 +1,91 @@ +"""Portal-first linker writes canonical Foundry project metadata.""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from conftest import REPO_ROOT + + +LINKER = REPO_ROOT / "scripts" / "link-portal-rg.sh" + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content) + path.chmod(0o755) + + +def test_linker_normalizes_project_name_and_sets_project_metadata(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + azd_log = tmp_path / "azd.log" + + _write_executable( + bin_dir / "az", + """#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "account show") echo '{}' ;; + "account show --query id -o tsv") echo '00000000-0000-0000-0000-000000000000' ;; + "group show -n rg-test --query location -o tsv") echo 'eastus' ;; + "cognitiveservices account list -g rg-test --query "*) echo 'foundry-test' ;; + "cognitiveservices account project list -g rg-test --name foundry-test --query [0] -o json") + cat <<'JSON' +{"id":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.CognitiveServices/accounts/foundry-test/projects/travel-project","location":"eastus2","name":"foundry-test/travel-project","properties":{"endpoints":{"AI Foundry API":"https://foundry-test.services.ai.azure.com/api/projects/travel-project"}}} +JSON + ;; + *) echo "unexpected az command: $*" >&2; exit 1 ;; +esac +""", + ) + _write_executable( + bin_dir / "azd", + """#!/usr/bin/env bash +set -euo pipefail +case "$*" in + "env list -o json") echo '[]' ;; + "env new test-env --no-prompt") ;; + "env set "*) printf '%s\n' "$*" >> "$AZD_LOG" ;; + "env get-values") + cat <<'VALUES' +AZURE_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000" +AZURE_RESOURCE_GROUP="rg-test" +AZURE_LOCATION="eastus2" +AZURE_AI_ACCOUNT_NAME="foundry-test" +AZURE_AI_PROJECT_NAME="travel-project" +AZURE_AI_PROJECT_ID="project-id" +AZURE_AI_FOUNDRY_PROJECT_ID="project-id" +AZURE_AI_PROJECT_ENDPOINT="project-endpoint" +FOUNDRY_PROJECT_ENDPOINT="project-endpoint" +USE_EXISTING_AI_PROJECT="true" +AI_PROJECT_DEPLOYMENTS="[]" +VALUES + ;; + *) echo "unexpected azd command: $*" >&2; exit 1 ;; +esac +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env['PATH']}" + env["AZD_LOG"] = str(azd_log) + result = subprocess.run( + [str(LINKER), "rg-test", "test-env"], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + writes = azd_log.read_text().splitlines() + assert "env set AZURE_LOCATION eastus2" in writes + assert "env set AZURE_AI_PROJECT_NAME travel-project" in writes + assert not any("foundry-test/travel-project" in write for write in writes) + project_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.CognitiveServices/accounts/foundry-test/projects/travel-project" + assert f"env set AZURE_AI_PROJECT_ID {project_id}" in writes + assert f"env set AZURE_AI_FOUNDRY_PROJECT_ID {project_id}" in writes + endpoint = "https://foundry-test.services.ai.azure.com/api/projects/travel-project" + assert f"env set AZURE_AI_PROJECT_ENDPOINT {endpoint}" in writes + assert f"env set FOUNDRY_PROJECT_ENDPOINT {endpoint}" in writes \ No newline at end of file