diff --git a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/challenges/challenge-01.md b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/challenges/challenge-01.md index fae5ab7a5..f6269e4a6 100644 --- a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/challenges/challenge-01.md +++ b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/challenges/challenge-01.md @@ -175,6 +175,15 @@ The data lives in **Microsoft Fabric** (a governed Lakehouse with inventory, dem ![The notebook with InventoryLakehouse attached as the default Lakehouse and the Run all button highlighted](../images/challenge-00-run-all.png) + > [!IMPORTANT] + > **First run on a fresh capacity — the table-selection step may need a moment (or a manual tick).** The publish cell selects your 7 tables automatically, but on a **cold F2 capacity** the Lakehouse's **SQL analytics endpoint** can take a couple of minutes to expose the newly-written tables. Until that schema sync finishes you may see **either**: + > - the notebook raise **`No tables were selected (schema may still be syncing)`**, or + > - an **`Error updating data agent data source selection(s)`** toast if you tick tables in the portal too early. + > + > This is expected first-run timing, **not** a failure — your 7 tables always write successfully; only the *agent's selection* waits on the sync. To finish it: + > 1. Open **`InventoryLakehouse`** → switch the view (top-right) from **Lakehouse** to **SQL analytics endpoint** → expand **`dbo → Tables`** and confirm all **7 tables** are listed. If they aren't yet, wait ~1–2 min and **Refresh** (opening the endpoint also nudges the sync). + > 2. Then **either** re-run the notebook's publish cell (it's idempotent), **or** open the **`inventory-hack-agent`** data agent → tick **all 7 tables** in the **Explorer** (left pane) → **Publish**. + **d. Copy your two IDs.** When it finishes, the **last cell prints your Workspace ID and Agent ID**. Note both — you paste them into the Fabric Data Agent tool in Challenge 2. @@ -233,6 +242,8 @@ The data lives in **Microsoft Fabric** (a governed Lakehouse with inventory, dem | The setup notebook errors on `%pip install` or the publish cell | Preview SDK drift — re-run the failed cell; if a method name differs, check the [SDK reference](https://learn.microsoft.com/fabric/data-science/fabric-data-agent-sdk). Make sure your F2 capacity is **running**. | | **Fabric Data Agent** isn't in the tool catalogue (previewing ahead) | Your F2 capacity must be running, and the setup notebook must have **published** the agent. | | The agent replies *"I'm unable to access the data source"* (or *"No tables selected yet"*) | Its tables aren't selected. The setup notebook selects all seven automatically, but if it didn't: open the data agent → **Data** tab → tick **all** tables under `InventoryLakehouse → dbo` → **Publish**, then re-test. | +| The publish cell raises **`No tables were selected (schema may still be syncing)`** | Expected on a **cold F2** — the 7 tables wrote fine, but the Lakehouse **SQL analytics endpoint** hasn't exposed them yet. Open `InventoryLakehouse` → **SQL analytics endpoint** → confirm all 7 tables under `dbo`, then **re-run the publish cell** (idempotent) or tick the tables in the data agent's **Explorer** and **Publish**. | +| Ticking tables in the data agent shows **`Error updating data agent data source selection(s)`** | Same root cause — the SQL endpoint schema isn't fully synced. Wait until all 7 tables show under the Lakehouse's **SQL analytics endpoint**, **refresh** the data agent page (F5), then re-tick and **Publish**. If it persists, remove and re-add the `InventoryLakehouse` data source (**+ Add data source → OneLake catalog**), tick the 7 tables, and **Publish**. | ![The data agent's Data tab showing InventoryLakehouse tables unselected and the agent replying that it cannot access the data source](../images/challenge-00-no-tables.png) diff --git a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/README.md b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/README.md index a744e142d..1451c764f 100644 --- a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/README.md +++ b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/README.md @@ -7,11 +7,11 @@ Everything for this hack is provisioned per attendee by [`deploy-lab.ps1`](deplo | Resource | Type | Notes | |----------|------|-------| | Azure AI Foundry account | `Microsoft.CognitiveServices/accounts` | One per attendee (AIServices kind) | -| Foundry project | `…/projects/inventory-hack` | Within the Foundry account | +| Foundry project | `…/projects/inventory-hack` | System-assigned managed identity; provisioning is verified before the lab continues | | gpt-5.4-mini model deployment | GlobalStandard, 100K TPM | Used by all three hack agents | | Fabric **F2 capacity** | `Microsoft.Fabric/capacities` | One per attendee; attendee set as **capacity admin** | -The script sets each attendee as **admin of their own F2 capacity** (resolving their Entra object ID to a UPN via the platform `Get-MhhLabUser` helper), so they can create a workspace, assign it to the capacity, and publish their own Data Agent — no shared state, no cross-attendee contention. +The script grants the **Foundry User** role to each attendee and to the Foundry project's managed identity at the Foundry account scope. It also sets each attendee as **admin of their own F2 capacity** (resolving their Entra object ID to a UPN via the platform `Get-MhhLabUser` helper), so they can create a workspace, assign it to the capacity, and publish their own Data Agent — no shared state, no cross-attendee contention. ## What the attendee does themselves (Challenge 1) @@ -24,16 +24,18 @@ The script sets each attendee as **admin of their own F2 capacity** (resolving t | Key | Value | |-----|-------| -| `FoundryProjectEndpoint` | `https://.services.ai.azure.com/api/projects/inventory-hack` | +| `FoundryProjectUrl` | Clickable deep-link to the attendee's `inventory-hack` project in the Foundry portal (`ai.azure.com`) — where they build the agents in Challenges 2-5 | +| `FoundryProjectEndpoint` | `https://.services.ai.azure.com/api/projects/inventory-hack` (SDK/API endpoint; optional — the hack is done in the portal) | | `ModelDeploymentName` | `gpt-5.4-mini` | | `FabricCapacityName` | The attendee's own F2 capacity (assign your workspace to it in Challenge 1) | +| `FabricPortalUrl` | Link to the Fabric portal (`app.fabric.microsoft.com`) to create the workspace on `FabricCapacityName` — the workspace URL itself only exists once the attendee creates it in Challenge 1 | | `ResourceGroupName` | The attendee's resource group | Attendees use these in the Foundry and Fabric portals — no SDK or code required. ## Prerequisites for facilitators / the platform -- The deploying identity (the platform **service principal**) needs **Owner** on each attendee resource group (creates the Foundry account, model deployment, and Fabric capacity via ARM) — the platform grants this automatically for `resourcegroup` deployments. +- The deploying identity (the platform **service principal**) needs **Owner** on each attendee resource group so it can create resources and assign the **Foundry User** role to the attendee and project managed identity — the platform grants this automatically for `resourcegroup` deployments. - **`groups: ["M365-E5-Users"]`** in [`lab-defaults.json`](lab-defaults.json) — each attendee's lab Entra user gets a **Microsoft 365 E5** license (includes **Power BI Pro**), which is what lets them sign into the Fabric portal ([app.fabric.microsoft.com](https://app.fabric.microsoft.com)) and create a workspace. Without a Fabric/Power BI license the attendee cannot open the portal. - **The lab tenant must have Fabric enabled** — a Fabric **tenant-admin** setting (*“Users can create Fabric items”* / workspaces). This is **not** settable via `lab-defaults.json`; the tenant administrator for the lab tenant must turn it on, or attendees cannot create a workspace even with a license. - **Fabric F-SKU quota** in a preferred region (F2 = 2 CU; a 512-CU subscription supports ~256 attendees). `swedencentral` and `norwayeast` are good defaults. diff --git a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/deploy-lab.ps1 b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/deploy-lab.ps1 index 622c69190..71b39bcbf 100644 --- a/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-04-AI/02_Inventory_Planning_Fabric/labautomation/deploy-lab.ps1 @@ -63,7 +63,11 @@ $ErrorActionPreference = "Stop" $candidateRegions = if ($PreferredLocation.Count -gt 0) { $PreferredLocation } else { @("swedencentral", "westeurope", "norwayeast") } $effectiveLocation = $candidateRegions[0] $effectiveRG = $ResourceGroupName -$stableHash = (Get-MhhStableHash $AllowedEntraUserIds -Length 12).ToLower() + +if ($AllowedEntraUserIds.Count -eq 0) { + throw "At least one attendee object ID must be supplied in AllowedEntraUserIds." +} +$stableHash = (Get-MhhStableHash $AllowedEntraUserIds -Length 12).ToLower() # For 'subscription' deployments the platform does NOT pre-create a resource group # ($ResourceGroupName arrives empty). Create one ourselves with a deterministic, @@ -170,29 +174,102 @@ if ($existingAccount) { # --------------------------------------------------------------------------- # Foundry project # --------------------------------------------------------------------------- +$accountScope = "/subscriptions/$SubscriptionId/resourceGroups/$effectiveRG" + + "/providers/Microsoft.CognitiveServices/accounts/$foundryAccountName" $projectUri = "/subscriptions/$SubscriptionId/resourceGroups/$effectiveRG" + "/providers/Microsoft.CognitiveServices/accounts/$foundryAccountName" + "/projects/${foundryProjectName}?api-version=2025-04-01-preview" -$existingProject = Invoke-AzRestMethod -Method GET -Path $projectUri -ErrorAction SilentlyContinue -if ($existingProject.StatusCode -ne 200) { - $projectBody = @{ - location = $effectiveLocation - properties = @{ description = "Agentic Inventory Planning MicroHack" } - } | ConvertTo-Json -Depth 3 +$projectBody = @{ + location = $effectiveLocation + identity = @{ type = "SystemAssigned" } + properties = @{ + description = "Agentic Inventory Planning MicroHack" + displayName = "Inventory Planning MicroHack" + } +} | ConvertTo-Json -Depth 4 + +$projectResponse = Invoke-AzRestMethod -Method PUT -Path $projectUri -Payload $projectBody +if ($projectResponse.StatusCode -ge 400) { + throw "Foundry project create or update failed (HTTP $($projectResponse.StatusCode)): $($projectResponse.Content)" +} - Invoke-AzRestMethod -Method PUT -Path $projectUri -Payload $projectBody | Out-Null +$projectResource = $null +$elapsed = 0 +do { + Start-Sleep -Seconds 8 + $elapsed += 8 + $projectResponse = Invoke-AzRestMethod -Method GET -Path $projectUri -ErrorAction SilentlyContinue + if ($projectResponse.StatusCode -eq 200) { + $projectResource = $projectResponse.Content | ConvertFrom-Json + if ($projectResource.properties.provisioningState -eq "Failed") { + throw "Foundry project provisioning failed: $($projectResponse.Content)" + } + } +} while ( + ($projectResponse.StatusCode -ne 200 -or + $projectResource.properties.provisioningState -ne "Succeeded" -or + -not $projectResource.identity.principalId) -and + ($elapsed -lt 180) +) - $elapsed = 0 - do { Start-Sleep -Seconds 8; $elapsed += 8 - $r = Invoke-AzRestMethod -Method GET -Path $projectUri -ErrorAction SilentlyContinue - } while (($r.StatusCode -ne 200) -and ($elapsed -lt 90)) +if ($projectResponse.StatusCode -ne 200 -or + $projectResource.properties.provisioningState -ne "Succeeded" -or + -not $projectResource.identity.principalId) { + throw "Foundry project did not reach Succeeded with a managed identity within 180 seconds. Last response: $($projectResponse.Content)" +} +Write-Host "[OK] Foundry project '$foundryProjectName' is ready." + +# Foundry User (formerly Azure AI User) enables project access and agent operations. +$foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d" + +function Grant-FoundryUserRole { + param( + [Parameter(Mandatory=$true)] + [string]$PrincipalId, + + [Parameter(Mandatory=$true)] + [string]$PrincipalDescription + ) + + $existingAssignment = Get-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -Scope $accountScope ` + -RoleDefinitionId $foundryUserRoleId ` + -ErrorAction SilentlyContinue + if ($existingAssignment) { + Write-Host "[OK] Foundry User already assigned to $PrincipalDescription." + return + } - Write-Host "[OK] Foundry project '$foundryProjectName' created." -} else { - Write-Host "[OK] Foundry project already exists — skipping." + $lastError = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + try { + New-AzRoleAssignment ` + -ObjectId $PrincipalId ` + -RoleDefinitionId $foundryUserRoleId ` + -Scope $accountScope ` + -ErrorAction Stop | Out-Null + Write-Host "[OK] Granted Foundry User to $PrincipalDescription." + return + } catch { + $lastError = $_ + if ($attempt -lt 6) { + Start-Sleep -Seconds 10 + } + } + } + + throw "Could not grant Foundry User to $PrincipalDescription after 6 attempts: $lastError" } +foreach ($userId in $AllowedEntraUserIds) { + Grant-FoundryUserRole -PrincipalId $userId -PrincipalDescription "attendee '$userId'" +} +Grant-FoundryUserRole ` + -PrincipalId $projectResource.identity.principalId ` + -PrincipalDescription "project managed identity '$($projectResource.identity.principalId)'" + # --------------------------------------------------------------------------- # gpt-5.4-mini model deployment (ACCOUNT-scoped, GlobalStandard capacity 100). # Matches the pro-code inventory hack: a low-cost GPT-5 model whose Foundry model @@ -308,14 +385,31 @@ if ($adminUpns.Count -eq 0) { # --------------------------------------------------------------------------- # Return credentials to the attendee dashboard # --------------------------------------------------------------------------- +$tenantId = (Get-AzContext).Tenant.Id $projectEndpoint = "https://$foundryAccountName.services.ai.azure.com/api/projects/$foundryProjectName" +# Clickable Foundry portal deep-link to THIS project. The ai.azure.com portal encodes the +# subscription GUID as a base64url token (in string/hex byte order — NOT Guid.ToByteArray()), +# followed by ,,,. Giving attendees this link means they don't have to +# hunt for their project in the portal. +$subHex = $SubscriptionId -replace '-', '' +$subBytes = for ($i = 0; $i -lt $subHex.Length; $i += 2) { [Convert]::ToByte($subHex.Substring($i, 2), 16) } +$subToken = [Convert]::ToBase64String([byte[]]$subBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +$projectPortalUrl = "https://ai.azure.com/nextgen/r/$subToken,$effectiveRG,,$foundryAccountName,$foundryProjectName/home?tid=$tenantId" +$fabricPortalUrl = "https://app.fabric.microsoft.com/home?tid=$tenantId" + Write-Host "[OK] Lab provisioning complete." +@{ HackboxCredential = @{ + name = "FoundryProjectUrl" + value = $projectPortalUrl + note = "Open your Foundry project directly (sign in with your lab user). This is where you build the agents in Challenges 2-5." +} } + @{ HackboxCredential = @{ name = "FoundryProjectEndpoint" value = $projectEndpoint - note = "Open ai.azure.com → select this project OR use as SDK endpoint" + note = "SDK / API endpoint for your project (optional - the hack is done in the portal via FoundryProjectUrl)" } } @{ HackboxCredential = @{ @@ -330,6 +424,12 @@ Write-Host "[OK] Lab provisioning complete." note = "Your own Fabric F2 capacity. In Challenge 1 you create a workspace and assign it to this capacity, then Run All the setup notebook to publish your Data Agent." } } +@{ HackboxCredential = @{ + name = "FabricPortalUrl" + value = $fabricPortalUrl + note = "Open the Fabric portal (sign in with your lab user) to create your workspace on your FabricCapacityName in Challenge 1. Your workspace URL doesn't exist until you create it there." +} } + @{ HackboxCredential = @{ name = "ResourceGroupName" value = $effectiveRG