From 2c687fd4cd1d08414e6855f67d8ba55b33cdb393 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Mon, 2 Nov 2026 17:47:15 +0100 Subject: [PATCH 01/20] Add single-resource-group lab automation Add hosted deployment and provider preparation with stable naming, scoped RBAC, readiness gates, non-destructive retries, and preferred-region preflight. Package Bicep under labautomation, use bounded asynchronous ARM polling, and align the learner flow with one resource group. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../06_Migration_Secure_AI_Ready/Readme.md | 10 +- .../challenges/challenge-01.md | 12 +- .../challenges/challenge-06.md | 9 +- .../challenges/challenge-07.md | 19 +- .../challenges/challenge-08.md | 9 +- .../challenges/finish.md | 6 +- .../bicep/clientVm/clientVm.bicep | 99 ++++-- .../bicep/main.bicep | 53 +-- .../bicep/main.bicepparam | 10 +- .../bicep/mgmt/mgmtArtifactsDestination.bicep | 21 +- .../bicep/mgmt/mgmtArtifactsSource.bicep | 28 +- .../labautomation/deploy-lab.ps1 | 333 ++++++++++++++++++ .../labautomation/lab-defaults.json | 8 + .../labautomation/shared-deploy-lab.ps1 | 51 +++ .../resources/README.md | 74 +++- .../resources/artifacts/Bootstrap.ps1 | 203 ++++++++--- .../artifacts/MHServersLogonScript.ps1 | 278 +++++++++------ .../resources/artifacts/WinGet.ps1 | 45 ++- .../walkthrough/challenge-01/solution-01.md | 132 ++----- .../walkthrough/challenge-02/solution-02.md | 21 +- .../walkthrough/challenge-05/solution-05.md | 20 +- .../walkthrough/challenge-06/solution-06.md | 17 +- .../walkthrough/challenge-07/solution-07.md | 51 +-- .../walkthrough/challenge-08/solution-08.md | 156 ++++---- 24 files changed, 1160 insertions(+), 505 deletions(-) rename 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/{resources => labautomation}/bicep/clientVm/clientVm.bicep (70%) rename 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/{resources => labautomation}/bicep/main.bicep (79%) rename 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/{resources => labautomation}/bicep/main.bicepparam (52%) rename 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/{resources => labautomation}/bicep/mgmt/mgmtArtifactsDestination.bicep (95%) rename 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/{resources => labautomation}/bicep/mgmt/mgmtArtifactsSource.bicep (94%) create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/lab-defaults.json create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/shared-deploy-lab.ps1 diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md index 7e53dec6f..9ee674311 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md @@ -46,7 +46,12 @@ The Hack follows a coherent modernization storyline: 6. **Operate intelligently** with Azure Monitor telemetry, alerting, and AI-assisted investigation. 7. **Replatform** the selected static IIS or Apache workload to Azure App Service and evaluate the optimal production hosting target. -The lab simulates an on-premises datacenter by running nested guest VMs on a Hyper-V host in an Azure source resource group. Azure Migrate discovers, assesses, and migrates those systems into a destination resource group. In the final challenges, each team operates and manually replatforms either the migrated Windows/IIS workload or the migrated Ubuntu/Apache workload. +Each learner receives one pre-created resource group, shown as `` in these instructions. It contains both the simulated on-premises environment and the migration destination: the source VNet (`10.1.0.0/16`) hosts a Hyper-V server with four nested VMs, while the destination VNet (`10.2.0.0/16`) receives the migrated web VMs. Both networks retain NAT Gateway outbound connectivity and Azure Bastion access. Azure resource names use a deterministic eight-character hash of the subscription and resource-group scope, not the deployer's user principal name; the Hyper-V host is named `mh-<8charStableLabSuffix>-hv`. + +The platform predeploys only the Challenge 1 infrastructure and waits until all four nested VMs and both web dashboards are ready for the initial handoff. Completed deployment reruns reuse that baseline without restarting source guests that migration may have stopped. Discovery, assessment, migration, monitoring, and App Service remain learner exercises. In the final challenges, each team operates and manually replatforms either the migrated Windows/IIS workload or the migrated Ubuntu/Apache workload, keeping all user-controlled resources in the same lab resource group. + +> [!NOTE] +> Screenshots throughout the Hack illustrate portal workflows and may show older resource names. The lab dashboard's subscription, resource group, host name, and credentials are authoritative. Do not create separate source and destination resource groups or delete the lab resource group during Challenges 2–8. The Hyper-V discovery and migration design is described here: @@ -76,8 +81,9 @@ Complete these prerequisites before the session: * A Microsoft Entra ID tenant. * At least one Azure subscription. -* A Microsoft Entra user with Contributor or Owner permissions on the Azure subscription. +* A Microsoft Entra learner account with **Owner** on its lab resource group and **Reader** on the subscription, assigned by the platform. The hosted shared hook registers required resource providers once per subscription without creating a shared group or enabling Defender. Instructors verify that preparation succeeded, arrange quota, and separately approve/enable any optional Defender plans; learners do not need subscription Owner. * A client network and browser that support the Azure portal, Azure Bastion, and Azure Cloud Shell. +* A ready lab deployment and access to the platform dashboard's connection details. Instructors should follow the [deployment and local validation instructions](resources/README.md) before the session; fresh predeployment can take 35–90+ minutes. Challenge 7 assumes Hack participants have Azure Copilot access, suitable RBAC, network access, and a supported region. It also includes a concise manual/KQL fallback if agent output is unavailable. No Azure OpenAI resource, model deployment, or model quota is required anywhere in this Hack. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md index 59fc3453e..500f788e0 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md @@ -7,18 +7,20 @@ ## Goal -- Deploy a *source* resource group with two VMs and their dependencies that act as on-premises Hyper-V workloads to be migrated to Azure. -- Deploy a *destination* resource group to which the servers will be migrated. +- Understand the preprovisioned single-resource-group lab, its source and destination networks, and the four nested Hyper-V VMs. +- Receive your platform dashboard connection details and verify that the lab is ready. ## Actions -- Deploy the [Bicep configuration](../resources) for the Hack. +- Open the platform dashboard and record your assigned subscription, ``, Hyper-V host name, and login credential fields. +- Connect through Azure Bastion, verify the host readiness status and four running nested VMs, and open both web dashboards. +- Leave discovery, migration, monitoring, and App Service configuration for the later challenges. Instructors handle [predeployment](../resources/README.md). ## Success criteria - You have understood the concept and architecture for the Hack. -- The Bicep deployment command exits successfully. -- The *source* and *destination* resource groups are visible in the Azure Portal. +- Your one assigned resource group contains the source VNet (`10.1.0.0/16`), destination VNet (`10.2.0.0/16`), NAT Gateways, Bastion access, Key Vault, and Hyper-V host `mh-<8charStableLabSuffix>-hv`. +- At initial handoff, the host VM's tags are `DeploymentProgress=Deployment Completed` and `LabReady=single-rg-v1`, the schema-version-1 marker `C:\MHBox\lab-ready.json` is present, and all four nested VMs are running. - The Windows/IIS and Ubuntu/Apache demo dashboards return HTTP success and show the correct hostname, platform, and web server. ## Learning resources diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md index e9cf380ac..c25b579b4 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md @@ -6,11 +6,16 @@ The goal of this exercise is to ... -* secure the migrated virtual machines by enabling Defender for Cloud for Servers. +* verify and use Defender for Cloud for Servers protection on the migrated virtual machines in your lab resource group. + +## Prerequisites + +* The instructor has approved and enabled the subscription-wide Defender CSPM and Defender for Servers plans on the shared subscription, including their costs. +* Learners retain **Owner** on `` and **Reader** on the subscription. Subscription-wide plan activation is an instructor task, not a reason to grant subscription Owner to each learner. ## Actions -* Enable Defender for Cloud. +* Verify the instructor-enabled Defender plans, inspect endpoint protection, and review alerts and recommendations for your lab resources. ## Success criteria diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md index 1910ae2a5..861a08977 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md @@ -19,10 +19,10 @@ The Linux alert monitors every process whose `"process.executable.name"` is `apa ## Prerequisites and access assumptions -* Challenge 5 is complete and both migrated web VMs in `destination-rg` are running. +* Challenge 5 is complete and both migrated web VMs in `` are running. * You can connect to both VMs through Azure Bastion and each site responds locally. -* Before opening **Infrastructure monitoring**, you can create or reuse one user-assigned managed identity for both migrated VMs. Using the destination resource group and the same region is recommended for this lab. -* You can grant that identity **Monitoring Reader** on both monitored VMs or their containing destination resource group, and on the Azure Monitor workspace when the alert uses workspace scope. +* You have RG Owner and subscription Reader. Before opening **Infrastructure monitoring**, create or reuse one user-assigned managed identity in the lab resource group for both migrated VMs. +* Grant that identity **Monitoring Reader** on the lab resource group for the migrated VMs and monitoring workspace. Do not assign roles at subscription scope. * This user-assigned identity authorizes OpenTelemetry query-based and recommended alerts. It is separate from the VM's system-assigned identity used by Azure Monitor Agent; the portal doesn't automatically create the user-assigned alert identity. * Azure Copilot access, a supported region, and the required telemetry permissions are available to Hack participants. No Azure OpenAI resource, model deployment, or model quota is required. * The client network allows WebSocket connections to `https://directline.botframework.com`. @@ -50,11 +50,12 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Use classic log coll * Warm and validate both web pages before changing either service. * Create one reusable user-assigned alert identity before opening Infrastructure monitoring, grant its required **Monitoring Reader** access, and select that existing identity in the portal's **Identity** field for both VMs. -* Let Infrastructure monitoring select or create the regional default Azure Monitor workspace and generated OpenTelemetry DCR; do not pre-create a custom workspace. +* As part of this exercise, explicitly choose or create an Azure Monitor workspace and a compatible regional Log Analytics workspace through the portal **in the lab resource group**, then select those workspaces in Infrastructure monitoring. +* Keep all user-controlled workspaces, identities, DCRs, alert rules, and action groups in the lab resource group. Verify the proposed scope before enabling monitoring; do not accept resources in another user-controlled group. * Enable OpenTelemetry metrics, per-process metrics, built-in Grafana dashboards, and recommended infrastructure alerts. * Confirm query-based and recommended alerts use the pre-created identity and that it retains the required monitoring read access. * Locate the Linux VM's generated `MSVMOtel--` DCR and ensure that `process.uptime` is collected. -* Reuse the portal-selected compatible Log Analytics workspace for Windows events and associate a focused Event DCR. +* Reuse the selected compatible Log Analytics workspace in the lab resource group for Windows events and associate a focused Event DCR. * Create an Apache PromQL absence alert and an IIS KQL scheduled-query alert. * Stop Apache and IIS in a controlled manner, prove local HTTP failure, and wait for both alerts. * Start a deep investigation from each fired alert, evaluate evidence and ruled-out hypotheses, and save useful context as an Azure Monitor issue when available. @@ -62,19 +63,23 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Use classic log coll ## Success criteria -* Both VMs send default OpenTelemetry guest metrics to the portal-selected Azure Monitor workspace through their intended generated DCR associations. +* Both VMs send default OpenTelemetry guest metrics to the explicitly selected Azure Monitor workspace in the lab resource group through their intended generated DCR associations. * Linux `process.uptime` metrics include one or more `apache2` process series before the incident. -* Windows System event ID 7036 records for `W3SVC` are visible in the portal-selected Log Analytics workspace. +* Windows System event ID 7036 records for `W3SVC` are visible in the selected compatible Log Analytics workspace in the lab resource group. * The Apache PromQL alert and IIS KQL alert are enabled, use the intended signals, and fire after the controlled failures. * An inactive service and local HTTP failure prove user-visible impact on each VM; process or service telemetry alone isn't treated as a synthetic availability test. * The AI-assisted or manual investigation distinguishes service failure from VM/platform failure and produces evidence-backed remediation with rollback and prevention guidance. * A human restores `W3SVC` and `apache2`; both sites return HTTP `200`, service/process telemetry returns, and alerts resolve. * Both recovered workloads are available for the path selection at the start of Challenge 8. +> [!NOTE] +> Azure Monitor automatically creates an `MA___managed` resource group for its workspace-managed DCR/DCE. This Azure-managed exception is not another learner-created lab group. Keep user-controlled monitoring resources in your assigned lab group; do not move or separately delete the service-managed internals. + ## Learning resources * [Metrics experience for virtual machines](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-opentelemetry-guest) * [Enable enhanced monitoring for an Azure VM](https://learn.microsoft.com/azure/azure-monitor/vm/tutorial-enable-monitoring) +* [Manage an Azure Monitor workspace](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage) * [Customize OpenTelemetry metrics for VMs](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-opentelemetry-guest-modify) * [OpenTelemetry guest OS metrics reference](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-guest-reference) * [PromQL for system and guest OS metrics](https://learn.microsoft.com/azure/azure-monitor/metrics/prometheus-system-metrics-best-practices) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md index 67d992c04..0c125202a 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md @@ -24,8 +24,8 @@ Complete one path during the standard challenge time. If time permits, you may c * Challenge 7 is complete and both `W3SVC` and `apache2` have been restored. * You can connect to the source VM for your chosen path through Azure Bastion. -* You know the Hack subscription ID and can create App Service resources in it. -* You know the exact destination resource group. The Bicep deployment names it `MHBox--destination-rg`, where `` is the deployer's user principal name before `@`. +* You know the Hack subscription ID and have **Owner** on your assigned lab resource group and **Reader** on the subscription. +* You know the exact `` from the platform dashboard. It already contains the simulated source and migrated VMs; create the App Service resources in this same group. * The selected site content is available at `C:\inetpub\wwwroot` or `/var/www/html`. * Path A can use Azure Cloud Shell and an interactive browser session for Kudu ZIP upload, or the documented CLI alternative. * Path B can make an interactive device-code sign-in from the migrated Linux VM and has permission to deploy App Service resources. @@ -40,7 +40,7 @@ Complete one path during the standard challenge time. If time permits, you may c * Replace the VM-specific hostname, platform, and web-server values in the package by using the page's stable metadata attributes. * Create a ZIP package whose root contains `index.html`. * Set and display the intended Azure subscription, tenant, and user before any resource operation. Stop if they don't identify the Hack subscription. -* Replace the destination resource-group placeholder with the exact `MHBox--destination-rg` name and verify that it exists. +* Replace `` with your exact dashboard-provided lab group name and verify that it exists. Do not derive a group name from a UPN or create another group. * Create a low-cost App Service plan for the selected operating system and a globally unique web app. * Deploy with supported App Service ZIP deployment, enforce HTTPS, and validate the page and assets. * Stop the source web service, prove that App Service remains available, and restore the source service. @@ -62,7 +62,7 @@ Complete one path during the standard challenge time. If time permits, you may c ## Success criteria * The selected site's inspection shows no required server-side runtime, database, machine-local dependency, or session state. -* The active subscription, tenant, user, and exact destination resource group are displayed and verified before deployment. +* The active subscription, tenant, user, and exact lab resource group are displayed and verified before deployment. * The ZIP root contains `index.html` rather than a nested content directory. * The chosen Windows or Linux App Service app serves the selected site's page and assets over HTTPS. * A Path B app uses a runtime returned by `az webapp list-runtimes --os-type linux` and a PM2 static-content startup command. @@ -70,6 +70,7 @@ Complete one path during the standard challenge time. If time permits, you may c * The App Service endpoint remains healthy while the selected source service is stopped, and the source service is restored afterward. * Path B signs out of Azure CLI after deployment. * No Azure Load Balancer is placed in front of App Service and no GitHub integration is required. +* The single lab resource group is retained through the exercise; optional App Service cleanup does not delete the whole group. ## Architecture decision diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/finish.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/finish.md index c1fe94526..fd34edd38 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/finish.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/finish.md @@ -12,6 +12,10 @@ You followed the complete storyline: discover, assess, plan, migrate, secure, op The final architecture keeps App Service independent of the migrated VM networking. For the static demo workload, Azure Storage static website hosting or Azure Static Web Apps should also be evaluated before choosing a production target. -Cleanup is optional and must be approved by the lab owner. Remember that stopping a VM or web app does not necessarily stop all charges; delete only the resources or resource groups that are not shared or needed for later work. +Keep `` throughout Challenges 2–8. Cleanup after this finish is optional and must be approved by the lab owner. Stopping a VM or web app does not necessarily stop all charges. + +The lab uses **one resource group** for both the simulated source environment and migrated workloads, plus monitoring and App Service. Deleting that group removes **all** of them, not just the source. Before an approved deletion, use the supported Azure Migrate test-migration cleanup and complete-migration/stop-replication actions, then remove any backup protection or retained recovery data through the relevant service's cleanup workflow. Backup and replication dependencies can block deletion; do not bypass their safeguards. + +Azure Monitor's automatically created `MA_*_managed` group is service-managed. Manage its lifecycle by deleting the parent Azure Monitor workspace through the supported workflow, not by separately deleting its internals. Do not delete shared subscription resources or change instructor-managed Defender plans. Thank you for investing the time. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/clientVm/clientVm.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep similarity index 70% rename from 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/clientVm/clientVm.bicep rename to 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep index c59f3489d..ea29e1e95 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/clientVm/clientVm.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep @@ -22,6 +22,11 @@ param location string = resourceGroup().location @description('Resource Id of the subnet in the virtual network') param subnetId string +param labSuffix string +param keyVaultName string +param participantObjectIds array +param tags object = {} + param spnAuthority string = environment().authentication.loginEndpoint @description('Your Microsoft Entra tenant Id') @@ -82,16 +87,14 @@ param zones string = '1' @description('Option to enable spot pricing for the ArcBox Client VM') param enableAzureSpotPricing bool = false -var userName = contains(deployer().userPrincipalName, '@') ? substring(deployer().userPrincipalName, 0, indexOf(deployer().userPrincipalName, '@')) : deployer().userPrincipalName - -var namingPrefix = '${Prefix}-${userName}' +var namingPrefix = 'mh-${labSuffix}' @description('The name of your Virtual Machine') -var vmName string = '${userName}-HV' +var hostName string = '${namingPrefix}-hv' var bastionName = '${namingPrefix}-Bastion' -var publicIpAddressName = deployBastion == false ? '${vmName}-PIP' : '${bastionName}-PIP' -var networkInterfaceName = '${vmName}-NIC' +var publicIpAddressName = deployBastion == false ? '${hostName}-PIP' : '${bastionName}-PIP' +var networkInterfaceName = '${hostName}-NIC' var osDiskType = 'Premium_LRS' var PublicIPNoBastion = { id: publicIpAddress.id @@ -99,6 +102,7 @@ var PublicIPNoBastion = { resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = { name: networkInterfaceName location: location + tags: tags properties: { ipConfigurations: [ { @@ -118,6 +122,7 @@ resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = { resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployBastion == false) { name: publicIpAddressName location: location + tags: tags zones: [zones] properties: { publicIPAllocationMethod: 'Static' @@ -131,7 +136,8 @@ resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if ( resource vmDisk 'Microsoft.Compute/disks@2024-03-02' = { location: location - name: '${vmName}-VMsDisk' + tags: tags + name: '${hostName}-VMsDisk' zones: [zones] sku: { name: vmsDiskSku @@ -148,8 +154,9 @@ resource vmDisk 'Microsoft.Compute/disks@2024-03-02' = { } resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { - name: vmName + name: hostName location: location + tags: tags zones: [zones] identity: { type: 'SystemAssigned' @@ -160,7 +167,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { } storageProfile: { osDisk: { - name: '${vmName}-OSDisk' + name: '${hostName}-OSDisk' caching: 'ReadWrite' createOption: 'FromImage' managedDisk: { @@ -192,7 +199,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { ] } osProfile: { - computerName: vmName + computerName: hostName adminUsername: windowsAdminUsername adminPassword: windowsAdminPassword windowsConfiguration: { @@ -216,6 +223,13 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { tags: { displayName: 'config-bootstrap' } + dependsOn: [ + vmVaultReader + vmResourceReader + vmTagContributor + vmDiskContributor + vmScheduleContributor + ] properties: { publisher: 'Microsoft.Compute' type: 'CustomScriptExtension' @@ -225,60 +239,78 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { fileUris: [ uri(templateBaseUrl, 'artifacts/Bootstrap.ps1') ] - //commandToExecute: 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File Bootstrap.ps1 -adminUsername ${windowsAdminUsername} -tenantId ${tenantId} -spnAuthority ${spnAuthority} -subscriptionId ${subscription().subscriptionId} -resourceGroup ${resourceGroup().name} -acceptEula ${acceptEula} -registryUsername ${registryUsername} -azureLocation ${location} -templateBaseUrl "${templateBaseUrl}" -flavor ${flavor} -githubUser ${githubUser} -githubBranch "${githubBranch}" -vmAutologon ${vmAutologon} -rdpPort ${rdpPort} -namingPrefix ${namingPrefix} -debugEnabled ${debugEnabled} -sqlServerEdition ${sqlServerEdition} -autoShutdownEnabled ${autoShutdownEnabled}' - commandToExecute: 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File Bootstrap.ps1 -adminUsername ${windowsAdminUsername} -tenantId ${tenantId} -spnAuthority ${spnAuthority} -subscriptionId ${subscription().subscriptionId} -resourceGroup ${resourceGroup().name} -acceptEula ${acceptEula} -registryUsername ${registryUsername} -azureLocation ${location} -templateBaseUrl "${templateBaseUrl}" -flavor ${flavor} -githubUser ${githubUser} -githubBranch "${githubBranch}" -vmAutologon ${vmAutologon} -rdpPort ${rdpPort} -namingPrefix "MHBox" -debugEnabled ${debugEnabled} -sqlServerEdition ${sqlServerEdition} -autoShutdownEnabled ${autoShutdownEnabled}' + commandToExecute: 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File Bootstrap.ps1 -adminUsername ${windowsAdminUsername} -tenantId ${tenantId} -spnAuthority ${spnAuthority} -subscriptionId ${subscription().subscriptionId} -resourceGroup "${resourceGroup().name}" -hostVmName "${hostName}" -keyVaultName "${keyVaultName}" -acceptEula ${acceptEula} -registryUsername ${registryUsername} -azureLocation ${location} -templateBaseUrl "${templateBaseUrl}" -flavor ${flavor} -githubUser ${githubUser} -githubBranch "${githubBranch}" -vmAutologon ${vmAutologon} -rdpPort ${rdpPort} -namingPrefix "${Prefix}" -debugEnabled ${debugEnabled} -sqlServerEdition ${sqlServerEdition} -autoShutdownEnabled ${autoShutdownEnabled}' } } } -// Add role assignment for the VM: Azure Key Vault Administrator role -resource vmRoleAssignment_KeyVaultAdministrator 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(vm.id, 'Microsoft.Authorization/roleAssignments', 'Administrator') - scope: resourceGroup() +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +resource vmVaultReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, vm.id, 'secrets-reader') + scope: vault properties: { principalId: vm.identity.principalId - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') principalType: 'ServicePrincipal' + } +} + +resource participantVaultReaders 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for participantId in participantObjectIds: { + name: guid(vault.id, participantId, 'secrets-reader') + scope: vault + properties: { + principalId: participantId + principalType: 'User' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') + } +}] +resource vmResourceReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, vm.id, 'reader') + properties: { + principalId: vm.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') } } -// Add role assignment for the deploy user: Azure Key Vault Administrator role -resource deployerRoleAssignment_KeyVaultAdministrator 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(resourceGroup().id,deployer().objectId, resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483')) - scope: resourceGroup() +resource vmTagContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, vm.id, 'tag-contributor') properties: { - principalId: deployer().objectId - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483') + principalId: vm.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4a9ae827-6dc8-4573-8ac7-8239d42aa03f') } } -// Add role assignment for the VM: Owner role -resource vmRoleAssignment_Owner 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(vm.id, 'Microsoft.Authorization/roleAssignments', 'Owner') - scope: resourceGroup() +resource vmDiskContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vmDisk.id, vm.id, 'contributor') + scope: vmDisk properties: { principalId: vm.identity.principalId - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '8e3af657-a8ff-443c-a75c-2fe8c4bcb635') principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') } } -// Add role assignment for the VM: Storage Blob Data Contributor -resource vmRoleAssignment_Storage 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(vm.id, 'Microsoft.Authorization/roleAssignments', 'Storage Blob Data Contributor') - scope: resourceGroup() +resource vmScheduleContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (autoShutdownEnabled) { + name: guid(autoShutdown.id, vm.id, 'contributor') + scope: autoShutdown properties: { principalId: vm.identity.principalId - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c') } } resource autoShutdown 'Microsoft.DevTestLab/schedules@2018-09-15' = if (autoShutdownEnabled) { name: 'shutdown-computevm-${vm.name}' location: location + tags: tags properties: { status: 'Enabled' taskType: 'ComputeVmShutdownTask' @@ -299,3 +331,4 @@ resource autoShutdown 'Microsoft.DevTestLab/schedules@2018-09-15' = if (autoShut output adminUsername string = windowsAdminUsername output publicIP string = deployBastion == false ? publicIpAddress!.properties.ipAddress : '' +output vmName string = vm.name diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep similarity index 79% rename from 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicep rename to 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep index 60c446419..363541432 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep @@ -1,4 +1,13 @@ -targetScope = 'subscription' +targetScope = 'resourceGroup' + +@minLength(8) +@maxLength(8) +@description('Stable, lowercase alphanumeric suffix derived from the subscription and lab resource group.') +param labSuffix string + +@minLength(1) +@description('Entra user object IDs that need to read the host login secrets.') +param participantObjectIds array @description('Your Microsoft Entra tenant Id') param tenantId string = tenant().tenantId @@ -10,7 +19,7 @@ param windowsAdminUsername string = 'HVAdmin' @minLength(12) @maxLength(123) @secure() -param windowsAdminPassword string = newGuid() +param windowsAdminPassword string @description('Enable automatic logon into ArcBox Virtual Machine') param vmAutologon bool = true @@ -53,7 +62,7 @@ param bastionSku string = 'Developer' param githubUser string = 'Azure' @description('Azure location to deploy all resources') -param location string = deployment().location +param location string = resourceGroup().location @description('Use this parameter to enable or disable debug mode for the automation scripts on the client VM, effectively configuring PowerShell ErrorActionPreference to Break. Intended for use when troubleshooting automation scripts. Default is false.') param debugEnabled bool = false @@ -82,34 +91,22 @@ param zones string = '1' var templateBaseUrl = 'https://raw.githubusercontent.com/${githubAccount}/MicroHack/${githubBranch}/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/' -var userName = contains(deployer().userPrincipalName, '@') ? substring(deployer().userPrincipalName, 0, indexOf(deployer().userPrincipalName, '@')) : deployer().userPrincipalName - -var namingPrefix = '${Prefix}-${userName}' +var namingPrefix = 'mh-${labSuffix}' @description('Tags to assign for all MH resources') var resourceTags object = { Microhack: '06_Migration_Secure_AI_Ready' - User: userName -} - -@description('Source resource group') -resource sourceRg 'Microsoft.Resources/resourceGroups@2021-01-01'={ - name: '${namingPrefix}-source-rg' - location: location - tags: resourceTags -} - -@description('Destination resource group') -resource destinationRg 'Microsoft.Resources/resourceGroups@2021-01-01'={ - name: '${namingPrefix}-destination-rg' - location: location - tags: resourceTags + LabId: labSuffix } module clientVmDeployment 'clientVm/clientVm.bicep' = { name: '${namingPrefix}-clientVmDeployment' - scope: sourceRg params: { + labSuffix: labSuffix + participantObjectIds: participantObjectIds + keyVaultName: mgmtArtifactsSourceDeployment.outputs.vaultName + tags: resourceTags + Prefix: Prefix windowsAdminUsername: windowsAdminUsername windowsAdminPassword: windowsAdminPassword tenantId: tenantId @@ -137,8 +134,9 @@ module clientVmDeployment 'clientVm/clientVm.bicep' = { module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { name: '${namingPrefix}-mgmtArtifactsSourceDeployment' - scope: sourceRg params: { + labSuffix: labSuffix + tags: resourceTags flavor: flavor deployBastion: deployBastion bastionSku: bastionSku @@ -150,8 +148,9 @@ module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { module mgmtArtifactsDestinationDeployment 'mgmt/mgmtArtifactsDestination.bicep' = { name: '${namingPrefix}-mgmtArtifactsDestinationDeployment' - scope: destinationRg params: { + labSuffix: labSuffix + tags: resourceTags flavor: flavor deployBastion: deployBastion bastionSku: bastionSku @@ -161,3 +160,9 @@ module mgmtArtifactsDestinationDeployment 'mgmt/mgmtArtifactsDestination.bicep' output clientVmLogonUserName string = windowsAdminUsername +output hostVmName string = clientVmDeployment.outputs.vmName +output keyVaultName string = mgmtArtifactsSourceDeployment.outputs.vaultName +output sourceVnetName string = mgmtArtifactsSourceDeployment.outputs.vnetName +output sourceSubnetId string = mgmtArtifactsSourceDeployment.outputs.subnetId +output destinationVnetName string = mgmtArtifactsDestinationDeployment.outputs.vnetName +output destinationSubnetId string = mgmtArtifactsDestinationDeployment.outputs.subnetId diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicepparam b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicepparam similarity index 52% rename from 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicepparam rename to 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicepparam index 940497c37..1ff7a2f65 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/main.bicepparam +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicepparam @@ -1,6 +1,11 @@ using 'main.bicep' -param tenantId = '' +// Example only; deploy-lab.ps1 derives the real suffix from the assigned lab scope. +param labSuffix = 'lab00001' + +param participantObjectIds = [ + '' +] param windowsAdminUsername = 'HVAdmin' @@ -11,6 +16,3 @@ param flavor = 'ITPro' param deployBastion = true param vmAutologon = true - - - diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsDestination.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep similarity index 95% rename from 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsDestination.bicep rename to 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep index 6dd83fe98..cc82caef6 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsDestination.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep @@ -22,13 +22,10 @@ param bastionSku string = 'Basic' @description('DNS Server configuration') param dnsServers array = [] -@maxLength(7) -@description('The naming prefix for the nested virtual machines. Example: ArcBox-Win2k19') -param Prefix string = 'MHBox' +param labSuffix string +param tags object = {} -var userName = contains(deployer().userPrincipalName, '@') ? substring(deployer().userPrincipalName, 0, indexOf(deployer().userPrincipalName, '@')) : deployer().userPrincipalName - -var namingPrefix = '${Prefix}-${userName}' +var namingPrefix = 'mh-${labSuffix}' @description('Name of the VNet') var virtualNetworkNameDST string = '${namingPrefix}-destination-VNet' @@ -86,6 +83,7 @@ var bastionDSTSubnet = bastionSku != 'Developer' resource destinationVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01' = { name: virtualNetworkNameDST location: location + tags: tags dependsOn: [ ] @@ -112,6 +110,7 @@ resource destinationVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01 resource natDSTGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-07-01' = if (deployBastion || flavor != 'ITPro') { name: '${natGatewayNameDST}-PIP' location: location + tags: tags properties: { publicIPAllocationMethod: 'Static' publicIPAddressVersion: 'IPv4' @@ -125,6 +124,7 @@ resource natDSTGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-07-01' resource natGatewayDST 'Microsoft.Network/natGateways@2024-07-01' = if (deployBastion || flavor != 'ITPro') { name: natGatewayNameDST location: location + tags: tags sku: { name: 'Standard' } @@ -142,6 +142,7 @@ resource natGatewayDST 'Microsoft.Network/natGateways@2024-07-01' = if (deployBa resource networkSecurityGroupDST 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { name: networkSecurityGroupDSTName location: location + tags: tags dependsOn: [ ] @@ -177,9 +178,10 @@ resource networkSecurityGroupDST 'Microsoft.Network/networkSecurityGroups@2024-0 } } -resource bastionnetworkSecurityGroupDST 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployBastion == true) { +resource bastionnetworkSecurityGroupDST 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployBastion && bastionSku != 'Developer') { name: bastionDSTNetworkSecurityGroupName location: location + tags: tags dependsOn: [ ] @@ -305,9 +307,10 @@ resource bastionnetworkSecurityGroupDST 'Microsoft.Network/networkSecurityGroups } } -resource publicIpAddressDST 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployBastion == true) { +resource publicIpAddressDST 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployBastion && bastionSku != 'Developer') { name: bastionDSTPublicIpAddressName location: location + tags: tags dependsOn: [ ] @@ -324,6 +327,7 @@ resource publicIpAddressDST 'Microsoft.Network/publicIPAddresses@2024-05-01' = i resource bastionHostDST 'Microsoft.Network/bastionHosts@2024-05-01' = if (deployBastion == true) { name: bastionDSTName location: location + tags: tags dependsOn: [ ] @@ -358,3 +362,4 @@ resource bastionHostDST 'Microsoft.Network/bastionHosts@2024-05-01' = if (deploy output vnetId string = destinationVirtualNetwork.id output subnetId string = destinationVirtualNetwork.properties.subnets[0].id +output vnetName string = destinationVirtualNetwork.name diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsSource.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep similarity index 94% rename from 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsSource.bicep rename to 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep index 28ba50a8f..4e86a405d 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep/mgmt/mgmtArtifactsSource.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep @@ -21,9 +21,8 @@ param bastionSku string = 'Basic' @description('DNS Server configuration') param dnsServers array = [] -@maxLength(7) -@description('The naming prefix for the nested virtual machines. Example: ArcBox-Win2k19') -param Prefix string = 'MHBox' +param labSuffix string +param tags object = {} @description('Password for Windows account. Password must have 3 of the following: 1 lower case character, 1 upper case character, 1 number, and 1 special character. The value must be between 12 and 123 characters long') @minLength(12) @@ -33,9 +32,7 @@ param windowsAdminPassword string? param windowsAdminUserName string? -var userName = contains(deployer().userPrincipalName, '@') ? substring(deployer().userPrincipalName, 0, indexOf(deployer().userPrincipalName, '@')) : deployer().userPrincipalName - -var namingPrefix = '${Prefix}-${userName}' +var namingPrefix = 'mh-${labSuffix}' @description('Name of the VNet') var virtualNetworkName string = '${namingPrefix}-Source-VNet' @@ -53,8 +50,7 @@ var networkSecurityGroupName string = '${namingPrefix}-Source-NSG' var bastionNetworkSecurityGroupName string = '${namingPrefix}-SourceBastion-NSG' var unique = uniqueString(resourceGroup().id) -var shortunique = substring(unique, 0, 4) -var keyVaultName = toLower('${namingPrefix}SRC${shortunique}') +var keyVaultName = 'mhkv${unique}' var subnetAddressPrefix = '10.1.1.0/24' var addressPrefix = '10.1.0.0/16' @@ -98,6 +94,7 @@ var bastionSubnet = bastionSku != 'Developer' resource sourceVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01' = { name: virtualNetworkName location: location + tags: tags dependsOn: [ ] @@ -124,6 +121,7 @@ resource sourceVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01' = { resource natGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-07-01' = if (deployBastion || flavor != 'ITPro') { name: '${natGatewayName}-PIP' location: location + tags: tags properties: { publicIPAllocationMethod: 'Static' publicIPAddressVersion: 'IPv4' @@ -137,6 +135,7 @@ resource natGatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-07-01' = i resource natGateway 'Microsoft.Network/natGateways@2024-07-01' = if (deployBastion || flavor != 'ITPro') { name: natGatewayName location: location + tags: tags sku: { name: 'Standard' } @@ -153,6 +152,7 @@ resource natGateway 'Microsoft.Network/natGateways@2024-07-01' = if (deployBasti resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { name: networkSecurityGroupName location: location + tags: tags dependsOn: [ ] @@ -175,9 +175,10 @@ resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-0 } } -resource bastionNetworkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployBastion == true) { +resource bastionNetworkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = if (deployBastion && bastionSku != 'Developer') { name: bastionNetworkSecurityGroupName location: location + tags: tags dependsOn: [ ] @@ -303,9 +304,10 @@ resource bastionNetworkSecurityGroup 'Microsoft.Network/networkSecurityGroups@20 } } -resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployBastion == true) { +resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (deployBastion && bastionSku != 'Developer') { name: bastionPublicIpAddressName location: location + tags: tags dependsOn: [ ] @@ -322,6 +324,7 @@ resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if ( resource bastionHost 'Microsoft.Network/bastionHosts@2024-05-01' = if (deployBastion == true) { name: bastionName location: location + tags: tags dependsOn: [ ] @@ -361,8 +364,9 @@ module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { params: { name: toLower(keyVaultName) enablePurgeProtection: false - enableSoftDelete: false + enableRbacAuthorization: true location: location + tags: tags } } @@ -397,3 +401,5 @@ resource windowsAdminUserName_kv_secret 'Microsoft.KeyVault/vaults/secrets@2024- output vnetId string = sourceVirtualNetwork.id output subnetId string = sourceVirtualNetwork.properties.subnets[0].id +output vnetName string = sourceVirtualNetwork.name +output vaultName string = kv.name diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 new file mode 100644 index 000000000..0c1fa7be7 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -0,0 +1,333 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('subscription', 'resourcegroup', 'resourcegroup-with-subscriptionowner')] + [string]$DeploymentType, + + [Parameter(Mandatory = $true)] + [string]$SubscriptionId, + + [string]$ResourceGroupName = '', + + [string[]]$PreferredLocation = @(), + + [string[]]$AllowedEntraUserIds = @() +) + +$ErrorActionPreference = 'Stop' +if ($DeploymentType -eq 'subscription' -or [string]::IsNullOrWhiteSpace($ResourceGroupName)) { + throw 'This Hack requires a platform-provided resource group. Select resourcegroup deployment type.' +} +if ($AllowedEntraUserIds.Count -eq 0) { + throw 'AllowedEntraUserIds must contain at least one participant object ID.' +} +foreach ($participantId in $AllowedEntraUserIds) { + $parsedId = [guid]::Empty + if (-not [guid]::TryParse($participantId, [ref]$parsedId) -or $parsedId -eq [guid]::Empty) { + throw "Invalid participant object ID: $participantId" + } +} +$AllowedEntraUserIds = @($AllowedEntraUserIds | Sort-Object -Unique) + +foreach ($helper in @('Get-MhhStableHash', 'New-MhhStablePassword', 'Update-MhhToken')) { + Get-Command $helper -ErrorAction Stop | Out-Null +} +foreach ($module in @('Az.Accounts', 'Az.Resources', 'Az.Compute')) { + Import-Module $module -ErrorAction Stop +} +if ((Get-AzContext).Subscription.Id -ne $SubscriptionId) { + throw 'The authenticated subscription does not match SubscriptionId. Stop rather than deploy into another subscription.' +} +Update-MhhToken | Out-Null +$group = Get-AzResourceGroup -Name $ResourceGroupName +$groupTags = if ($null -eq $group.Tags) { @{} } else { $group.Tags } +$ResourceGroupName = $group.ResourceGroupName +$locations = @($PreferredLocation | ForEach-Object { $_ -split ',' } | + ForEach-Object { $_.Trim().Replace(' ', '').ToLowerInvariant() } | Where-Object { $_ } | + Select-Object -Unique) +if ($locations.Count -eq 0) { + $locations = @('swedencentral') +} +$labHash = Get-MhhStableHash -Value @($SubscriptionId.ToLowerInvariant(), $ResourceGroupName.ToLowerInvariant()) -Length 12 +if ($labHash -cnotmatch '^[a-f0-9]{12}$') { + throw 'The platform returned an invalid stable lab suffix.' +} +$labSuffix = $labHash.Substring(0, 8) +$hostName = "mh-$labSuffix-hv" +$deploymentName = "migration-$labSuffix" +$labVersion = 'single-rg-v1' +$hackName = '06_Migration_Secure_AI_Ready' +$resourceGroupId = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName" + +if (($groupTags['Microhack'] -and $groupTags['Microhack'] -ne $hackName) -or + ($groupTags['LabId'] -and $groupTags['LabId'] -ne $labSuffix)) { + throw 'The supplied RG belongs to a different lab. No resources were changed.' +} +$existingResources = @(Get-AzResource -ResourceGroupName $ResourceGroupName) +if ($existingResources.Count -gt 0 -and $groupTags['Microhack'] -ne $hackName) { + throw 'The supplied RG is not an empty platform scope or a recognized migration lab. It will not be adopted or cleared.' +} + +$deployments = @(Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName) +$deployment = $deployments | Where-Object DeploymentName -EQ $deploymentName | Select-Object -First 1 +$hostVm = $null +if ($existingResources.ResourceId -contains "$resourceGroupId/providers/Microsoft.Compute/virtualMachines/$hostName") { + $hostVm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $hostName +} +$baselineCompleted = $null -ne $hostVm -and $null -ne $hostVm.Tags -and $hostVm.Tags['LabReady'] -eq $labVersion +$alreadyReady = $baselineCompleted -and $groupTags['LabVerified'] -eq $labVersion +if ($baselineCompleted -and ($null -eq $deployment -or $deployment.ProvisioningState -in @('Failed', 'Canceled'))) { + throw 'A completed baseline exists without its successful deployment record. Inspect the lab; it will not be redeployed automatically.' +} +$recordedLocations = @( + if ($groupTags['LabRegion']) { $groupTags['LabRegion'] } + if ($null -ne $hostVm) { $hostVm.Location } + if ($null -ne $deployment -and $null -ne $deployment.Parameters) { $deployment.Parameters['location'].Value } +) | Where-Object { $_ } | ForEach-Object { $_.Replace(' ', '').ToLowerInvariant() } | Select-Object -Unique +$recordedLocations = @($recordedLocations) +if ($recordedLocations.Count -gt 1) { + throw 'The existing lab has conflicting resource regions. Inspect it; no resources will be moved or deleted.' +} +$location = if ($recordedLocations.Count -eq 1) { $recordedLocations[0] } else { $null } +if ($location -and $location -notin $locations) { + throw "The existing lab is pinned to $location. Include it in PreferredLocation; retries never move or delete a lab." +} +if (-not $location -and ($existingResources.Count -gt 0 -or $null -ne $deployment)) { + throw 'Cannot determine the existing lab region from its LabRegion tag, host, or deployment parameters. Inspect the lab before retrying.' +} +if (-not $alreadyReady -and $groupTags['LabVerified']) { + Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ LabVerified = 'pending' } | Out-Null +} + +$password = New-MhhStablePassword -Purpose 'migration-host-admin' -Length 24 +$submissionJob = $null +$previousCorrelationId = $null +if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningState -in @('Failed', 'Canceled'))) { + if ($null -ne $deployment) { + $previousCorrelationId = $deployment.CorrelationId + if ([string]::IsNullOrWhiteSpace($previousCorrelationId)) { + throw 'Cannot identify the previous deployment attempt. Inspect its correlation ID before retrying.' + } + } + $providers = @{} + foreach ($provider in @('Microsoft.Compute', 'Microsoft.Network', 'Microsoft.KeyVault', 'Microsoft.DevTestLab')) { + Update-MhhToken | Out-Null + $registration = Get-AzResourceProvider -ProviderNamespace $provider + if ($registration.RegistrationState -notcontains 'Registered') { + throw "$provider is not registered. Run the shared subscription preparation before deploying labs." + } + $providers[$provider] = $registration + } + + if ($null -ne $hostVm -and $hostVm.HardwareProfile.VmSize -ne 'Standard_D16s_v5') { + throw 'The existing host has an unexpected VM size. Inspect it instead of resizing an active lab automatically.' + } + $candidateLocations = if ($location) { @($location) } else { $locations } + $location = $null + # Developer SKU coverage is narrower than bastionHosts provider coverage. + # https://learn.microsoft.com/azure/bastion/bastion-sku-comparison#regional-availability + $bastionDeveloperLocations = @( + 'brazilsouth', 'canadacentral', 'canadaeast', 'centralus', 'centraluseuap', + 'eastus2', 'eastus2euap', 'mexicocentral', 'northcentralus', 'westcentralus', 'westus', + 'francecentral', 'germanywestcentral', 'italynorth', 'northeurope', 'norwayeast', + 'spaincentral', 'swedencentral', 'switzerlandnorth', 'uksouth', 'ukwest', 'westeurope', + 'australiacentral', 'australiaeast', 'australiasoutheast', 'centralindia', 'eastasia', + 'japaneast', 'japanwest', 'koreacentral', 'koreasouth', 'southindia', 'southeastasia', + 'southafricanorth', 'uaenorth' + ) + foreach ($candidate in $candidateLocations) { + Update-MhhToken | Out-Null + $reasons = [Collections.Generic.List[string]]::new() + if ($candidate -notin $bastionDeveloperLocations) { + $reasons.Add('Bastion Developer is not in the documented regional availability list') + } + foreach ($requiredType in @( + @{ Provider = 'Microsoft.Network'; Type = 'virtualNetworks' }, + @{ Provider = 'Microsoft.Network'; Type = 'natGateways' }, + @{ Provider = 'Microsoft.Network'; Type = 'bastionHosts' }, + @{ Provider = 'Microsoft.KeyVault'; Type = 'vaults' }, + @{ Provider = 'Microsoft.DevTestLab'; Type = 'schedules' } + )) { + $type = @($providers[$requiredType.Provider].ResourceTypes | + Where-Object ResourceTypeName -EQ $requiredType.Type) + $supported = @($type.Locations | Where-Object { $_ } | + ForEach-Object { $_.Replace(' ', '').ToLowerInvariant() }) + if ($candidate -notin $supported) { + $reasons.Add("$($requiredType.Provider)/$($requiredType.Type) is not advertised in this region") + } + } + $skus = @(Get-AzComputeResourceSku -Location $candidate) + foreach ($requiredSku in @( + @{ Type = 'virtualMachines'; Name = 'Standard_D16s_v5' }, + @{ Type = 'disks'; Name = 'PremiumV2_LRS' } + )) { + $sku = @($skus | Where-Object { $_.ResourceType -eq $requiredSku.Type -and $_.Name -eq $requiredSku.Name }) + $zones = @($sku.LocationInfo | Where-Object Location -EQ $candidate | ForEach-Object Zones) + $restricted = @($sku.Restrictions | Where-Object { + $restrictedLocations = @($_.RestrictionInfo.Locations | Where-Object { $_ }) + if ($restrictedLocations.Count -eq 0 -and $_.Type -eq 'Location') { + $restrictedLocations = @($_.Values | Where-Object { $_ }) + } + $appliesHere = $restrictedLocations.Count -eq 0 -or $restrictedLocations -contains $candidate + $appliesHere -and ($_.Type -eq 'Location' -or + ($_.Type -eq 'Zone' -and $_.RestrictionInfo.Zones -contains '1')) + }) + if ($sku.Count -ne 1 -or $zones -notcontains '1' -or $restricted.Count -gt 0) { + $reasons.Add("$($requiredSku.Name) in zone 1 is unavailable") + } + } + if ($null -eq $hostVm) { + $usage = @(Get-AzVMUsage -Location $candidate) + foreach ($quotaName in @('cores', 'standardDSv5Family')) { + $quota = $usage | Where-Object { $_.Name.Value -ieq $quotaName } | Select-Object -First 1 + if ($null -eq $quota -or ($quota.Limit - $quota.CurrentValue) -lt 16) { + $reasons.Add("at least 16 available $quotaName vCPUs are required") + } + } + } + if ($reasons.Count -eq 0) { + $location = $candidate + break + } + Write-Warning "Skipping ${candidate}: $($reasons -join '; ')." + } + if (-not $location) { + throw "No eligible deployment region among $($candidateLocations -join ', '). Resources are preserved; an existing lab cannot fall back to another region." + } + + $githubAccount = if ($env:MICROHACK_GITHUB_ACCOUNT) { $env:MICROHACK_GITHUB_ACCOUNT } else { 'microsoft' } + $githubBranch = if ($env:MICROHACK_GITHUB_BRANCH) { $env:MICROHACK_GITHUB_BRANCH } else { 'main' } + if ($githubAccount -notmatch '^[A-Za-z0-9][A-Za-z0-9-]*$' -or + $githubBranch -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $githubBranch.Contains('..')) { + throw 'Invalid MICROHACK_GITHUB_ACCOUNT or MICROHACK_GITHUB_BRANCH artifact source.' + } + $artifactUrl = "https://raw.githubusercontent.com/$githubAccount/MicroHack/$githubBranch/03-Azure/01-03-Infrastructure/$hackName/resources/artifacts/Bootstrap.ps1" + Invoke-WebRequest -Uri $artifactUrl -Method Head -UseBasicParsing -TimeoutSec 30 | Out-Null + Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ + Microhack = $hackName + LabId = $labSuffix + LabRegion = $location + } | Out-Null + Update-MhhToken | Out-Null + $deploymentParameters = @{ + Name = $deploymentName + ResourceGroupName = $ResourceGroupName + TemplateFile = Join-Path $PSScriptRoot 'bicep\main.bicep' + Mode = 'Incremental' + AsJob = $true + TemplateParameterObject = @{ + labSuffix = $labSuffix + participantObjectIds = $AllowedEntraUserIds + location = $location + windowsAdminPassword = ConvertTo-SecureString $password -AsPlainText -Force + githubAccount = $githubAccount + githubBranch = $githubBranch + } + } + Write-Host "Deploying $deploymentName into $ResourceGroupName ($location). Both VNets remain in this RG." + $submissionJob = New-AzResourceGroupDeployment @deploymentParameters + $deployment = $null +} elseif ($null -ne $deployment -and $deployment.ProvisioningState -ne 'Succeeded') { + Write-Host "Resuming ARM deployment '$deploymentName' in $location; no replacement deployment will be submitted." +} + +if ($null -eq $deployment -or $deployment.ProvisioningState -ne 'Succeeded') { + $armDeadline = (Get-Date).ToUniversalTime().AddMinutes(180) + do { + Update-MhhToken | Out-Null + try { + $deployment = Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -Name $deploymentName -ErrorAction Stop + } catch { + $notRegistered = $_.Exception.Response.StatusCode -eq 404 -or + $_.FullyQualifiedErrorId -match 'DeploymentNotFound|ResourceNotFound' -or + $_.ErrorDetails.Message -match '"code"\s*:\s*"(DeploymentNotFound|ResourceNotFound)"' + if (-not $notRegistered) { throw } + $deployment = $null + } + if ($null -ne $deployment -and $previousCorrelationId -and $deployment.CorrelationId -eq $previousCorrelationId) { + $deployment = $null + } + if ($null -ne $deployment) { + Write-Host "ARM deployment: $($deployment.ProvisioningState)" + if ($deployment.ProvisioningState -eq 'Succeeded') { break } + if ($deployment.ProvisioningState -in @('Failed', 'Canceled')) { + throw "ARM deployment ended as $($deployment.ProvisioningState). Inspect deployment operations; resources and the selected region are preserved." + } + } elseif ($null -ne $submissionJob -and $submissionJob.State -in @('Failed', 'Stopped')) { + throw "ARM submission failed before the deployment was registered: $($submissionJob.JobStateInfo.Reason)" + } + if ((Get-Date).ToUniversalTime() -ge $armDeadline) { + throw "ARM deployment '$deploymentName' timed out after 180 minutes. It may still be running; the RG is preserved and a rerun resumes polling." + } + Start-Sleep -Seconds 30 + } while ($true) +} + +if (-not $alreadyReady) { + $deadline = [DateTime]::UtcNow.AddMinutes(120) + do { + Update-MhhToken | Out-Null + $hostVm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $hostName + $progress = if ($null -eq $hostVm.Tags) { '' } else { $hostVm.Tags['DeploymentProgress'] } + Write-Host "Host provisioning: $progress" + if ($progress -eq 'Deployment Failed') { + throw "Host provisioning failed. Inspect C:\MHBox\Logs on $hostName; reruns never clear the lab RG." + } + $extension = Get-AzVMExtension -ResourceGroupName $ResourceGroupName -VMName $hostName -Name 'Bootstrap' -Status + if ($extension.ProvisioningState -eq 'Failed') { + throw "The Bootstrap extension failed on $hostName. Inspect its instance view and C:\MHBox\Logs." + } + if ($progress -eq 'Deployment Completed' -and $hostVm.Tags['LabReady'] -eq $labVersion) { + break + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for $hostName. Last progress: $progress. Resources are preserved for diagnosis." + } + Start-Sleep -Seconds 30 + } while ($true) + + $healthCheck = @' +$ErrorActionPreference = 'Stop' +$ready = Get-Content 'C:\MHBox\lab-ready.json' -Raw | ConvertFrom-Json +$expected = @('MHBox-AzMigSrv', 'MHBox-SQL', 'MHBox-Win2K22', 'MHBox-Ubuntu-01') +if ($ready.schemaVersion -ne 1 -or $ready.hostName -ine $env:COMPUTERNAME -or + $ready.windowsHttpStatus -ne 200 -or $ready.linuxHttpStatus -ne 200 -or + @(Compare-Object $expected @($ready.nestedVmNames)).Count -ne 0) { + throw 'The baseline readiness marker is invalid.' +} +foreach ($name in $expected) { + if ((Get-VM -Name $name -ErrorAction Stop).State -ne 'Running') { + throw "Nested VM is not running: $name" + } +} +'MICROHACK_BASELINE_READY_V1' +'@ + Update-MhhToken | Out-Null + $check = Invoke-AzVMRunCommand -ResourceGroupName $ResourceGroupName -VMName $hostName ` + -CommandId 'RunPowerShellScript' -ScriptString $healthCheck + $messages = @($check.Value | ForEach-Object Message) -join "`n" + if (($check.Value.Code -match '/failed$') -or + (($messages -split '\r?\n') -notcontains 'MICROHACK_BASELINE_READY_V1')) { + throw "Baseline readiness verification failed on $hostName. Inspect C:\MHBox\Logs; no credentials were published." + } + Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ LabVerified = $labVersion } | Out-Null +} else { + Write-Host 'Reusing the completed baseline without changing resources or restarting nested VMs. Later learner work is preserved.' +} + +$outputs = $deployment.Outputs +foreach ($name in @('hostVmName', 'keyVaultName', 'sourceVnetName', 'destinationVnetName', 'clientVmLogonUserName')) { + if ([string]::IsNullOrWhiteSpace($outputs[$name].Value)) { + throw "The deployment is missing the required output $name. Inspect the lab instead of guessing its resource names." + } +} +if ($outputs['hostVmName'].Value -ne $hostName) { + throw 'The deployment host output does not match the stable lab name.' +} + +@{ HackboxCredential = @{ name = 'Migration Lab Host'; value = $hostName; note = 'Connect through the source VNet Bastion.' } } +@{ HackboxCredential = @{ name = 'Migration Host Username'; value = $outputs['clientVmLogonUserName'].Value } } +@{ HackboxCredential = @{ name = 'Migration Host Password'; value = $password; note = 'Lab credential; do not copy into logs or source control.' } } +@{ HackboxCredential = @{ name = 'Migration Lab Key Vault'; value = $outputs['keyVaultName'].Value } } +@{ HackboxCredential = @{ name = 'Migration Source VNet'; value = $outputs['sourceVnetName'].Value } } +@{ HackboxCredential = @{ name = 'Migration Destination VNet'; value = $outputs['destinationVnetName'].Value } } +@{ HackboxCredential = @{ name = 'Migration Lab Region'; value = $location } } diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/lab-defaults.json b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/lab-defaults.json new file mode 100644 index 000000000..5887d6175 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/lab-defaults.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json", + "groups": [], + "deploymentType": "resourcegroup", + "labsPerSubscription": 2, + "preferredLocation": "swedencentral", + "estimatedDailyCostsUsd": 80.0 +} diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/shared-deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/shared-deploy-lab.ps1 new file mode 100644 index 000000000..88a6032a0 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/shared-deploy-lab.ps1 @@ -0,0 +1,51 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [string[]]$PreferredLocation = @(), + + [string[]]$AllowedEntraUserIds = @() +) + +$ErrorActionPreference = 'Stop' +if ((Get-AzContext).Subscription.Id -ne $SubscriptionId) { + throw 'The authenticated subscription does not match SubscriptionId.' +} + +$providers = @( + 'Microsoft.Compute', 'Microsoft.Network', 'Microsoft.KeyVault', + 'Microsoft.Storage', 'Microsoft.DevTestLab', 'Microsoft.ManagedIdentity', + 'Microsoft.Migrate', 'Microsoft.OffAzure', 'Microsoft.RecoveryServices', + 'Microsoft.Insights', 'Microsoft.OperationalInsights', 'Microsoft.Monitor', + 'Microsoft.AlertsManagement', 'Microsoft.Web', 'Microsoft.Security' +) + +foreach ($provider in $providers) { + Update-MhhToken | Out-Null + $registration = Get-AzResourceProvider -ProviderNamespace $provider + if ($registration.RegistrationState -notcontains 'Registered') { + Register-AzResourceProvider -ProviderNamespace $provider | Out-Null + } +} + +$deadline = [DateTime]::UtcNow.AddMinutes(10) +do { + Update-MhhToken | Out-Null + $pending = @( + foreach ($provider in $providers) { + $registration = Get-AzResourceProvider -ProviderNamespace $provider + if ($registration.RegistrationState -notcontains 'Registered') { + $provider + } + } + ) + if ($pending.Count -eq 0) { + Write-Host 'Migration lab resource providers are registered. No subscription-wide Defender plans were changed.' + return + } + Write-Host "Waiting for provider registration: $($pending -join ', ')" + Start-Sleep -Seconds 10 +} while ([DateTime]::UtcNow -lt $deadline) + +throw "Provider registration timed out: $($pending -join ', ')." diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index a3ccbc5f2..71811e140 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -1,9 +1,77 @@ -# Deploy the Landing Zone for the Hack +# Instructor deployment and validation -Download the \*.bicep files to your local PC and follow [Solution 1](../walkthrough/challenge-01/solution-01.md). +Learners start with a ready environment and follow [Solution 1](../walkthrough/challenge-01/solution-01.md) to verify it. They do not deploy the landing zone themselves. -The deployment defaults to downloading automation and demo-page assets from `microsoft/MicroHack` on the `main` branch. To test a fork before merge, check out that fork branch locally and pass the existing `githubAccount` and `githubBranch` parameters as described in Solution 1. The selected branch must contain the same Hack resource paths. +## Hosted deployment +The platform reads [lab-defaults.json](../labautomation/lab-defaults.json): + +| Default | Value | +| --- | --- | +| `groups` | `[]` | +| `deploymentType` | `resourcegroup` | +| `labsPerSubscription` | `2` | +| `preferredLocation` | `swedencentral` | +| `estimatedDailyCostsUsd` | `80.0` | + +The USD 80 daily value is a **conservative planning estimate for a running baseline, not a quote**. Check current Azure pricing, quota, learner count, and event duration before the event; later migration, monitoring, and App Service exercises can add charges. + +The platform first runs [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) **once per subscription** to register required resource providers. This hook creates no shared resource group and makes no Defender plan changes. It then invokes [deploy-lab.ps1](../labautomation/deploy-lab.ps1) once per learner. The per-learner entry point accepts exactly these platform parameters: + +| Parameter | Type | Meaning | +| --- | --- | --- | +| `DeploymentType` | `string` | `resourcegroup` for this Hack | +| `SubscriptionId` | `string` | The learner's assigned subscription | +| `ResourceGroupName` | `string` | The single, already-created learner resource group | +| `PreferredLocation` | `string[]` | Ordered deployment regions; default `swedencentral`. The RG's metadata location does not restrict its resources' region. | +| `AllowedEntraUserIds` | `string[]` | Learner Entra object IDs requiring lab access | + +Before per-learner invocation, the platform creates the resource group, authenticates Az PowerShell and Azure CLI, selects the subscription, and assigns the learner **Owner** on the resource group and **Reader** on the subscription. The hosted script neither signs in nor creates another resource group. Instructors must verify that the shared provider-registration hook succeeded, quota is sufficient, and any optional Defender plans were separately approved and enabled; do not grant subscription Owner to every learner. + +[main.bicep](../labautomation/bicep/main.bicep) and its local modules are packaged together under `labautomation/bicep`, so importing or mounting only `labautomation` is sufficient for template deployment. The guest automation and demo-page assets remain under `resources/artifacts` and are downloaded from the selected repository/branch. + +The template deploys at **resource-group scope** into the existing group. For a fresh lab, the deployer tries `PreferredLocation` in order and warns when skipping a region. Preflight checks provider-advertised service regions, the documented Bastion Developer regional list, zone-1 availability of `Standard_D16s_v5` and `PremiumV2_LRS`, and available regional/DSv5 quota for a new host. These checks do not reserve capacity or guarantee that a later ARM deployment will succeed. With only the default `swedencentral` configured, there is no second candidate; instructors can configure an ordered list of suitable regions before deployment. + +The selected region is recorded as **LabRegion** on the RG before submission. Its metadata location stays unchanged, even if resources deploy in a different preferred region. Existing labs retain their recorded region, checked against the host location and deployment parameters. A retry never moves partial resources to another region, deletes the RG, or changes the lab names. Include the recorded region in `PreferredLocation` when rerunning an existing lab; resolve inconsistent or missing region records before retrying. + +All user-controlled resources for the Hack stay in that one group: the source VNet (`10.1.0.0/16`), destination VNet (`10.2.0.0/16`), NAT Gateways, Bastion access, Key Vault, and Hyper-V host. + +Names use the first eight characters of a deterministic 12-character platform hash of the subscription and resource-group scope, not the deployer's UPN. Requesting 12 characters respects `Get-MhhStableHash`'s documented 12–64 range while retaining the eight-character naming suffix: + +| Resource | Name | +| --- | --- | +| Hyper-V host | `mh--hv` (14 characters) | +| Source VNet | `mh--Source-VNet` | +| Destination VNet | `mh--destination-VNet` | +| Key Vault | `mhkv` followed by `uniqueString(resourceGroup().id)` | + +The deployer passes the Bicep parameters `labSuffix` and `participantObjectIds`. The main template exposes `hostVmName`, `keyVaultName`, `sourceVnetName`, `destinationVnetName`, and `clientVmLogonUserName` so automation can discover the actual deployed names. + +Fresh predeployment prepares only Challenge 1, including the four running nested guests `MHBox-AzMigSrv`, `MHBox-SQL`, `MHBox-Win2K22`, and `MHBox-Ubuntu-01`. It does not configure discovery, migration, monitoring, or App Service. Bootstrap publishes the schema-version-1 ready marker at `C:\MHBox\lab-ready.json` and sets the host VM tags `LabReady=single-rg-v1` and `DeploymentProgress=Deployment Completed`. Before the initial handoff, the deployer waits for completion and independently checks the marker and four running guests; the marker records Bootstrap's HTTP/metadata checks of both dashboards. Only after independent verification succeeds does the deployer set `LabVerified=single-rg-v1` on the resource group. Failed verification is retried rather than treated as a completed handoff. + +Allow **35–90+ minutes** for a fresh environment. ARM is submitted asynchronously and polled every 30 seconds with token refresh, bounded to **180 minutes per invocation**. An already-running deployment is polled rather than resubmitted. After ARM succeeds, the separate guest-readiness wait is bounded to **120 minutes**. ARM failure/cancellation, submission errors, and timeouts stop the script without publishing credentials. A timeout does not cancel ARM; resources are preserved, and a rerun resumes polling. Inspect deployment/provisioning diagnostics before retrying; no deployment failure triggers destructive cleanup or cross-region recreation. An ARM deployment success alone does not prove guest readiness. + +**Completed reruns preserve learner progress:** the deployer reuses an already-completed baseline without restarting the nested guests or requiring them to be running. A learner may have legitimately stopped source guests during migration. Only incomplete deployments receive an incremental deployment retry; rerunning is not a request to reset the lab. + +The platform dashboard provides the lab resource group, connection details, and login credential fields. The Key Vault retains the host login username and generated password. Keep those credentials private; do not paste generated passwords into deployment commands, shared notes, or screenshots. + +## Manual/local validation for instructors + +Use the current standard template's [local testing harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing), not an arbitrary PowerShell session. That harness supplies the platform helper stand-ins through the `ghcr.io/qxsch/adminpwsh:latest` container. Mount this Hack's `labautomation` directory, including its `bicep` subtree, at `/app` as shown by the harness. No sibling repository directories are required. The pinned Azure Verified Module for Key Vault must still be restored by Bicep, and downloaded guest artifacts must be reachable. + +1. Follow the harness's container setup and authenticate **both** Az PowerShell and Azure CLI locally; select the same intended instructor-owned test subscription in both contexts. Hosted runs already have those contexts. +2. Follow [testing the shared hook locally](../../../../99-MicroHack-Template/labautomation/README.md#testing-the-shared-hook-locally): from this Hack's `labautomation` directory, run `shared-deploy-lab.ps1` once for the test subscription before the per-learner script, using that subscription's preferred regions and learner object IDs. The instructor test identity needs permission to register providers; learner subscription Reader is intentionally insufficient. +3. Create a fresh, uniquely named test resource group before the per-learner invocation, normally in the first preferred region, and grant the test learner RG Owner and subscription Reader to reproduce learner access. Unlike hosted execution, local preparation is your responsibility. If a test group already exists, inspect it or choose a new name; do not delete a group as a retry shortcut. +4. Inside the harness, invoke `deploy-lab.ps1` from this Hack's `labautomation` directory with all five parameters from the table above, using `DeploymentType` = `resourcegroup`, the existing test resource group, ordered region array, and learner object-ID array. Confirm that the reported selected region and RG **LabRegion** tag match the resources, not necessarily the RG's metadata location. Follow the standard harness invocation syntax and helper limitations. +5. Verify the returned credential fields (the platform dashboard shows these for hosted runs) and Key Vault access as the test learner, then follow Solution 1's **initial-handoff** readiness checks. Also verify that a completed rerun preserves learner state and does not restart migrated source guests. Syntax/build checks alone are not evidence of a live deployment. + +The official source remains `microsoft/MicroHack` on `main`. For premerge fork testing, check out the fork's feature branch locally **and** set `MICROHACK_GITHUB_ACCOUNT` and `MICROHACK_GITHUB_BRANCH` in the harness/deployment environment to that owner and branch before invocation. These environment variables default to `microsoft` and `main` and select downloaded automation and demo-page assets via the existing Bicep `githubAccount` and `githubBranch` parameters. The local checkout and asset reference must match, and the branch must retain the Hack resource paths. Do not change the official repository identifiers in documentation or templates just to test a fork. + +## Resource lifecycle + +Keep the single lab resource group throughout Challenges 2–8, including the learner-created Azure Migrate, migrated VMs, monitoring resources, and App Service resources. Azure Monitor can create an Azure-managed `MA_*_managed` group for workspace internals; that service-managed exception is not another learner-created lab group. + +Only after the lab owner approves the finish should you clean up. Use Azure Migrate's supported test-migration cleanup and complete-migration/stop-replication actions, and remove backup protection or retained recovery data through the relevant service's supported cleanup. Backup or replication dependencies can block resource-group deletion. Deleting the lab resource group removes **both** the simulated source and migrated resources, plus monitoring and App Service; it is not a way to remove just the source environment. ## Quota information if multiple users deploy to the same subscription diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 index 83c14bb24..de6fd4d85 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 @@ -4,6 +4,12 @@ param ( [string]$spnAuthority, [string]$subscriptionId, [string]$resourceGroup, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$hostVmName, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$keyVaultName, [string]$acceptEula, [string]$registryUsername, [string]$azureLocation, @@ -19,10 +25,34 @@ param ( [string]$autoShutdownEnabled ) +$ErrorActionPreference = 'Stop' + +trap { + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + Remove-Item -LiteralPath 'C:\MHBox\lab-ready.json' -Force -ErrorAction SilentlyContinue + try { + if (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue) { + Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId -ErrorAction Stop | Out-Null + $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + try { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null + } + catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } + } + } + } + catch { Write-Warning "Could not authenticate to report bootstrap failure: $($_.Exception.Message)" } + exit 1 +} + [System.Environment]::SetEnvironmentVariable('adminUsername', $adminUsername, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('spnAuthority', $spnAuthority, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('tenantId', $tenantId, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('resourceGroup', $resourceGroup, [System.EnvironmentVariableTarget]::Machine) +[System.Environment]::SetEnvironmentVariable('hostVmName', $hostVmName, [System.EnvironmentVariableTarget]::Machine) +[System.Environment]::SetEnvironmentVariable('keyVaultName', $keyVaultName, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('ACCEPT_EULA', $acceptEula, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('registryUsername', $registryUsername, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('subscriptionId', $subscriptionId, [System.EnvironmentVariableTarget]::Machine) @@ -36,20 +66,42 @@ param ( [System.Environment]::SetEnvironmentVariable('sqlServerEdition', $sqlServerEdition, [System.EnvironmentVariableTarget]::Machine) [System.Environment]::SetEnvironmentVariable('autoShutdownEnabled', $autoShutdownEnabled, [System.EnvironmentVariableTarget]::Machine) -if ($debugEnabled -eq 'true') { - [System.Environment]::SetEnvironmentVariable('ErrorActionPreference', 'Break', [System.EnvironmentVariableTarget]::Machine) -} -else { - [System.Environment]::SetEnvironmentVariable('ErrorActionPreference', 'Continue', [System.EnvironmentVariableTarget]::Machine) -} +[System.Environment]::SetEnvironmentVariable('ErrorActionPreference', 'Stop', [System.EnvironmentVariableTarget]::Machine) -# Formatting VMs disk -$disk = (Get-Disk | Where-Object partitionstyle -eq 'raw')[0] -$driveLetter = 'F' -$label = 'VMsDisk' -$disk | Initialize-Disk -PartitionStyle MBR -PassThru | ` - New-Partition -UseMaximumSize -DriveLetter $driveLetter | ` - Format-Volume -FileSystem NTFS -NewFileSystemLabel $label -Confirm:$false -Force +function Initialize-LabDataDisk { + # This host contract is OS + one data disk at LUN 0; never guess among RAW disks. + $dataDisks = @(Get-Disk | Where-Object { -not $_.IsBoot -and -not $_.IsSystem }) + if ($dataDisks.Count -ne 1) { + throw "Expected exactly one non-system data disk; found $($dataDisks.Count). No disk was changed." + } + $disk = $dataDisks[0] + $scsiDisks = @(Get-CimInstance -ClassName Win32_DiskDrive -Filter "Index = $($disk.Number)") + if ($scsiDisks.Count -ne 1 -or $null -eq $scsiDisks[0].SCSILogicalUnit -or $scsiDisks[0].SCSILogicalUnit -ne 0) { + throw "Cannot identify disk $($disk.Number) as the expected LUN 0 data disk. No disk was changed." + } + + $volumes = @(Get-Volume | Where-Object DriveLetter -eq 'F') + if ($volumes.Count -gt 0) { + $partitions = @(Get-Partition -DiskNumber $disk.Number | Where-Object DriveLetter -eq 'F') + if ($volumes.Count -ne 1 -or $partitions.Count -ne 1 -or + $volumes[0].FileSystemLabel -ne 'VMsDisk' -or $volumes[0].FileSystem -ne 'NTFS') { + throw 'F: is not the expected NTFS VMsDisk volume on the LUN 0 data disk. No disk was changed.' + } + return + } + if ($disk.PartitionStyle -ne 'RAW') { + throw "Expected an uninitialized LUN 0 data disk or an existing F: VMsDisk volume. Disk $($disk.Number) will not be formatted." + } + $disk | Initialize-Disk -PartitionStyle MBR -PassThru | + New-Partition -UseMaximumSize -DriveLetter F | + Format-Volume -FileSystem NTFS -NewFileSystemLabel 'VMsDisk' -Confirm:$false -Force | + Out-Null + $volume = Get-Volume -DriveLetter F + if ($volume.FileSystemLabel -ne 'VMsDisk' -or $volume.FileSystem -ne 'NTFS' -or + (Get-Partition -DriveLetter F).DiskNumber -ne $disk.Number) { + throw 'The initialized data disk did not produce the expected F: VMsDisk volume.' + } +} # Creating MHBox path Write-Output 'Creating MHBox path' @@ -63,11 +115,15 @@ $Env:MHBoxDemoPageDir = "$Env:MHBoxDir\DemoPage" New-Item -Path $Env:MHBoxDir -ItemType Directory -Force | Out-Null New-Item -Path $Env:MHBoxDscDir -ItemType Directory -Force | Out-Null New-Item -Path $Env:MHBoxLogsDir -ItemType Directory -Force | Out-Null -New-Item -Path $Env:MHBoxVMDir -ItemType Directory -Force | Out-Null New-Item -Path $Env:tempDir -ItemType Directory -Force | Out-Null New-Item -Path $Env:MHBoxDemoPageDir -ItemType Directory -Force | Out-Null Start-Transcript -Path "$Env:MHBoxLogsDir\Bootstrap.log" +if (Test-Path -LiteralPath "$Env:MHBoxDir\lab-ready.json") { + Remove-Item -LiteralPath "$Env:MHBoxDir\lab-ready.json" -Force +} +Initialize-LabDataDisk +New-Item -Path $Env:MHBoxVMDir -ItemType Directory -Force | Out-Null Write-Host "Script path: $PSCommandPath" Write-Host "Script dir : $PSScriptRoot" @@ -85,7 +141,10 @@ Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' ' # Extending C:\ partition to the maximum size Write-Host 'Extending C:\ partition to the maximum size' -Resize-Partition -DriveLetter C -Size $(Get-PartitionSupportedSize -DriveLetter C).SizeMax +$maximumSystemPartitionSize = (Get-PartitionSupportedSize -DriveLetter C).SizeMax +if ((Get-Partition -DriveLetter C).Size -lt $maximumSystemPartitionSize) { + Resize-Partition -DriveLetter C -Size $maximumSystemPartitionSize +} # Installing PowerShell Modules Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force @@ -102,6 +161,8 @@ param( [string]$tenantId, [string]$subscriptionId, [string]$resourceGroup, + [string]$hostVmName, + [string]$keyVaultName, [string]$adminUsername, [string]$vmAutologon, [string]$autoShutdownEnabled, @@ -116,24 +177,44 @@ try { Import-Module Az.Resources -Force Import-Module Az.KeyVault -Force - Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId | Out-Null - Set-AzContext -Subscription $subscriptionId | Out-Null - - $DeploymentProgressString = 'Started bootstrap-script...' - - $tags = Get-AzResourceGroup -Name $resourceGroup | Select-Object -ExpandProperty Tags - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $DeploymentProgressString - } else { - $tags = @{ 'DeploymentProgress' = $DeploymentProgressString } + $initializationTimer = [Diagnostics.Stopwatch]::StartNew() + function Invoke-InitialAzureOperation { + param([scriptblock]$Operation, [string]$Description) + while ($true) { + try { return & $Operation } + catch { + $reason = $_.Exception.Message + $statusCode = $_.Exception.Response.StatusCode + $retryable = [string]$statusCode -match '^(401|403|408|429|500|502|503|504|Unauthorized|Forbidden|TooManyRequests|ServiceUnavailable)$' -or + $reason -match '(?i)\b(401|403|408|429|500|502|503|504|Forbidden|ForbiddenByRbac|AuthorizationFailed)\b|role assignment|RBAC|managed identity.*(unavailable|not found)|identity not found|identity.*endpoint|IMDS|temporarily unavailable|timed out' + if (-not $retryable) { throw } + $remaining = 300 - $initializationTimer.Elapsed.TotalSeconds + if ($remaining -le 0) { + throw "Timed out after 300 seconds waiting for $Description. Last error: $reason" + } + Write-Warning "Waiting for $Description (identity/RBAC propagation): $reason" + Start-Sleep -Seconds ([Math]::Min(10, [Math]::Ceiling($remaining))) + if ($initializationTimer.Elapsed.TotalSeconds -ge 300) { + throw "Timed out after 300 seconds waiting for $Description. Last error: $reason" + } + } + } } - $null = Set-AzResourceGroup -ResourceGroupName $resourceGroup -Tag $tags - - $KeyVault = Get-AzKeyVault -ResourceGroupName $resourceGroup - [System.Environment]::SetEnvironmentVariable('keyVaultName', $KeyVault.VaultName, [System.EnvironmentVariableTarget]::Machine) - - $adminPassword = Get-AzKeyVaultSecret -VaultName $KeyVault.VaultName -Name 'windowsAdminPassword' -AsPlainText + Invoke-InitialAzureOperation -Description 'managed identity authentication' -Operation { + Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId | Out-Null + Set-AzContext -Subscription $subscriptionId | Out-Null + } + $adminPassword = Invoke-InitialAzureOperation -Description "secret access to vault '$keyVaultName'" -Operation { + Get-AzKeyVaultSecret -VaultName $keyVaultName -Name 'windowsAdminPassword' -AsPlainText + } + if ([string]::IsNullOrWhiteSpace($adminPassword)) { throw 'The Windows administrator secret is empty.' } + $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + Invoke-InitialAzureOperation -Description "progress tag access to '$target'" -Operation { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Started bootstrap-script...' } | Out-Null + } + } if ($vmAutologon -eq 'true') { Write-Host 'Configuring VM Autologon' @@ -145,22 +226,37 @@ try { } if ($autoShutdownEnabled -eq 'true') { - $ScheduleResource = Get-AzResource -ResourceGroup $resourceGroup -ResourceType Microsoft.DevTestLab/schedules - $Uri = "https://management.azure.com$($ScheduleResource.ResourceId)?api-version=2018-09-15" + $scheduleId = "$groupId/providers/Microsoft.DevTestLab/schedules/shutdown-computevm-$hostVmName" + $Uri = "https://management.azure.com${scheduleId}?api-version=2018-09-15" $Schedule = Invoke-AzRestMethod -Uri $Uri + if ($Schedule.StatusCode -ne 200) { throw "Reading shutdown schedule failed: HTTP $($Schedule.StatusCode). $($Schedule.Content)" } $ScheduleSettings = $Schedule.Content | ConvertFrom-Json $ScheduleSettings.properties.status = 'Disabled' - Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) | Out-Null + $result = Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) + if ($result.StatusCode -notin @(200, 201, 202)) { throw "Disabling shutdown schedule failed: HTTP $($result.StatusCode). $($result.Content)" } } Stop-Transcript exit 0 } catch { - Write-Error $_ - Stop-Transcript + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + try { + if (Get-AzContext -ErrorAction Stop) { + $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + try { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null + } + catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } + } + } + } + catch { Write-Warning "Could not report bootstrap initialization failure: $($_.Exception.Message)" } + Stop-Transcript -ErrorAction SilentlyContinue exit 1 } '@ @@ -175,6 +271,8 @@ $azInitScript | Set-Content -Path $azInitScriptPath -Encoding UTF8 -tenantId $tenantId ` -subscriptionId $subscriptionId ` -resourceGroup $resourceGroup ` + -hostVmName $hostVmName ` + -keyVaultName $keyVaultName ` -adminUsername $adminUsername ` -vmAutologon $vmAutologon ` -autoShutdownEnabled $autoShutdownEnabled ` @@ -196,7 +294,8 @@ $latestVersion = (Invoke-WebRequest -UseBasicParsing -Uri $url).Content | $downloadUrl = "https://github.com/PowerShell/PowerShell/releases/download/$latestVersion/PowerShell-$($latestVersion.Substring(1,5))-win-x64.msi" Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile .\PowerShell7.msi -Start-Process msiexec.exe -Wait -ArgumentList '/I PowerShell7.msi /quiet ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1 ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1 ENABLE_PSREMOTING=1 REGISTER_MANIFEST=1 USE_MU=1 ENABLE_MU=1 ADD_PATH=1' +$installation = Start-Process msiexec.exe -Wait -PassThru -ArgumentList '/I PowerShell7.msi /quiet ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=1 ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=1 ENABLE_PSREMOTING=1 REGISTER_MANIFEST=1 USE_MU=1 ENABLE_MU=1 ADD_PATH=1' +if ($installation.ExitCode -notin @(0, 3010)) { throw "PowerShell installation failed with exit code $($installation.ExitCode)." } Remove-Item .\PowerShell7.msi -Force Write-Host "`n### Fetching GitHub Artifacts ###`n" @@ -279,6 +378,7 @@ if (($rdpPort -ne $null) -and ($rdpPort -ne '') -and ($rdpPort -ne '3389')) { $systemroot = Get-Content env:systemroot netsh advfirewall firewall add rule name='Remote Desktop - Custom Port' dir=in program=$systemroot\system32\svchost.exe service=termservice action=allow protocol=TCP localport=$rdpPort enable=yes | Out-Null } + if ($LASTEXITCODE -ne 0) { throw "RDP firewall configuration failed with exit code $LASTEXITCODE." } Write-Host 'RDP port configuration complete.' } @@ -328,6 +428,7 @@ param( [string]$tenantId, [string]$subscriptionId, [string]$resourceGroup, + [string]$hostVmName, [string]$deploymentProgressString, [string]$logPath ) @@ -342,21 +443,30 @@ try { Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId | Out-Null Set-AzContext -Subscription $subscriptionId | Out-Null - $tags = Get-AzResourceGroup -Name $resourceGroup | Select-Object -ExpandProperty Tags - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $deploymentProgressString - } else { - $tags = @{ 'DeploymentProgress' = $deploymentProgressString } + $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = $deploymentProgressString } | Out-Null } - $null = Set-AzResourceGroup -ResourceGroupName $resourceGroup -Tag $tags - Stop-Transcript exit 0 } catch { - Write-Error $_ - Stop-Transcript + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + try { + if (Get-AzContext -ErrorAction Stop) { + $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + try { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null + } + catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } + } + } + } + catch { Write-Warning "Could not report bootstrap progress failure: $($_.Exception.Message)" } + Stop-Transcript -ErrorAction SilentlyContinue exit 1 } '@ @@ -371,6 +481,7 @@ $azProgressScript | Set-Content -Path $azProgressScriptPath -Encoding UTF8 -tenantId $tenantId ` -subscriptionId $subscriptionId ` -resourceGroup $resourceGroup ` + -hostVmName $hostVmName ` -deploymentProgressString $DeploymentProgressString ` -logPath $azProgressLogPath diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/MHServersLogonScript.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/MHServersLogonScript.ps1 index a7316a790..c594c5798 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/MHServersLogonScript.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/MHServersLogonScript.ps1 @@ -1,14 +1,54 @@ -$ErrorActionPreference = $env:ErrorActionPreference +$ErrorActionPreference = 'Stop' + +trap { + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + Remove-Item -LiteralPath 'C:\MHBox\lab-ready.json' -Force -ErrorAction SilentlyContinue + try { + Connect-AzAccount -Identity -Tenant $env:tenantId -Subscription $env:subscriptionId -ErrorAction Stop | Out-Null + $groupId = "/subscriptions/$env:subscriptionId/resourceGroups/$env:resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$env:hostVmName")) { + try { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null + } + catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } + } + } + catch { Write-Warning "Could not authenticate to report guest provisioning failure: $($_.Exception.Message)" } + exit 1 +} $Env:MHBoxDir = 'C:\MHBox' $Env:MHBoxLogsDir = "$Env:MHBoxDir\Logs" $Env:MHBoxVMDir = 'F:\Virtual Machines' $Env:MHBoxDscDir = "$Env:MHBoxDir\DSC" +# Archive the previous attempt and start logging before validating host configuration. +$logFilePath = "$Env:MHBoxLogsDir\MHServersLogonScript.log" +if (Test-Path $logFilePath) { + $archivefile = "$Env:MHBoxLogsDir\MHServersLogonScript-" + (Get-Date -Format 'yyyyMMddHHmmss') + Rename-Item -Path $logFilePath -NewName $archivefile -Force +} +Start-Transcript -Path $logFilePath -Force + # Set variables to execute remote powershell scripts on guest VMs $tenantId = $env:tenantId $subscriptionId = $env:subscriptionId $namingPrefix = $env:namingPrefix +if (Test-Path -LiteralPath "$Env:MHBoxDir\lab-ready.json") { + Remove-Item -LiteralPath "$Env:MHBoxDir\lab-ready.json" -Force +} +if ([string]::IsNullOrWhiteSpace($env:hostVmName)) { throw 'The hostVmName machine environment variable is required.' } +if ($namingPrefix -ne 'MHBox') { throw 'The single-RG baseline requires the nested VM prefix MHBox.' } +$expectedVmNames = @("$namingPrefix-SQL", "$namingPrefix-Win2K22", "$namingPrefix-Ubuntu-01", "$namingPrefix-AzMigSrv") + +function Set-LabDeploymentTags { + param([hashtable]$Tags) + $groupId = "/subscriptions/$env:subscriptionId/resourceGroups/$env:resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$env:hostVmName")) { + Update-AzTag -ResourceId $target -Operation Merge -Tag $Tags | Out-Null + } +} $templateBaseUrl = [Environment]::GetEnvironmentVariable( 'templateBaseUrl', [EnvironmentVariableTarget]::Machine @@ -603,26 +643,14 @@ function Wait-LinuxHostName { # Moved VHD storage account details here to keep only in place to prevent duplicates. $vhdSourceFolder = 'https://jumpstartprodsg.blob.core.windows.net/arcbox/prod/*' -# Archive existing log file and create new one -$logFilePath = "$Env:MHBoxLogsDir\MHServersLogonScript.log" -if (Test-Path $logFilePath) { - $archivefile = "$Env:MHBoxLogsDir\MHServersLogonScript-" + (Get-Date -Format 'yyyyMMddHHmmss') - Rename-Item -Path $logFilePath -NewName $archivefile -Force -} - -Start-Transcript -Path $logFilePath -Force -ErrorAction SilentlyContinue - # Remove registry keys that are used to automatically logon the user (only used for first-time setup) $registryPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' $keys = @('AutoAdminLogon', 'DefaultUserName', 'DefaultPassword') foreach ($key in $keys) { - try { - $property = Get-ItemProperty -Path $registryPath -Name $key -ErrorAction Stop + if ((Get-Item -Path $registryPath).Property -contains $key) { Remove-ItemProperty -Path $registryPath -Name $key Write-Host "Removed registry key that are used to automatically logon the user: $key" - } catch { - Write-Verbose "Key $key does not exist." } } @@ -697,35 +725,37 @@ if ($Env:flavor -eq 'ITPro') { $cliDir = New-Item -Path "$Env:MHBoxDir\.cli\" -Name '.servers' -ItemType Directory -Force if (-not $($cliDir.Parent.Attributes.HasFlag([System.IO.FileAttributes]::Hidden))) { - $folder = Get-Item $cliDir.Parent.FullName -ErrorAction SilentlyContinue + $folder = Get-Item $cliDir.Parent.FullName $folder.Attributes += [System.IO.FileAttributes]::Hidden } # Required for CLI commands Write-Host "`n### Az CLI Login ###`n" az login --identity + if ($LASTEXITCODE -ne 0) { throw "Azure CLI identity login failed with exit code $LASTEXITCODE." } az account set -s $subscriptionId + if ($LASTEXITCODE -ne 0) { throw "Azure CLI subscription selection failed with exit code $LASTEXITCODE." } Write-Host "`n### Az PowerShell Login ###`n" Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId $DeploymentProgressString = 'Started MHServersLogonScript' - $tags = Get-AzResourceGroup -Name $env:resourceGroup | Select-Object -ExpandProperty Tags + Set-LabDeploymentTags @{ DeploymentProgress = $DeploymentProgressString } - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $DeploymentProgressString - } else { - $tags = @{'DeploymentProgress' = $DeploymentProgressString } + $existingVMDisk = Get-AzDisk -ResourceGroupName $env:resourceGroup -DiskName "$env:hostVmName-VMsDisk" + $originalDiskIops = $existingVMDisk.DiskIOPSReadWrite + $originalDiskThroughput = $existingVMDisk.DiskMBpsReadWrite + if ($null -eq $originalDiskIops -or $originalDiskIops -le 0 -or + $null -eq $originalDiskThroughput -or $originalDiskThroughput -le 0) { + throw 'Cannot temporarily boost VMsDisk without valid original IOPS and throughput.' } - $null = Set-AzResourceGroup -ResourceGroupName $env:resourceGroup -Tag $tags - $null = Set-AzResource -ResourceName $env:computername -ResourceGroupName $env:resourceGroup -ResourceType 'microsoft.compute/virtualmachines' -Tag $tags -Force - - $existingVMDisk = Get-AzDisk -ResourceGroupName $env:resourceGroup | Where-Object name -Like *VMsDisk - + $diskProvisioningError = $null + try { # Update disk IOPS and throughput before downloading nested VMs az disk update --resource-group $env:resourceGroup --name $existingVMDisk.Name --disk-iops-read-write 80000 --disk-mbps-read-write 1200 + if ($LASTEXITCODE -ne 0) { throw "Increasing VMsDisk performance failed with exit code $LASTEXITCODE." } ############################## ### SKIPP nested SQL VM @@ -741,16 +771,7 @@ if ($Env:flavor -eq 'ITPro') { $DeploymentProgressString = 'Downloading and configuring nested SQL VM' - $tags = Get-AzResourceGroup -Name $env:resourceGroup | Select-Object -ExpandProperty Tags - - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $DeploymentProgressString - } else { - $tags = @{'DeploymentProgress' = $DeploymentProgressString } - } - - $null = Set-AzResourceGroup -ResourceGroupName $env:resourceGroup -Tag $tags - $null = Set-AzResource -ResourceName $env:computername -ResourceGroupName $env:resourceGroup -ResourceType 'microsoft.compute/virtualmachines' -Tag $tags -Force + Set-LabDeploymentTags @{ DeploymentProgress = $DeploymentProgressString } Write-Host 'Fetching SQL VM' $SQLvmName = "$namingPrefix-SQL" @@ -760,6 +781,7 @@ if ($Env:flavor -eq 'ITPro') { if (!(Test-Path $SQLvmvhdPath)) { Write-Output 'Downloading nested VMs VHDX file for SQL. This can take some time, hold tight...' azcopy cp $vhdSourceFolder $Env:MHBoxVMDir --include-pattern "$vhdImageToDownload" --recursive=true --check-length=false --log-level=ERROR + if ($LASTEXITCODE -ne 0) { throw "Downloading the SQL VHD failed with exit code $LASTEXITCODE." } # Rename VHD file Rename-Item -Path "$Env:MHBoxVMDir\$vhdImageToDownload" -NewName $SQLvmvhdPath -Force @@ -772,6 +794,7 @@ if ($Env:flavor -eq 'ITPro') { $sqlDscConfigurationFile = "$Env:MHBoxDscDir\virtual_machines_sql.dsc.yml" (Get-Content -Path $sqlDscConfigurationFile) -replace 'namingPrefixStage', $namingPrefix | Set-Content -Path $sqlDscConfigurationFile winget configure --file C:\MHBox\DSC\virtual_machines_sql.dsc.yml --accept-configuration-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { throw "SQL VM WinGet configuration failed with exit code $LASTEXITCODE." } # Restart the newly created VM from the host before guest configuration. Write-Host 'Restarting the nested SQL VM' @@ -792,6 +815,7 @@ if ($Env:flavor -eq 'ITPro') { -Credential $winCreds ` -ScriptBlock { param($newName) + $ErrorActionPreference = 'Stop' if ($env:COMPUTERNAME -cne $newName) { Rename-Computer -NewName $newName } @@ -818,7 +842,6 @@ if ($Env:flavor -eq 'ITPro') { -Description 'SQL Server firewall configuration' # Onboard nested Windows and Linux VMs to Azure Arc - if ($Env:flavor -eq 'ITPro') { Write-Host "`n### Fetching Nested VMs ###`n" $Win2k22vmName = "$namingPrefix-Win2K22" @@ -836,71 +859,61 @@ if ($Env:flavor -eq 'ITPro') { $DeploymentProgressString = 'Downloading and configuring nested VMs' - $tags = Get-AzResourceGroup -Name $env:resourceGroup | Select-Object -ExpandProperty Tags - - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $DeploymentProgressString - } else { - $tags = @{'DeploymentProgress' = $DeploymentProgressString } - } - - $null = Set-AzResourceGroup -ResourceGroupName $env:resourceGroup -Tag $tags - $null = Set-AzResource -ResourceName $env:computername -ResourceGroupName $env:resourceGroup -ResourceType 'microsoft.compute/virtualmachines' -Tag $tags -Force + Set-LabDeploymentTags @{ DeploymentProgress = $DeploymentProgressString } # Verify if VHD files already downloaded especially when re-running this script - if (!((Test-Path $Win2k22vmvhdPath) -and (Test-Path $Ubuntu01vmvhdPath))) { - <# Action when all if and elseif conditions are false #> - $Env:AZCOPY_BUFFER_GB = 4 - Write-Output 'Downloading nested VMs VHDX files. This can take some time, hold tight...' - azcopy cp $vhdSourceFolder $Env:MHBoxVMDir --include-pattern $files --recursive=true --check-length=false --log-level=ERROR - } - - if ($namingPrefix -ne 'ArcBox') { - - # Split the string into an array - $fileList = $files -split ';' | Where-Object { $_ -ne '' } - - # Set the path to search for files - $searchPath = $Env:MHBoxVMDir - - # Loop through each file and rename if found - foreach ($file in $fileList) { - $filePath = Join-Path -Path $searchPath -ChildPath $file - if (Test-Path $filePath) { - $newFileName = $file -replace 'ArcBox', $namingPrefix - - Rename-Item -Path $filePath -NewName $newFileName - Write-Output "Renamed $file to $newFileName" - } else { - Write-Output "$file not found in $searchPath" - } - } - + $Env:AZCOPY_BUFFER_GB = 4 + foreach ($file in ($files -split ';' | Where-Object { $_ -ne '' })) { + $destinationPath = Join-Path $Env:MHBoxVMDir ($file -replace 'ArcBox', $namingPrefix) + if (-not (Test-Path -LiteralPath $destinationPath)) { + Write-Output "Downloading missing nested VM disk '$file'..." + azcopy cp $vhdSourceFolder $Env:MHBoxVMDir --include-pattern $file --recursive=true --check-length=false --log-level=ERROR + if ($LASTEXITCODE -ne 0) { throw "Downloading '$file' failed with exit code $LASTEXITCODE." } + Rename-Item -LiteralPath (Join-Path $Env:MHBoxVMDir $file) -NewName $destinationPath } + } - if ((Test-Path $Win2k22vmvhdPath) ) { - <# Local Copy of Win2K22 Disk for Azure Migrate Appliances #> + if (-not (Test-Path -LiteralPath $AzMigSrvvmvhdPath) -and + -not (Get-VM -Name $AzMigSrvvmName -ErrorAction SilentlyContinue)) { Write-Output 'Local Copy of Win2K22 Disk for Azure Migrate Appliance. This can take some time, hold tight...' + Copy-Item -LiteralPath $Win2k22vmvhdPath -Destination $AzMigSrvvmvhdPath + } - Copy-Item -Path $Win2k22vmvhdPath -Destination $AzMigSrvvmvhdPath -Force - } - - # Update disk IOPS and throughput after downloading nested VMs (note: a disk's performance tier can be downgraded only once every 12 hours) - az disk update --resource-group $env:resourceGroup --name $existingVMDisk.Name --disk-iops-read-write $existingVMDisk.DiskIOPSReadWrite --disk-mbps-read-write $existingVMDisk.DiskMBpsReadWrite + } + catch { + $diskProvisioningError = $_ + throw + } + finally { + try { + az disk update --resource-group $env:resourceGroup --name $existingVMDisk.Name --disk-iops-read-write $originalDiskIops --disk-mbps-read-write $originalDiskThroughput + if ($LASTEXITCODE -ne 0) { throw "Restoring VMsDisk performance failed with exit code $LASTEXITCODE." } + } + catch { + if ($null -ne $diskProvisioningError) { + Write-Warning "Restoring VMsDisk '$($existingVMDisk.Name)' to $originalDiskIops IOPS / $originalDiskThroughput MBps also failed: $($_.Exception.Message) Preserving provisioning error: $($diskProvisioningError.Exception.Message)" -WarningAction Continue + } + else { + throw + } + } + } + if ($Env:flavor -eq 'ITPro') { # Create the nested VMs if not already created Write-Host "`n### Create Hyper-V VMs ###`n" $serversDscConfigurationFile = "$Env:MHBoxDscDir\virtual_machines_itpro.dsc.yml" (Get-Content -Path $serversDscConfigurationFile) -replace 'namingPrefixStage', $namingPrefix | Set-Content -Path $serversDscConfigurationFile winget configure --file C:\MHBox\DSC\virtual_machines_itpro.dsc.yml --accept-configuration-agreements --disable-interactivity + if ($LASTEXITCODE -ne 0) { throw "Nested VM WinGet configuration failed with exit code $LASTEXITCODE." } # Configure automatic start & stop action for the nested VMs - Get-VM | Where-Object {$_.State -eq "Running"} | - ForEach-Object -Parallel { - Stop-VM -Force -Name $PSItem.Name - Set-VM -Name $PSItem.Name -AutomaticStopAction ShutDown -AutomaticStartAction Start - Start-VM -Name $PSItem.Name - } + foreach ($vmName in $expectedVmNames) { + $vm = Get-VM -Name $vmName + if ($vm.State -eq 'Running') { Stop-VM -Force -Name $vmName } + Set-VM -Name $vmName -AutomaticStopAction ShutDown -AutomaticStartAction Start + Start-VM -Name $vmName + } foreach ($vmName in @($SQLvmName, $Win2k22vmName, $AzMigSrvvmName)) { Wait-VMCommand -VMName $vmName -Credential $winCreds @@ -920,6 +933,7 @@ if ($Env:flavor -eq 'ITPro') { -Credential $winCreds ` -ScriptBlock { param($newName) + $ErrorActionPreference = 'Stop' if ($env:COMPUTERNAME -ceq $newName) { return $false } @@ -938,8 +952,11 @@ if ($Env:flavor -eq 'ITPro') { # Configuring SSH for accessing Linux VMs Write-Output 'Generating SSH key for accessing nested Linux VMs' - $null = New-Item -Path ~ -Name .ssh -ItemType Directory - ssh-keygen -t rsa -N '' -f $Env:USERPROFILE\.ssh\id_rsa + $null = New-Item -Path ~ -Name .ssh -ItemType Directory -Force + if (-not (Test-Path -LiteralPath "$Env:USERPROFILE\.ssh\id_rsa")) { + ssh-keygen -t rsa -N '' -f $Env:USERPROFILE\.ssh\id_rsa + if ($LASTEXITCODE -ne 0) { throw "SSH key generation failed with exit code $LASTEXITCODE." } + } Copy-Item -Path "$Env:USERPROFILE\.ssh\id_rsa.pub" -Destination "$Env:TEMP\authorized_keys" @@ -948,7 +965,7 @@ if ($Env:flavor -eq 'ITPro') { $Ubuntu01VmIp = Get-VMIPv4AddressWithRetry -VMName $Ubuntu01vmName Wait-TcpPort -ComputerName $Ubuntu01VmIp -Port 22 - Get-VM *Ubuntu* | Copy-VMFile -SourcePath "$Env:TEMP\authorized_keys" -DestinationPath "/home/$nestedLinuxUsername/.ssh/" -FileSource Host -Force -CreateFullPath + Get-VM -Name $Ubuntu01vmName | Copy-VMFile -SourcePath "$Env:TEMP\authorized_keys" -DestinationPath "/home/$nestedLinuxUsername/.ssh/" -FileSource Host -Force -CreateFullPath if ($namingPrefix -ne 'ArcBox') { @@ -970,6 +987,7 @@ if ($Env:flavor -eq 'ITPro') { [ValidateNotNullOrEmpty()] [string]$NewHostName ) + $ErrorActionPreference = 'Stop' function Invoke-SudoCommand { param( @@ -1204,10 +1222,15 @@ if ($Env:flavor -eq 'ITPro') { Write-Output 'Activating operating system on Windows VMs...' $activationScript = { + $ErrorActionPreference = 'Stop' cscript C:\Windows\system32\slmgr.vbs -ipk VDYBN-27WPP-V4HQT-9VMD4-VMK7H + if ($LASTEXITCODE -ne 0) { throw "Windows product key installation failed with exit code $LASTEXITCODE." } cscript C:\Windows\system32\slmgr.vbs -skms kms.core.windows.net + if ($LASTEXITCODE -ne 0) { throw "Windows KMS configuration failed with exit code $LASTEXITCODE." } cscript C:\Windows\system32\slmgr.vbs -ato + if ($LASTEXITCODE -ne 0) { throw "Windows activation failed with exit code $LASTEXITCODE." } cscript C:\Windows\system32\slmgr.vbs -dlv + if ($LASTEXITCODE -ne 0) { throw "Windows activation query failed with exit code $LASTEXITCODE." } } foreach ($vmName in @($Win2k22vmName, $AzMigSrvvmName, $SQLvmName)) { @@ -1228,6 +1251,7 @@ if ($Env:flavor -eq 'ITPro') { # Install IIS and deploy the demo page on Windows VM Invoke-Command -VMName $Win2k22vmName -ScriptBlock { param($sourceRoot) + $ErrorActionPreference = 'Stop' Add-WindowsFeature Web-Server -IncludeManagementTools & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` @@ -1251,6 +1275,7 @@ if ($Env:flavor -eq 'ITPro') { ) Invoke-Command -Session $UbuntuSessions -ScriptBlock { param($deploymentScript, $encodedSourceRoot) + $ErrorActionPreference = 'Stop' try { $sourceRoot = [Text.Encoding]::UTF8.GetString( @@ -1280,11 +1305,6 @@ if ($Env:flavor -eq 'ITPro') { } - # Removing the LogonScript Scheduled Task so it won't run on next reboot - Write-Host "`n### Removing Logon Task ###`n" - if ($null -ne (Get-ScheduledTask -TaskName 'MHServersLogonScript' -ErrorAction SilentlyContinue)) { - Unregister-ScheduledTask -TaskName 'MHServersLogonScript' -Confirm:$false - } } @@ -1293,15 +1313,17 @@ if ($Env:flavor -eq 'ITPro') { Write-Host "`n### Enabling Azure VM Auto-shutdown ###`n" - $ScheduleResource = Get-AzResource -ResourceGroup $env:resourceGroup -ResourceType Microsoft.DevTestLab/schedules - $Uri = "https://management.azure.com$($ScheduleResource.ResourceId)?api-version=2018-09-15" + $scheduleId = "/subscriptions/$subscriptionId/resourceGroups/$env:resourceGroup/providers/Microsoft.DevTestLab/schedules/shutdown-computevm-$env:hostVmName" + $Uri = "https://management.azure.com${scheduleId}?api-version=2018-09-15" $Schedule = Invoke-AzRestMethod -Uri $Uri + if ($Schedule.StatusCode -ne 200) { throw "Reading shutdown schedule failed: HTTP $($Schedule.StatusCode). $($Schedule.Content)" } $ScheduleSettings = $Schedule.Content | ConvertFrom-Json $ScheduleSettings.properties.status = "Enabled" - Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json) + $result = Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) + if ($result.StatusCode -notin @(200, 201, 202)) { throw "Enabling shutdown schedule failed: HTTP $($result.StatusCode). $($result.Content)" } } else { @@ -1310,15 +1332,57 @@ if ($Env:flavor -eq 'ITPro') { } - $DeploymentProgressString = 'Deployment Completed' +if ($Env:flavor -ne 'ITPro') { throw 'The single-RG baseline requires the ITPro flavor.' } - $tags = Get-AzResourceGroup -Name $env:resourceGroup | Select-Object -ExpandProperty Tags - - if ($null -ne $tags) { - $tags['DeploymentProgress'] = $DeploymentProgressString - } else { - $tags = @{'DeploymentProgress' = $DeploymentProgressString } +# Validate HTTP and substituted metadata again at the completion boundary. +$validateWebPage = { + param([string]$ExpectedHostName, [string]$ExpectedPlatform, [string]$ExpectedWebServer) + $ErrorActionPreference = 'Stop' + $response = Invoke-WebRequest -Uri 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 15 + if ($response.StatusCode -ne 200) { throw "Web validation returned HTTP $($response.StatusCode)." } + if ($response.Content -match '<(?:HOSTNAME|PLATFORM|WEB_SERVER)>') { throw 'Unsubstituted web metadata tokens remain.' } + $expectedFields = @{ + hostname = $ExpectedHostName + platform = $ExpectedPlatform + 'web-server' = $ExpectedWebServer + } + foreach ($field in $expectedFields.Keys) { + $pattern = ']*\bdata-workload-value="' + [regex]::Escape($field) + '"[^>]*>\s*' + + [regex]::Escape($expectedFields[$field]) + '\s*' + if ($response.Content -notmatch $pattern) { throw "The web page does not contain the expected '$field' metadata." } + } + return [int]$response.StatusCode +} +$windowsHttpStatus = Invoke-Command -VMName $Win2k22vmName -Credential $winCreds ` + -ScriptBlock $validateWebPage -ArgumentList @($Win2k22vmName, 'Windows Server 2022', 'IIS') -ErrorAction Stop +$linuxHttpStatus = Invoke-Command -HostName $Ubuntu01VmIp -KeyFilePath "$Env:USERPROFILE\.ssh\id_rsa" ` + -UserName $nestedLinuxUsername -ScriptBlock $validateWebPage ` + -ArgumentList @($Ubuntu01HostName, 'Ubuntu Linux', 'Apache') -ErrorAction Stop +if ($windowsHttpStatus -ne 200 -or $linuxHttpStatus -ne 200) { throw 'Both guest web validations must return HTTP 200.' } + +foreach ($vmName in $expectedVmNames) { + if ((Get-VM -Name $vmName -ErrorAction Stop).State -ne 'Running') { + throw "Cannot complete the baseline: nested VM '$vmName' is not running." } +} + +$readyMarkerPath = "$Env:MHBoxDir\lab-ready.json" +$pendingMarkerPath = "$readyMarkerPath.pending" +try { + [ordered]@{ + schemaVersion = 1 + hostName = $env:COMPUTERNAME + nestedVmNames = $expectedVmNames + windowsHttpStatus = [int]$windowsHttpStatus + linuxHttpStatus = [int]$linuxHttpStatus + } | ConvertTo-Json | Set-Content -LiteralPath $pendingMarkerPath -Encoding UTF8 + Move-Item -LiteralPath $pendingMarkerPath -Destination $readyMarkerPath -Force +} +finally { + Remove-Item -LiteralPath $pendingMarkerPath -Force -ErrorAction SilentlyContinue +} - $null = Set-AzResourceGroup -ResourceGroupName $env:resourceGroup -Tag $tags - $null = Set-AzResource -ResourceName $env:computername -ResourceGroupName $env:resourceGroup -ResourceType 'microsoft.compute/virtualmachines' -Tag $tags -Force \ No newline at end of file +# Only remove the task after the baseline has passed all readiness checks. +Unregister-ScheduledTask -TaskName 'MHServersLogonScript' -Confirm:$false +Set-LabDeploymentTags @{ DeploymentProgress = 'Deployment Completed'; LabReady = 'single-rg-v1' } +Stop-Transcript \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 index 7ca337151..8b6f2d07e 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 @@ -1,4 +1,22 @@ -$ErrorActionPreference = $env:ErrorActionPreference +$ErrorActionPreference = 'Stop' + +trap { + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + Remove-Item -LiteralPath 'C:\MHBox\lab-ready.json' -Force -ErrorAction SilentlyContinue + try { + Connect-AzAccount -Identity -Tenant $env:tenantId -Subscription $env:subscriptionId -ErrorAction Stop | Out-Null + $groupId = "/subscriptions/$env:subscriptionId/resourceGroups/$env:resourceGroup" + foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$env:hostVmName")) { + try { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null + } + catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } + } + } + catch { Write-Warning "Could not authenticate to report WinGet failure: $($_.Exception.Message)" } + exit 1 +} $Env:MHBoxDir = 'C:\MHBox' $Env:MHBoxLogsDir = "$Env:MHBoxDir\Logs" @@ -9,23 +27,21 @@ $resourceGroup = $env:resourceGroup $logFilePath = Join-Path -Path $Env:MHBoxLogsDir -ChildPath ('WinGet-provisioning-' + (Get-Date -Format 'yyyyMMddHHmmss') + '.log') -Start-Transcript -Path $logFilePath -Force -ErrorAction SilentlyContinue +Start-Transcript -Path $logFilePath -Force +if (Test-Path -LiteralPath "$Env:MHBoxDir\lab-ready.json") { + Remove-Item -LiteralPath "$Env:MHBoxDir\lab-ready.json" -Force +} $DeploymentProgressString = "Installing WinGet packages..." Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId -$tags = Get-AzResourceGroup -Name $resourceGroup | Select-Object -ExpandProperty Tags - -if ($null -ne $tags) { - $tags["DeploymentProgress"] = $DeploymentProgressString -} else { - $tags = @{"DeploymentProgress" = $DeploymentProgressString} +if ([string]::IsNullOrWhiteSpace($env:hostVmName)) { throw 'The hostVmName machine environment variable is required.' } +$groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" +foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$env:hostVmName")) { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = $DeploymentProgressString } | Out-Null } -$null = Set-AzResourceGroup -ResourceGroupName $resourceGroup -Tag $tags -$null = Set-AzResource -ResourceName $env:computername -ResourceGroupName $resourceGroup -ResourceType "microsoft.compute/virtualmachines" -Tag $tags -Force - # Pinned to version 1.11.460 to avoid known issue: https://github.com/microsoft/winget-cli/issues/5826 Install-PSResource -Name Microsoft.WinGet.Client -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version 1.11.460 Install-PSResource -Name Microsoft.WinGet.DSC -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version 1.11.460 @@ -40,11 +56,12 @@ Repair-WinGetPackageManager -AllUsers -Force -Latest -Verbose # Apply WinGet Configuration files winget configure --file C:\MHBox\DSC\common.dsc.yml --accept-configuration-agreements --disable-interactivity +if ($LASTEXITCODE -ne 0) { throw "Common WinGet configuration failed with exit code $LASTEXITCODE." } winget configure --file C:\MHBox\DSC\itpro.dsc.yml --accept-configuration-agreements --disable-interactivity +if ($LASTEXITCODE -ne 0) { throw "ITPro WinGet configuration failed with exit code $LASTEXITCODE." } # Start remaining logon scripts -Start-ScheduledTask -TaskName 'MHServersLogonScript' - -#Cleanup +# Remove this logon trigger before handing off so a reboot cannot start both stages. Unregister-ScheduledTask -TaskName 'WinGetLogonScript' -Confirm:$false +Start-ScheduledTask -TaskName 'MHServersLogonScript' Stop-Transcript \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md index f81c7fff4..78a7e8d3e 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md @@ -2,111 +2,37 @@ **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-02/solution-02.md) -Duration: 30 minutes +Duration: 15 minutes (verification of an already-provisioned lab) ## Prerequisites - Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. -- The Azure CLI is required to deploy the Bicep configuration for the Hack. -- Clone the GitHub repository or download the [Resources](../../resources) directory to your local PC. +- Your instructor/platform has completed [predeployment](../../resources/README.md), and your platform dashboard contains the subscription, lab resource group, connection details, and login credential fields. +- Your learner account has **Owner** on the assigned resource group and **Reader** on the subscription. > [!IMPORTANT] > **Many of the resource deployments in this Hack are adapted from the [Jumpstart ArcBox for IT Pros](https://jumpstart.azure.com/azure_jumpstart_arcbox/ITPro). Special thanks to the [Jumpstart Team](https://aka.ms/arcjumpstart) for their excellent work.** -### **Task 1: Deploy the Landing Zone for the Hack** +### **Task 1: Receive the lab and verify host readiness** -- Open the [Azure Portal](https://portal.azure.com) and log in using a user account with at least Contributor permissions on an Azure subscription. Start Azure Cloud Shell from the menu bar at the top. - -![image](./img/CS1.png) - -> [!NOTE] -> You can also use your local PC but make sure to install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli). - -- If this is the first time you have started Cloud Shell, select *Bash*, click *No storage account required*, and then click *Apply*. - -![image](./img/CS1-1.png) - -![image](./img/CS2.png) - -- Make sure to select *Bash*. - -![image](./img/CS3.png) - -- Clone the Hack GitHub repository using the following command: - -```bash -git clone https://github.com/microsoft/MicroHack.git -``` +1. Open your platform dashboard. Record the assigned subscription, ``, region, Hyper-V host name, and login credential fields. Do not copy passwords into shared notes. +2. Sign in to the [Azure Portal](https://portal.azure.com) with your assigned learner account. Open that existing resource group; do not create a source or destination resource group. +3. Locate the Hyper-V host `mh-<8charStableLabSuffix>-hv`. The suffix is a deterministic eight-character hash of the subscription and resource-group scope, not a UPN. Use the actual host name shown by your dashboard. +4. On the host VM, check that **DeploymentProgress** is **Deployment Completed** and **LabReady** is **single-rg-v1**; the resource group's **LabVerified** tag must also be **single-rg-v1**. Bootstrap publishes `C:\MHBox\lab-ready.json` (schema version 1), which the deployer independently checks before handoff. You can inspect ARM deployment status under the lab resource group's **Deployments**, but ARM success alone is not the readiness gate. +5. Connect to the host using **Connect** > **Bastion** and the supplied host login credentials. You can also select **Password from Azure Key Vault** and use the login secrets stored in the lab Key Vault. > [!NOTE] -> Contributors testing before merge must clone or check out their fork's feature branch locally so the Bicep files match the automation source selected below. - -![image](./img/CS4.png) +> Screenshots are illustrative and can show older names. Your dashboard's resource group, host name, and credentials are authoritative. -- Change into the Hack's resources directory in the cloned repository using the command: - -```bash -cd MicroHack/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/bicep -``` +![Host VM DeploymentProgress tag example](./img/CS9.png) > [!IMPORTANT] -> **Update the `REGION` variable with your desired region (the example defaults to `swedencentral`) and set `USERPASSWORD` to your desired password. The GitHub owner and branch default to the official `microsoft/MicroHack` `main` branch.** - -- Set the deployment values. The defaults use the official repository. To test before merge, replace `microsoft` with your `` and `main` with your ``. - -```bash -REGION="swedencentral" -USERPASSWORD="" -GITHUB_ACCOUNT="microsoft" -GITHUB_BRANCH="main" -``` - -> [!NOTE] -> The local `main.bicep` comes from your checked-out clone. `GITHUB_ACCOUNT` and `GITHUB_BRANCH` select the repository version used for all automation and demo-page assets downloaded during deployment. - -- Verify that the selected repository branch contains a required deployment artifact before starting the costly Azure deployment: - -```bash -ARTIFACT_BASE_URL="https://raw.githubusercontent.com/${GITHUB_ACCOUNT}/MicroHack/${GITHUB_BRANCH}/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources" -curl --fail --silent --show-error --head \ - "${ARTIFACT_BASE_URL}/artifacts/Bootstrap.ps1" >/dev/null -``` - -- Start the deployment: - -```bash -az deployment sub create \ - --name "$(az ad signed-in-user show --query userPrincipalName -o tsv | cut -d "@" -f 1 | tr '[:upper:]' '[:lower:]')-MHBox" \ - --location "$REGION" \ - --template-file ./main.bicep \ - --parameters \ - windowsAdminPassword="$USERPASSWORD" \ - githubAccount="$GITHUB_ACCOUNT" \ - githubBranch="$GITHUB_BRANCH" -``` -![image](./img/CS5.png) - -- Wait for the deployment to finish. You can view the deployment from the Azure portal by selecting the Azure subscription and clicking on *Deployments* from the navigation pane on the left. - -![image](./img/CS7.png) - -- You can also click the deployment name to see the detailed deployment steps. +> Fresh predeployment can take **35–90+ minutes**, including nested-VM downloads and configuration. ARM submission is polled with token refresh for up to **180 minutes per invocation**; guest readiness then has a separate **120-minute post-deployment timeout**. Before the initial handoff, the deployer waits for the completion tags, ready marker, four running guests, and both dashboard HTTP checks. Use the dashboard's lab region, also recorded in the RG's **LabRegion** tag; the RG's own metadata location can differ. The instructor's deployer can skip an unsupported preferred region before initial deployment, but retries never move or delete an existing lab. If your lab is not ready, contact the instructor with its status; do not delete the resource group or redeploy into a different region. -![image](./img/CS8.png) - -> [!NOTE] -> Please note that the deployment may take up to 15 minutes. After the deployment, multiple scripts are executed on the VM to install the required roles and to download the nested guest VMs. This process might take an additional 20 minutes. The deployment will be finished if the **DeploymentProgress** tag shows a value of **Deployment Completed**. - -![image](./img/CS9.png) - -You can also log on to the MHBox-HV system during the deployment to follow the steps. - -![image](./img/CS10.png) - -![image](./img/CS11.png) +These are **initial-handoff** checks. If you revisit this walkthrough after migration, source guests may correctly be stopped. Completed deployment reruns preserve that state instead of restarting guests or requiring all four to be running; do not reset learner progress just to repeat Challenge 1. ### **Task 2: Verify the deployed VM resources** -After the deployment has completed, open *Hyper-V Manager* from MHBox-HV and ensure that four VMs are running. +Open *Hyper-V Manager* on the supplied host and ensure that four VMs are running. + MHBox-AzMigSrv + This system will be used later to deploy the Azure Migrate appliance, which is required for the discovery process. + MHBox-SQL @@ -116,7 +42,7 @@ After the deployment has completed, open *Hyper-V Manager* from MHBox-HV and ens + MHBox-Win2K22 + This system hosts Windows Server 2022 with an installed IIS web server. -The following credentials are being used inside the nested VMs. +The following lab-only credentials are used inside the nested VMs. These are separate from the generated host login credentials delivered by the platform and stored in Key Vault. **Windows virtual machine credentials:** @@ -148,21 +74,17 @@ The **Hostname** value must match the VM you opened. ### **Task 3: Verify the deployed Azure resources** -The Bicep deployment should have created the following Azure resources: - -- source-rg resource group containing the following resources - + Virtual Network *source-vnet* - + Virtual Machine *MHBox-HV* with Hyper-V role installed and all required nested VMs - + Azure Bastion *source-bastion* - + Azure Key Vault containing username and password for VM login - + NAT Gateway for outbound internet access - -- destination-rg resource group containing the following resources - + Virtual Network *destination-vnet* - + Azure Bastion *destination-bastion* - + NAT Gateway for outbound internet access - -The deployed architecture looks like the following diagram: -![image](./img/Challenge-1.jpg) +Within **the same** ``, verify: + +| Component | Purpose | +| --- | --- | +| Source VNet, `mh--Source-VNet`, `10.1.0.0/16` | Hosts the Azure Hyper-V VM `mh--hv`, with the four nested guests above | +| Destination VNet, `mh--destination-VNet`, `10.2.0.0/16` | Target network for the migrated Azure VMs in Challenge 5 | +| Bastion access and NAT Gateways | Private administration and outbound connectivity for the respective networks | +| Azure Key Vault, `mhkv` plus the unique suffix derived from the RG ID | Stores the host login username and generated password | + +**Textual architecture:** one learner resource group contains two networks. The source network contains the Hyper-V host and its nested appliance, SQL, Windows/IIS, and Ubuntu/Apache guests. Azure Migrate will later discover the guests and migrate the two web workloads into the destination network **without changing resource group**. Monitoring and App Service resources will also be created in this lab group during their exercises. + +Do not install the Azure Migrate appliance or configure replication, monitoring, or App Service yet. Keep the whole resource group through Challenges 2–8; deleting it would remove both the simulated source and the migrated environment. You successfully completed Challenge 1! 🚀🚀🚀 diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md index 6dde80e1e..985cec63b 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md @@ -6,7 +6,10 @@ Duration: 60 minutes ### **Task 1: Create an Azure Migrate project** -Open the [Azure Portal](https://portal.azure.com) and log in using a user account with at least Contributor permissions on an Azure subscription. +Open the [Azure Portal](https://portal.azure.com) with your assigned learner account: **Owner** on `` and **Reader** on the subscription. + +> [!NOTE] +> Screenshots illustrate the portal workflow and may show older resource names. Use the lab resource group and Hyper-V host from your platform dashboard throughout; all learner-created resources belong in that group. In the search bar, enter *Azure Migrate* and select Azure Migrate from the list of services. @@ -19,9 +22,7 @@ Select *All projects* from the navigation pane on the left and click *Create pro > [!IMPORTANT] > To create a business case, make sure to select **Europe** as the *Geography* for the Azure Migrate project. -Select the *destination-rg* resource group, specify a name for the Azure Migrate project, and select a geography where the data will be stored. - -![image](./img/AzMig3.png) +Select your existing ``, specify a name for the Azure Migrate project, and select a geography where the data will be stored. Do not create another resource group. Wait until the Azure Migrate project has been created. Select *All projects* from the navigation pane on the left. Your previously created Azure Migrate project should be listed. Click on it to open the project. @@ -32,19 +33,19 @@ Your previously created Azure Migrate project should be listed. Click on it to o ### **Task 2: Install the Azure Migrate appliance for Hyper-V discovery** -Hyper-V discovery uses an Azure Migrate appliance that connects to the Hyper-V host and continuously collects VM configuration, performance metadata, and workload inventory. For this Hack, install and register the appliance by running the PowerShell installation script on the existing **MHBOX-AzMigSrv** server, and then connect it to **MHBox-HV**. +Hyper-V discovery uses an Azure Migrate appliance that connects to the Hyper-V host and continuously collects VM configuration, performance metadata, and workload inventory. For this Hack, install and register the appliance by running the PowerShell installation script on the existing **MHBox-AzMigSrv** nested server, and then connect it to the supplied Hyper-V host **`mh-<8charStableLabSuffix>-hv`**. > [!IMPORTANT] > Check the current [Hyper-V discovery prerequisites](https://learn.microsoft.com/en-us/azure/migrate/tutorial-discover-hyper-v?view=migrate#prerequisites) for the Azure Migrate appliance and Hyper-V host. -In the Azure Portal select *Virtual machines* from the navigation pane on the left. Select the *MHBox-HV* system and log on via Azure Bastion with your credentials: +In the Azure Portal select *Virtual machines* from the navigation pane on the left. Select the host named in your dashboard (`mh-<8charStableLabSuffix>-hv`) and log on via Azure Bastion with your host credentials: > [!NOTE] > You can also select *Password from Azure Key Vault* under *Authentication Type* to select the secret stored in the Key Vault. ![image](./img/AzMigApp1.png) -Start the Hyper-V Manager and connect to the **MHBOX-AzMigSrv** server. +Start the Hyper-V Manager and connect to the **MHBox-AzMigSrv** server. The following credentials are being used inside the nested VMs. @@ -63,7 +64,7 @@ Password: JS123!! ``` > [!IMPORTANT] -> Run the following commands inside the **MHBOX-AzMigSrv** virtual machine that was created for the discovery appliance during the lab deployment. +> Run the following commands inside the **MHBox-AzMigSrv** virtual machine that was created for the discovery appliance during the lab deployment. ![image](./img/AzMigApp2.png) @@ -178,6 +179,4 @@ After discovery has been successfully initiated, go to the Azure portal to revie You successfully completed Challenge 2! 🚀🚀🚀 -The deployed architecture now looks like the following diagram. - -![image](./img/Challenge-2.jpg) +The Azure Migrate project is in your single lab resource group and the discovery appliance runs inside the source Hyper-V host. Discovery does not create a separate destination resource group or migrate the guests; keep the source and destination networks and all lab resources for the following challenges. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md index c508e82b2..2d38eb51e 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md @@ -8,12 +8,14 @@ Duration: 90 minutes Please make sure that you successfully completed [Challenge 4](../challenge-04/solution-04.md) before continuing with this challenge. +Use the existing `` for the Azure Migrate project, replication resources you create, and migrated VMs. The source and destination VNets are separate networks in this **same** group. Screenshots are illustrative; the platform dashboard's resource group and host name are authoritative. + > [!IMPORTANT] > Native Hyper-V migration uses host-installed replication components: the Azure Site Recovery provider and Recovery Services agent. Install them on the Hyper-V host or cluster nodes. Nothing is installed in the guest VMs, and the Azure Migrate appliance isn't used for migration. ### **Task 1: Configure and register the Hyper-V replication providers** -In the Azure Portal, select *Virtual machines* from the navigation pane on the left. Select the **MHBox-HV** system and log on via Azure Bastion with your credentials: +In the Azure Portal, select *Virtual machines* from the navigation pane on the left. Select the supplied Hyper-V host **`mh-<8charStableLabSuffix>-hv`** and log on via Azure Bastion with your host credentials: > [!NOTE] > You can also select *Password from Azure Key Vault* under *Authentication Type* to select the secret stored in the Key Vault. @@ -38,7 +40,7 @@ On *Specify intent*, select *Servers or virtual machines (VMs)* as the workload ![image](./img/PrepRep3-1.png) > [!IMPORTANT] -> **Make sure to select the correct target region. Double-check it against the destination resource group location. This can't be changed afterward.** +> **Select the target region containing your destination VNet (`10.2.0.0/16`), as shown in the lab dashboard. The source and destination resources use the same lab resource group; the selected migration target region can't be changed afterward.** Click *Create resources*. @@ -93,9 +95,7 @@ Next, select both web VMs: **MHBox-Win2K22** and **MHBox-Ubuntu-01**. Enable rep ![image](./img/Rep3.png) -Next, select the destination resource group, virtual network, and subnet. - -![image](./img/Rep4.png) +Next, select `` as the target resource group, then its destination VNet (`10.2.0.0/16`) and workload subnet. Do not select the source VNet or create a separate target resource group. In the *Compute* section, you can adjust target settings such as the VM size. @@ -129,7 +129,7 @@ On the new page, make sure that *Preparation* is *Completed*. Open the menu next ![image](./img/TestMig2.png) -Select the destination network and click *Test migration*. +Select the destination network in your lab resource group and click *Test migration*. ![image](./img/TestMig3.png) @@ -175,7 +175,7 @@ From the *Testing* menu, select *Cleanup test migration*. ![image](./img/TestMig8.png) -Provide a comment, select *Testing is complete....*, and click *Cleanup Test* to remove all resources. +Provide a comment, select *Testing is complete....*, and click *Cleanup Test* to remove the test-migration resources only. Do not delete the lab resource group. ![image](./img/TestMig9.png) @@ -215,9 +215,7 @@ On the Hyper-V host, the VMs should also be turned off. ![image](./img/mig6.png) -In the *Virtual machines* section of the Azure Portal, you should now see two additional servers in the *destination-rg* resource group. - -![image](./img/mig8.png) +In the *Virtual machines* section of the Azure Portal, filter to ``. You should now see the two migrated Azure web VMs alongside the original Hyper-V host; they use the destination VNet. Connect to both migrated VMs through Azure Bastion and validate each workload directly. @@ -252,4 +250,4 @@ Under *Completion*, select *Complete migration*. Repeat this action for both **M You successfully completed Challenge 5. -Continue to Challenge 6 to secure the migrated environment. Do not remove `destination-rg` because Challenges 6 through 8 use the migrated workload. +Continue to Challenge 6 to secure the migrated environment. Do not delete `` during Challenges 2–8: it contains **both** the simulated source and the migrated workloads, and later also monitoring and App Service resources. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md index b434e6f48..0a22228fe 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md @@ -4,18 +4,19 @@ Duration: 40 minutes -## **Task 1: Enable Defender for Cloud for Servers** +## **Task 1: Verify instructor-enabled Defender plans** -In [Challenge 5](../challenge-05/solution-05.md) we migrated two servers to Azure. The servers are already protected by the basic services of Defender for Cloud. In this challenge, we'll significantly improve the protection level by activating advanced services such as "Cloud Security Posture Management (CSPM)" and "Cloud Workload Protection (CWP)" (Defender for Servers). +In [Challenge 5](../challenge-05/solution-05.md) we migrated two servers to Azure in ``. This challenge uses advanced services such as "Cloud Security Posture Management (CSPM)" and "Cloud Workload Protection (CWP)" (Defender for Servers), beyond the basic Defender for Cloud services. -To enable the advanced Defender for Cloud components, open the portal and select *Defender for Cloud*. Under *Management*, select *Environment settings* to define the Defender for Cloud settings for the subscription. +> [!IMPORTANT] +> Defender plan activation changes subscription-wide settings and costs on a shared subscription. An authorized **instructor** must approve and enable the required plans before this exercise. Learners have RG Owner and subscription Reader, not subscription Owner; do not broaden every learner's permissions to enable a plan. If the plans are unavailable, ask the instructor or skip this optional challenge. -![image](./img/Def-environment-settings.jpg) - -In the settings, enable the *Defender CSPM* and *Defender for Servers* plans to better protect the migrated servers from threats. After enabling the plans, use the *Settings* link for both plans and verify that all features are enabled. Finally, click *Save*. +For instructor setup, open *Defender for Cloud* > *Management* > *Environment settings* and select the shared subscription. Learners can inspect the resulting settings without changing subscription-wide configuration. ![image](./img/Def-environment-settings.jpg) +The instructor enables the approved *Defender CSPM* and *Defender for Servers* plans, reviews their features through *Settings*, and saves the configuration. Learners verify that the expected protection is active for their migrated VMs. Screenshots illustrate the workflow; use the actual subscription and lab resource names from your dashboard. + Verify the *Defender CSPM* settings and monitoring details. ![image](./img/Def-CSPM-monitoring.png) @@ -68,7 +69,7 @@ Try to save the file. Defender for Endpoint will detect and quarantine it, and t ![image](./img/vmthreat.png) -Next, verify that the alert was forwarded to Azure. Open the portal, select *Defender for Cloud*, and then select *Security Alerts*. EICAR detections are reported with the severity *Informational*. Add *Informational* to the severity filter to display these alerts. +Next, verify that the alert was forwarded to Azure. Open the portal, select *Defender for Cloud*, and then select *Security Alerts*. Filter to your lab resources. EICAR detections are reported with the severity *Informational*. Add *Informational* to the severity filter to display these alerts. ![image](./img/DefSecAlert.png) @@ -98,4 +99,4 @@ In this Hack, we deployed virtual machines that use public IP addresses and are You successfully completed Challenge 6. -Continue to Challenge 7 and configure intelligent observability for both migrated web workloads. Do not remove `destination-rg`. +Continue to Challenge 7 and configure intelligent observability for both migrated web workloads. Keep `` throughout Challenges 2–8; deleting it removes both the simulated source and migrated resources. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md index 782a48316..ac8e64ff5 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md @@ -6,7 +6,7 @@ Duration: 70 minutes ## Task 1: Validate both workloads and complete preflight (5 minutes) -Record both VM names, their region, and their Azure resource IDs. Connect to each VM with Azure Bastion. +Record both VM names, their region, and their Azure resource IDs in ``. Use the actual lab group from the platform dashboard throughout. Connect to each VM with Azure Bastion. Monitoring is a learner exercise; it was not preconfigured during Challenge 1. ### Windows/IIS baseline @@ -44,9 +44,9 @@ Opening each page before the incident both proves the baseline and warms IIS/Apa Azure Copilot access is treated as available for this Hack. Confirm: 1. The portal **Copilot** entry opens and the current region is listed in the [Observability Agent regions](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview#regions). -2. Your account can read both VMs and their monitoring data and create the lab monitoring resources. To grant **Monitoring Reader**, you also need Owner, Role Based Access Control Administrator, User Access Administrator, or equivalent role-assignment permissions at the chosen scope; Contributor alone can't create the role assignment. +2. Your account has **Owner** on the lab resource group and **Reader** on the subscription. RG Owner allows creation of the monitoring resources and lab-scoped **Monitoring Reader** assignments; Contributor alone can't create role assignments. Do not request subscription Owner or make subscription-level assignments. 3. The client network allows WebSocket connections to `https://directline.botframework.com`. -4. Your account can create or reuse a user-assigned managed identity and grant it **Monitoring Reader** at the scope used by the query-based metric alerts. +4. Your account can create or reuse a user-assigned managed identity in the lab group and grant it **Monitoring Reader** on that group for the query-based metric alerts. > [!IMPORTANT] > Observability Agent, deep investigations, Azure Monitor issues, and query-based metric alerts include preview experiences. A deep investigation can use up to 300 Azure Agent Credits. Portal labels and availability can vary by tenant and region. @@ -68,13 +68,24 @@ Azure Monitor provides two different guest-monitoring models: Use the recommended OpenTelemetry experience for both VMs. Windows also needs Log Analytics for the authoritative service-control event; this challenge doesn't query classic performance tables. +### Choose the workspaces explicitly in the lab resource group + +1. In the portal, open **Azure Monitor workspaces**. Select a suitable existing workspace in ``, or select **Create**, choose the Hack subscription and that exact resource group, and create an Azure Monitor workspace in a supported region for the migrated VMs. Follow [Manage an Azure Monitor workspace](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage). +2. Open **Log Analytics workspaces** and choose or create a workspace in **the same lab resource group**. Use a regional workspace supported by the Windows Infrastructure monitoring flow, normally in the VM's region. A workspace in another region is usable only if the current portal flow supports it; compatibility is not implied by placing it in the same RG. Azure Monitor workspaces hold metrics; Log Analytics workspaces hold logs and aren't interchangeable. +3. Record both workspace resource IDs. During onboarding, explicitly select these workspaces rather than accepting a default in another resource group. Keep all user-controlled DCRs, alert rules, action groups, and identities in the lab group too. + +Azure Monitor automatically creates an `MA___managed` resource group containing its workspace-managed DCR and DCE. This documented **Azure-managed** exception is not an extra learner-created lab group. Do not move those internals or delete their group separately. User-controlled VM collection DCRs still belong in the lab group. + +> [!IMPORTANT] +> Review every resource-group selection before enabling monitoring. If this preview's wizard cannot select the lab workspaces or keep user-controlled generated resources in the lab group, stop and ask the instructor for a compatible portal configuration. Do not accept an unrelated default group, create another learner group, or broaden permissions to subscription Owner. + ### Create the alert-query identity before onboarding Create the identity before opening **Infrastructure monitoring** on either VM: -1. In the Azure portal, search for **Managed Identities**, select **Create**, and create a user-assigned managed identity such as `id-mh-monitor-alerts`. For this lab, use the Hack subscription and preferably the destination resource group and the same region as the migrated VMs. -2. Open the destination resource group or each migrated VM, select **Access control (IAM)** > **Add role assignment**, choose **Monitoring Reader**, and assign it to the new user-assigned managed identity. Resource-group scope is the simplest reusable choice for both lab VMs. -3. If a query-based alert is scoped to the Azure Monitor workspace instead of a VM or resource group, also grant the identity **Monitoring Reader** on that workspace. +1. In the Azure portal, search for **Managed Identities**, select **Create**, and create a user-assigned managed identity such as `id-mh-monitor-alerts` in `` and the same region as the migrated VMs. +2. Open the lab resource group, select **Access control (IAM)** > **Add role assignment**, choose **Monitoring Reader**, and assign it to the new user-assigned managed identity. This lab-scoped assignment covers both migrated VMs and the monitoring workspaces in the group. +3. For a query-based alert scoped to the Azure Monitor workspace, confirm the identity's inherited **Monitoring Reader** access on that workspace. No subscription-level role assignment is needed. 4. Reuse this identity for both migrated VMs. The [query-based metric alert identity requirements](https://learn.microsoft.com/azure/azure-monitor/alerts/alerts-query-based-metric-alerts-overview#managed-identities-for-query-based-alerts) require read access to the alert scope. This user-assigned identity authorizes OpenTelemetry alert queries. It is different from the VM's system-assigned identity that Azure Monitor Agent can use. The Infrastructure monitoring wizard doesn't create the user-assigned alert identity, so create and authorize it before selecting it in the wizard. @@ -85,17 +96,15 @@ This user-assigned identity authorizes OpenTelemetry alert queries. It is differ 2. Select **Customize infrastructure monitoring**. 3. Keep **OpenTelemetry metrics** selected. 4. Optionally select the OpenTelemetry detailed or per-process option to explore the cross-platform schema. The Windows alert doesn't use process metrics, and enabling them can incur ingestion cost. -5. Also select **Classic Log-based metrics** for the Windows VM so the portal selects or creates a compatible regional default Log Analytics workspace for the Windows Event DCR and Logs-scoped agent experience. This also enables classic performance ingestion as a side effect and can incur Log Analytics cost. Do not create a separately named lab workspace first. -6. Use the default regional Azure Monitor workspace, Log Analytics workspace, and generated DCR proposed by the portal. +5. Also select **Classic Log-based metrics** for the Windows VM and explicitly select the compatible regional Log Analytics workspace in the lab group for the Windows Event DCR and Logs-scoped agent experience. This also enables classic performance ingestion as a side effect and can incur Log Analytics cost. +6. Select the Azure Monitor workspace chosen above and confirm the generated user-controlled DCR is placed in ``. Do not accept a regional default workspace or collection rule in another user-controlled group. 7. Keep **Dashboards with Grafana** and **Recommended alerts** selected when offered. Recommended alerts cover infrastructure health; they don't replace the service-specific alerts created in Task 3. -8. In **Identity**, select the pre-created `id-mh-monitor-alerts` user-assigned identity. Confirm it has **Monitoring Reader** on the VM or containing destination resource group and, for a workspace-scoped rule, on the Azure Monitor workspace. -9. Review and enable monitoring. - -Before continuing, confirm that the review page names an Azure Monitor workspace, a compatible regional default Log Analytics workspace, and the generated DCR. If no Log Analytics workspace appears, return to customization and confirm **Classic Log-based metrics** is selected. If the portal still doesn't propose a default workspace, stop and ask your instructor to provide the lab's compatible default; don't independently recreate the obsolete custom-workspace flow. +8. In **Identity**, select the pre-created `id-mh-monitor-alerts` user-assigned identity. Confirm its lab-RG **Monitoring Reader** assignment covers the VM and Azure Monitor workspace. +9. Review all user-controlled DCR, alert, and action-group resource-group selections, keep them in the lab group, and enable monitoring. -The configuration should show both **OpenTelemetry metrics** and **Classic Log-based metrics** selected, the portal-proposed Azure Monitor and Log Analytics workspaces, recommended alerts enabled, and the pre-created user-assigned identity selected. +Before continuing, confirm that the review page names the selected Azure Monitor workspace, compatible regional Log Analytics workspace, and generated VM collection DCR **in the lab group**. If no Log Analytics workspace appears, return to customization and confirm **Classic Log-based metrics** is selected. If your selected regional workspace is not offered, ask the instructor to check region/service compatibility before proceeding; don't silently accept an out-of-scope default. -![Azure portal Infrastructure monitoring configuration showing OpenTelemetry and Classic Log-based metrics, proposed workspaces, recommended alerts, and a pre-created user-assigned identity](./img/configure-infrastructure-monitoring.png) +The final configuration should show both **OpenTelemetry metrics** and **Classic Log-based metrics**, your selected workspaces, recommended alerts, and the pre-created user-assigned identity. Portal labels can change; the resource IDs and lab RG are the authoritative checks. > [!NOTE] > As part of Azure Monitor Agent installation, Azure can enable a system-assigned identity on the VM. That VM identity is separate from the pre-created user-assigned identity selected for alert queries; the wizard doesn't create the user-assigned alert identity. @@ -106,8 +115,8 @@ Repeat the Infrastructure monitoring flow for the Linux VM: 1. Keep **OpenTelemetry metrics** and its detailed/per-process option selected. 2. Leave **Classic Log-based metrics** off; Apache uses an OpenTelemetry process signal and doesn't require a Log Analytics workspace. -3. Let the portal select or create the same regional default Azure Monitor workspace. -4. Accept the generated DCR, dashboard, and recommended alerts. +3. Explicitly select the same Azure Monitor workspace in the lab resource group. +4. Keep the generated VM collection DCR, dashboard configuration, and recommended alert/action-group resources in the lab resource group. 5. Select the same pre-created user-assigned alert identity and enable monitoring. The portal installs `AzureMonitorWindowsAgent` or `AzureMonitorLinuxAgent`. Provisioning, DCR association, and the first metric samples take several minutes. @@ -115,7 +124,7 @@ The portal installs `AzureMonitorWindowsAgent` or `AzureMonitorLinuxAgent`. Prov ### Verify and customize the generated OpenTelemetry DCR 1. Open **Monitor** > **Data Collection Rules** > **Resources**. -2. Locate each VM and open its associated DCR whose name follows `MSVMOtel--`. +2. Locate each VM and open its associated DCR whose name follows `MSVMOtel--`. Confirm that this user-controlled VM collection DCR is in the lab group and targets the selected Azure Monitor workspace; it is not the Azure-managed workspace-internal DCR. 3. For the Linux DCR, open **Data sources** > **OpenTelemetry Performance Counters**. 4. Select **Custom** and confirm that `process.uptime` is included. Add it if it isn't present. 5. Optionally include `process.cpu.utilization` and `process.memory.usage` for richer investigation evidence. @@ -146,14 +155,14 @@ The first query must return recent data for each VM. The Linux query must return ## Task 3: Create service-specific alert paths (15 minutes) -Reuse the action group created by recommended monitoring alerts, or create `ag-mh-operators` in `destination-rg` with an email receiver you can check. +Reuse the action group created by recommended monitoring alerts **in the lab group**, or create `ag-mh-operators` in `` with an email receiver you can check. Save both service-specific alert rules below in that same group. ### Windows/IIS: collect the authoritative service event Create a focused DCR: 1. Open **Monitor** > **Data Collection Rules** and select **Create**. -2. Name it `dcr-iis-service-events`, use `destination-rg`, the VM's region, and platform type **Windows**. +2. Name it `dcr-iis-service-events`, use ``, the VM's region, and platform type **Windows**. 3. On **Resources**, add the Windows VM. Leave **Enable Data Collection Endpoints** off unless your network design requires a DCE. 4. Add a **Windows Event Logs** data source. 5. Select **Custom** and enter: @@ -162,7 +171,7 @@ Create a focused DCR: System!*[System[(EventID=7036)]] ``` -6. Set the destination to **Azure Monitor Logs** and select the compatible default Log Analytics workspace chosen during Windows onboarding. +6. Set the destination to **Azure Monitor Logs** and select the compatible regional Log Analytics workspace in the lab group chosen during Windows onboarding. 7. Create the DCR and verify its VM association. Confirm that the DCR's **Resources** page shows the Windows VM. You can use the Log Analytics workspace's **Logs** page to inspect any service-control records collected after the association: @@ -225,7 +234,7 @@ Configure: | Failing period | 1 minute | | Auto resolution | Enabled | | Severity | 2 - Warning | -| Managed identity | Pre-created user-assigned identity with **Monitoring Reader** on the Linux VM or destination resource group and, for workspace scope, the Azure Monitor workspace | +| Managed identity | Pre-created user-assigned identity with **Monitoring Reader** on the lab resource group, covering the Linux VM and Azure Monitor workspace | | Action group | `ag-mh-operators` | `apache2` normally has a parent and multiple workers, each with its own `process.uptime` series. `absent_over_time` returns no result while any matching series exists and returns `1` only after all matching series have been absent for the full five-minute window. Allow up to 10 minutes after the stop command for the final sample, absence window, collection, and alert evaluation before treating a missing alert as a configuration problem. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md index 689686896..ab74f4ba9 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md @@ -235,7 +235,7 @@ For both paths, the archive must contain `index.html` and all four image/style a ## Task 3: Verify Azure scope and create the App Service target (10 minutes) -The Bicep deployment names the destination resource group `MHBox--destination-rg`, where `` is the deployer's user principal name before `@`. Find the exact name under **Resource groups** in the Azure portal. After selecting the intended subscription, you can also run `az group list --query "[?ends_with(name, '-destination-rg')].[name,location]" --output table`. +Use the exact existing `` and subscription ID from the platform dashboard. This single group already contains the source Hyper-V environment and migrated Azure VMs; App Service resources also belong here. The platform supplies the group name; do not infer it from a deployer's UPN or create a separate destination group. Replace every placeholder below before running a resource command. Each Path B block repeats the required subscription and resource-group values and derives the same stable plan and app names, so it can be rerun from a new SSH session. @@ -245,15 +245,15 @@ Open Azure Cloud Shell in the portal and select **PowerShell**. Verify the Az Po ```powershell $subscriptionId = '' -$destinationRg = 'MHBox--destination-rg' +$labResourceGroup = '' if ([string]::IsNullOrWhiteSpace($subscriptionId) -or $subscriptionId -eq '') { throw 'Replace $subscriptionId with the Hack subscription ID.' } -if ([string]::IsNullOrWhiteSpace($destinationRg) -or - $destinationRg -match '[<>]') { - throw 'Replace $destinationRg with the exact destination resource-group name.' +if ([string]::IsNullOrWhiteSpace($labResourceGroup) -or + $labResourceGroup -match '[<>]') { + throw 'Replace $labResourceGroup with the exact lab resource-group name.' } if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { @@ -285,13 +285,13 @@ if ($azContext.Subscription.Id -ne $cliContext.subscriptionId) { throw 'Az PowerShell and Azure CLI are using different subscriptions.' } -$resourceGroup = Get-AzResourceGroup -Name $destinationRg -ErrorAction Stop +$resourceGroup = Get-AzResourceGroup -Name $labResourceGroup -ErrorAction Stop $location = $resourceGroup.Location $resourceGroup | Select-Object ResourceGroupName, Location ``` > [!WARNING] -> Stop if either displayed context isn't the Hack subscription or if the resource group isn't your `MHBox--destination-rg`. +> Stop if either displayed context isn't the Hack subscription or if the resource group isn't the exact lab group shown in your platform dashboard. Create the Windows plan and app: @@ -302,20 +302,20 @@ $appName = "mh-web-win-$suffix" az appservice plan create ` --name $planName ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --location $location ` --sku B1 ` --is-linux false az webapp create ` --name $appName ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --plan $planName ` --https-only true az webapp show ` --name $appName ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --query '{name:name,host:defaultHostName,httpsOnly:httpsOnly,state:state}' ` --output table @@ -335,7 +335,7 @@ if bash /dev/fd/3 3<<'PATH_B_SCOPE' set -euo pipefail subscription_id='' -destination_rg='MHBox--destination-rg' +lab_rg='' installer_path="$(mktemp)" cleanup_installer() { @@ -351,10 +351,10 @@ if [[ -z "${subscription_id}" || echo 'Replace subscription_id with the Hack subscription ID.' >&2 false fi -if [[ -z "${destination_rg}" || - "${destination_rg}" == *'<'* || - "${destination_rg}" == *'>'* ]]; then - echo 'Replace destination_rg with the exact destination resource-group name.' >&2 +if [[ -z "${lab_rg}" || + "${lab_rg}" == *'<'* || + "${lab_rg}" == *'>'* ]]; then + echo 'Replace lab_rg with the exact lab resource-group name.' >&2 false fi @@ -372,22 +372,22 @@ account_show_args=(account show --query '{subscription:name,subscriptionId:id,te az "${account_show_args[@]}" active_subscription_id="$(az account show --query id --output tsv)" -destination_rg="$(az group show --name "${destination_rg}" --query name --output tsv)" -location="$(az group show --name "${destination_rg}" --query location --output tsv)" +lab_rg="$(az group show --name "${lab_rg}" --query name --output tsv)" +location="$(az group show --name "${lab_rg}" --query location --output tsv)" if [[ -z "${location}" ]]; then - echo "Could not resolve ${destination_rg} in the active subscription." >&2 + echo "Could not resolve ${lab_rg} in the active subscription." >&2 false fi name_suffix="$( - printf '%s\0%s' "${active_subscription_id}" "${destination_rg}" | + printf '%s\0%s' "${active_subscription_id}" "${lab_rg}" | sha256sum | cut -c1-12 )" plan_name="asp-mh-linux-${name_suffix}" app_name="mh-web-linux-${name_suffix}" -printf 'Destination resource group: %s (%s)\nPlan name: %s\nWeb app name: %s\n' "${destination_rg}" "${location}" "${plan_name}" "${app_name}" +printf 'Lab resource group: %s (%s)\nPlan name: %s\nWeb app name: %s\n' "${lab_rg}" "${location}" "${plan_name}" "${app_name}" PATH_B_SCOPE then echo 'Path B scope verification completed.' @@ -398,10 +398,10 @@ else fi ``` -Complete the sign-in in the browser using the displayed code. Do not paste passwords, tokens, or publishing credentials into the shell or lab notes. The stable 12-character suffix is derived only from Azure's validated subscription ID and canonical destination resource-group name; it contains no secret and is reproduced by every later block. +Complete the sign-in in the browser using the displayed code. Do not paste passwords, tokens, or publishing credentials into the shell or lab notes. The stable 12-character suffix is derived only from Azure's validated subscription ID and canonical lab resource-group name; it contains no secret and is reproduced by every later block. > [!WARNING] -> Stop if the displayed subscription, tenant, or user doesn't identify the Hack subscription, or if the resource group isn't your `MHBox--destination-rg`. +> Stop if the displayed subscription, tenant, or user doesn't identify the Hack subscription, or if the resource group isn't the exact lab group shown in your platform dashboard. Resolve the newest advertised Node.js LTS runtime and create a Linux target: @@ -410,28 +410,28 @@ if bash /dev/fd/3 3<<'PATH_B_CREATE' set -euo pipefail subscription_id='' -destination_rg='MHBox--destination-rg' +lab_rg='' if [[ -z "${subscription_id}" || "${subscription_id}" == '' ]]; then echo 'Replace subscription_id with the Hack subscription ID.' >&2 false fi -if [[ -z "${destination_rg}" || - "${destination_rg}" == *'<'* || - "${destination_rg}" == *'>'* ]]; then - echo 'Replace destination_rg with the exact destination resource-group name.' >&2 +if [[ -z "${lab_rg}" || + "${lab_rg}" == *'<'* || + "${lab_rg}" == *'>'* ]]; then + echo 'Replace lab_rg with the exact lab resource-group name.' >&2 false fi command -v az >/dev/null az account set --subscription "${subscription_id}" active_subscription_id="$(az account show --query id --output tsv)" -destination_rg="$(az group show --name "${destination_rg}" --query name --output tsv)" -location="$(az group show --name "${destination_rg}" --query location --output tsv)" +lab_rg="$(az group show --name "${lab_rg}" --query name --output tsv)" +location="$(az group show --name "${lab_rg}" --query location --output tsv)" name_suffix="$( - printf '%s\0%s' "${active_subscription_id}" "${destination_rg}" | + printf '%s\0%s' "${active_subscription_id}" "${lab_rg}" | sha256sum | cut -c1-12 )" @@ -489,17 +489,17 @@ runtime="${runtime_candidates%%$'\n'*}" printf 'Discovered Node LTS runtimes, normalized for az webapp create:\n%s\n' "${runtime_candidates}" printf 'Selected runtime: %s\n' "${runtime}" -plan_list_args=(appservice plan list --resource-group "${destination_rg}" --query "[?name=='${plan_name}'].id | [0]" --output tsv) +plan_list_args=(appservice plan list --resource-group "${lab_rg}" --query "[?name=='${plan_name}'].id | [0]" --output tsv) plan_id="$(az "${plan_list_args[@]}")" if [[ -z "${plan_id}" ]]; then - plan_create_args=(appservice plan create --name "${plan_name}" --resource-group "${destination_rg}" --location "${location}" --sku B1 --is-linux true --output none) + plan_create_args=(appservice plan create --name "${plan_name}" --resource-group "${lab_rg}" --location "${location}" --sku B1 --is-linux true --output none) az "${plan_create_args[@]}" - plan_id="$(az appservice plan show --name "${plan_name}" --resource-group "${destination_rg}" --query id --output tsv)" + plan_id="$(az appservice plan show --name "${plan_name}" --resource-group "${lab_rg}" --query id --output tsv)" echo "Created Linux App Service plan: ${plan_name}" else - plan_is_linux="$(az appservice plan show --name "${plan_name}" --resource-group "${destination_rg}" --query reserved --output tsv)" - plan_location="$(az appservice plan show --name "${plan_name}" --resource-group "${destination_rg}" --query location --output tsv)" - plan_sku="$(az appservice plan show --name "${plan_name}" --resource-group "${destination_rg}" --query sku.name --output tsv)" + plan_is_linux="$(az appservice plan show --name "${plan_name}" --resource-group "${lab_rg}" --query reserved --output tsv)" + plan_location="$(az appservice plan show --name "${plan_name}" --resource-group "${lab_rg}" --query location --output tsv)" + plan_sku="$(az appservice plan show --name "${plan_name}" --resource-group "${lab_rg}" --query sku.name --output tsv)" normalized_plan_location="${plan_location// /}" normalized_location="${location// /}" if [[ "${plan_is_linux,,}" != 'true' || @@ -511,16 +511,16 @@ else echo "Reusing compatible Linux App Service plan: ${plan_name}" fi -app_list_args=(webapp list --resource-group "${destination_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) +app_list_args=(webapp list --resource-group "${lab_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) app_id="$(az "${app_list_args[@]}")" if [[ -z "${app_id}" ]]; then - app_create_args=(webapp create --name "${app_name}" --resource-group "${destination_rg}" --plan "${plan_name}" --runtime "${runtime}" --https-only true --output none) + app_create_args=(webapp create --name "${app_name}" --resource-group "${lab_rg}" --plan "${plan_name}" --runtime "${runtime}" --https-only true --output none) az "${app_create_args[@]}" echo "Created Linux web app: ${app_name}" else - existing_plan_id="$(az webapp show --name "${app_name}" --resource-group "${destination_rg}" --query serverFarmId --output tsv)" - existing_https_only="$(az webapp show --name "${app_name}" --resource-group "${destination_rg}" --query httpsOnly --output tsv)" - existing_runtime="$(az webapp config show --name "${app_name}" --resource-group "${destination_rg}" --query linuxFxVersion --output tsv)" + existing_plan_id="$(az webapp show --name "${app_name}" --resource-group "${lab_rg}" --query serverFarmId --output tsv)" + existing_https_only="$(az webapp show --name "${app_name}" --resource-group "${lab_rg}" --query httpsOnly --output tsv)" + existing_runtime="$(az webapp config show --name "${app_name}" --resource-group "${lab_rg}" --query linuxFxVersion --output tsv)" existing_runtime_canonical="$(printf '%s' "${existing_runtime/|/:}" | tr '[:upper:]' '[:lower:]')" supported_runtime_candidates="$(printf '%s\n' "${runtime_candidates}" | tr '[:upper:]' '[:lower:]')" if [[ "${existing_plan_id,,}" != "${plan_id,,}" || @@ -534,13 +534,13 @@ else runtime="${existing_runtime_canonical}" fi -appsettings_args=(webapp config appsettings set --resource-group "${destination_rg}" --name "${app_name}" --settings SCM_DO_BUILD_DURING_DEPLOYMENT=false --output none) +appsettings_args=(webapp config appsettings set --resource-group "${lab_rg}" --name "${app_name}" --settings SCM_DO_BUILD_DURING_DEPLOYMENT=false --output none) az "${appsettings_args[@]}" -startup_args=(webapp config set --resource-group "${destination_rg}" --name "${app_name}" --startup-file "${startup_command}" --output none) +startup_args=(webapp config set --resource-group "${lab_rg}" --name "${app_name}" --startup-file "${startup_command}" --output none) az "${startup_args[@]}" -app_show_args=(webapp show --name "${app_name}" --resource-group "${destination_rg}" --query '{name:name,host:defaultHostName,httpsOnly:httpsOnly,state:state}' --output table) +app_show_args=(webapp show --name "${app_name}" --resource-group "${lab_rg}" --query '{name:name,host:defaultHostName,httpsOnly:httpsOnly,state:state}' --output table) az "${app_show_args[@]}" -config_show_args=(webapp config show --name "${app_name}" --resource-group "${destination_rg}" --query '{runtime:linuxFxVersion,startup:appCommandLine}' --output table) +config_show_args=(webapp config show --name "${app_name}" --resource-group "${lab_rg}" --query '{runtime:linuxFxVersion,startup:appCommandLine}' --output table) az "${config_show_args[@]}" printf 'Runtime: %s\nPlan name: %s\nWeb app name: %s\nHTTPS URL: https://%s.azurewebsites.net\n' "${runtime}" "${plan_name}" "${app_name}" "${app_name}" @@ -554,7 +554,7 @@ else fi ``` -If runtime discovery stopped this step on an earlier attempt, paste and run the entire corrected block again with the same subscription ID and destination resource group. Its stable names reconcile compatible existing resources instead of creating duplicates. Continue only after the block reports success. +If runtime discovery stopped this step on an earlier attempt, paste and run the entire corrected block again with the same subscription ID and lab resource group. Its stable names reconcile compatible existing resources instead of creating duplicates. Continue only after the block reports success. `az webapp list-runtimes` is the source of truth for currently available built-in runtimes. The block chooses the highest advertised Node.js LTS major version for a new app and fails rather than inventing a fallback. On rerun, it reuses only the same Linux B1 plan and HTTPS-enabled app when their plan association and Node LTS runtime remain compatible. PM2 runs in the foreground, listens on the App Service-provided `$PORT`, and serves the ready-to-run static ZIP from `/home/site/wwwroot`. `B1` incurs charges until the plan is deleted. @@ -574,7 +574,7 @@ If the package is available in a shell with Azure CLI, this is an equivalent sup ```powershell az webapp deploy ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --name $appName ` --src-path '' ` --type zip @@ -591,7 +591,7 @@ if bash /dev/fd/3 3<<'PATH_B_DEPLOY' set -euo pipefail subscription_id='' -destination_rg='MHBox--destination-rg' +lab_rg='' package_path="${HOME}/microhack-app.zip" if [[ -z "${subscription_id}" || @@ -599,10 +599,10 @@ if [[ -z "${subscription_id}" || echo 'Replace subscription_id with the Hack subscription ID.' >&2 false fi -if [[ -z "${destination_rg}" || - "${destination_rg}" == *'<'* || - "${destination_rg}" == *'>'* ]]; then - echo 'Replace destination_rg with the exact destination resource-group name.' >&2 +if [[ -z "${lab_rg}" || + "${lab_rg}" == *'<'* || + "${lab_rg}" == *'>'* ]]; then + echo 'Replace lab_rg with the exact lab resource-group name.' >&2 false fi if [[ ! -f "${package_path}" ]]; then @@ -639,15 +639,15 @@ fi command -v az >/dev/null az account set --subscription "${subscription_id}" active_subscription_id="$(az account show --query id --output tsv)" -destination_rg="$(az group show --name "${destination_rg}" --query name --output tsv)" +lab_rg="$(az group show --name "${lab_rg}" --query name --output tsv)" name_suffix="$( - printf '%s\0%s' "${active_subscription_id}" "${destination_rg}" | + printf '%s\0%s' "${active_subscription_id}" "${lab_rg}" | sha256sum | cut -c1-12 )" plan_name="asp-mh-linux-${name_suffix}" app_name="mh-web-linux-${name_suffix}" -app_list_args=(webapp list --resource-group "${destination_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) +app_list_args=(webapp list --resource-group "${lab_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) app_id="$(az "${app_list_args[@]}")" if [[ -z "${app_id}" ]]; then echo "Web app ${app_name} doesn't exist. Complete Task 3 first." >&2 @@ -656,7 +656,7 @@ fi printf 'Plan name: %s\nWeb app name: %s\nPackage: %s\n' "${plan_name}" "${app_name}" "${package_path}" -deploy_args=(webapp deploy --resource-group "${destination_rg}" --name "${app_name}" --src-path "${package_path}" --type zip) +deploy_args=(webapp deploy --resource-group "${lab_rg}" --name "${app_name}" --src-path "${package_path}" --type zip) az "${deploy_args[@]}" PATH_B_DEPLOY then @@ -743,26 +743,26 @@ if bash /dev/fd/3 3<<'PATH_B_VALIDATE' set -euo pipefail subscription_id='' -destination_rg='MHBox--destination-rg' +lab_rg='' if [[ -z "${subscription_id}" || "${subscription_id}" == '' ]]; then echo 'Replace subscription_id with the Hack subscription ID.' >&2 false fi -if [[ -z "${destination_rg}" || - "${destination_rg}" == *'<'* || - "${destination_rg}" == *'>'* ]]; then - echo 'Replace destination_rg with the exact destination resource-group name.' >&2 +if [[ -z "${lab_rg}" || + "${lab_rg}" == *'<'* || + "${lab_rg}" == *'>'* ]]; then + echo 'Replace lab_rg with the exact lab resource-group name.' >&2 false fi command -v az >/dev/null az account set --subscription "${subscription_id}" active_subscription_id="$(az account show --query id --output tsv)" -destination_rg="$(az group show --name "${destination_rg}" --query name --output tsv)" +lab_rg="$(az group show --name "${lab_rg}" --query name --output tsv)" name_suffix="$( - printf '%s\0%s' "${active_subscription_id}" "${destination_rg}" | + printf '%s\0%s' "${active_subscription_id}" "${lab_rg}" | sha256sum | cut -c1-12 )" @@ -803,7 +803,7 @@ for asset in stylesheet.css GitHub_Logo.png MSLogo.png MSicon.png; do curl "${asset_curl_args[@]}" "${app_url}/${asset}" done -app_show_args=(webapp show --resource-group "${destination_rg}" --name "${app_name}" --query '{state:state,httpsOnly:httpsOnly,host:defaultHostName}' --output table) +app_show_args=(webapp show --resource-group "${lab_rg}" --name "${app_name}" --query '{state:state,httpsOnly:httpsOnly,host:defaultHostName}' --output table) az "${app_show_args[@]}" if ! sudo systemctl is-active --quiet apache2; then @@ -876,17 +876,17 @@ Document which target you would choose and why. ### Optional resource cleanup -Do not delete resources automatically. If the lab owner approves cleanup, delete the web app and plan. Path B reconstructs its stable names from the validated subscription and resource group: +Do not delete resources automatically. If the lab owner approves cleanup, delete only the web app and its plan using the blocks below. Keep `` through the exercise: it also contains the simulated source, migrated VMs, and monitoring. Whole-lab deletion belongs to the [approved finish cleanup](../../challenges/finish.md), after supported backup/replication cleanup. Path B reconstructs its stable names from the validated subscription and resource group: Path A, Azure Cloud Shell PowerShell: ```powershell az webapp delete ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --name $appName az appservice plan delete ` - --resource-group $destinationRg ` + --resource-group $labResourceGroup ` --name $planName ` --yes ``` @@ -898,26 +898,26 @@ if bash /dev/fd/3 3<<'PATH_B_CLEANUP' set -euo pipefail subscription_id='' -destination_rg='MHBox--destination-rg' +lab_rg='' if [[ -z "${subscription_id}" || "${subscription_id}" == '' ]]; then echo 'Replace subscription_id with the Hack subscription ID.' >&2 false fi -if [[ -z "${destination_rg}" || - "${destination_rg}" == *'<'* || - "${destination_rg}" == *'>'* ]]; then - echo 'Replace destination_rg with the exact destination resource-group name.' >&2 +if [[ -z "${lab_rg}" || + "${lab_rg}" == *'<'* || + "${lab_rg}" == *'>'* ]]; then + echo 'Replace lab_rg with the exact lab resource-group name.' >&2 false fi command -v az >/dev/null az account set --subscription "${subscription_id}" active_subscription_id="$(az account show --query id --output tsv)" -destination_rg="$(az group show --name "${destination_rg}" --query name --output tsv)" +lab_rg="$(az group show --name "${lab_rg}" --query name --output tsv)" name_suffix="$( - printf '%s\0%s' "${active_subscription_id}" "${destination_rg}" | + printf '%s\0%s' "${active_subscription_id}" "${lab_rg}" | sha256sum | cut -c1-12 )" @@ -926,10 +926,10 @@ app_name="mh-web-linux-${name_suffix}" printf 'Plan name: %s\nWeb app name: %s\n' "${plan_name}" "${app_name}" cleanup_failed=0 -app_list_args=(webapp list --resource-group "${destination_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) +app_list_args=(webapp list --resource-group "${lab_rg}" --query "[?name=='${app_name}'].id | [0]" --output tsv) app_id="$(az "${app_list_args[@]}")" if [[ -n "${app_id}" ]]; then - app_delete_args=(webapp delete --resource-group "${destination_rg}" --name "${app_name}") + app_delete_args=(webapp delete --resource-group "${lab_rg}" --name "${app_name}") if ! az "${app_delete_args[@]}"; then echo "Could not delete web app ${app_name}." >&2 cleanup_failed=1 @@ -938,10 +938,10 @@ else echo "Web app is already absent: ${app_name}" fi -plan_list_args=(appservice plan list --resource-group "${destination_rg}" --query "[?name=='${plan_name}'].id | [0]" --output tsv) +plan_list_args=(appservice plan list --resource-group "${lab_rg}" --query "[?name=='${plan_name}'].id | [0]" --output tsv) plan_id="$(az "${plan_list_args[@]}")" if [[ -n "${plan_id}" ]]; then - plan_delete_args=(appservice plan delete --resource-group "${destination_rg}" --name "${plan_name}" --yes) + plan_delete_args=(appservice plan delete --resource-group "${lab_rg}" --name "${plan_name}" --yes) if ! az "${plan_delete_args[@]}"; then echo "Could not delete App Service plan ${plan_name}." >&2 cleanup_failed=1 From d8bcec66e93ef50b9dd2227aff071dd0aa32efcb Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Mon, 2 Nov 2026 21:09:55 +0100 Subject: [PATCH 02/20] Configure repository deployment sources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deploy-lab.ps1 | 25 ++++++++++++++----- .../labautomation/deployment-settings.json | 4 +++ .../resources/README.md | 17 ++++++++++++- 3 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 0c1fa7be7..643674d76 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -28,6 +28,24 @@ foreach ($participantId in $AllowedEntraUserIds) { } $AllowedEntraUserIds = @($AllowedEntraUserIds | Sort-Object -Unique) +$sourceSettingsPath = Join-Path $PSScriptRoot 'deployment-settings.json' +$sourceSettings = Get-Content -LiteralPath $sourceSettingsPath -Raw | ConvertFrom-Json -AsHashtable -NoEnumerate +if ($sourceSettings -isnot [Collections.IDictionary] -or @($sourceSettings.Keys).Count -ne 2 -or + @($sourceSettings.Keys | Where-Object { $_ -cnotin @('githubAccount', 'githubBranch') }).Count -gt 0) { + throw 'deployment-settings.json must contain only githubAccount and githubBranch.' +} +foreach ($key in @('githubAccount', 'githubBranch')) { + if ($sourceSettings[$key] -isnot [string] -or [string]::IsNullOrWhiteSpace($sourceSettings[$key])) { + throw "deployment-settings.json requires a nonempty string for $key." + } +} +$githubAccount = if ($env:MICROHACK_GITHUB_ACCOUNT) { $env:MICROHACK_GITHUB_ACCOUNT } else { $sourceSettings['githubAccount'] } +$githubBranch = if ($env:MICROHACK_GITHUB_BRANCH) { $env:MICROHACK_GITHUB_BRANCH } else { $sourceSettings['githubBranch'] } +if ($githubAccount -notmatch '^[A-Za-z0-9][A-Za-z0-9-]*$' -or + $githubBranch -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $githubBranch.Contains('..')) { + throw 'Invalid GitHub artifact source in deployment-settings.json or MICROHACK_GITHUB_ACCOUNT/MICROHACK_GITHUB_BRANCH overrides.' +} + foreach ($helper in @('Get-MhhStableHash', 'New-MhhStablePassword', 'Update-MhhToken')) { Get-Command $helper -ErrorAction Stop | Out-Null } @@ -194,12 +212,7 @@ if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningS throw "No eligible deployment region among $($candidateLocations -join ', '). Resources are preserved; an existing lab cannot fall back to another region." } - $githubAccount = if ($env:MICROHACK_GITHUB_ACCOUNT) { $env:MICROHACK_GITHUB_ACCOUNT } else { 'microsoft' } - $githubBranch = if ($env:MICROHACK_GITHUB_BRANCH) { $env:MICROHACK_GITHUB_BRANCH } else { 'main' } - if ($githubAccount -notmatch '^[A-Za-z0-9][A-Za-z0-9-]*$' -or - $githubBranch -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $githubBranch.Contains('..')) { - throw 'Invalid MICROHACK_GITHUB_ACCOUNT or MICROHACK_GITHUB_BRANCH artifact source.' - } + Write-Host "Artifact source: $githubAccount/MicroHack at $githubBranch" $artifactUrl = "https://raw.githubusercontent.com/$githubAccount/MicroHack/$githubBranch/03-Azure/01-03-Infrastructure/$hackName/resources/artifacts/Bootstrap.ps1" Invoke-WebRequest -Uri $artifactUrl -Method Head -UseBasicParsing -TimeoutSec 30 | Out-Null Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json new file mode 100644 index 000000000..a4d90c49d --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json @@ -0,0 +1,4 @@ +{ + "githubAccount": "ASchwarz1984", + "githubBranch": "aschwarz1984-migration-lab" +} diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 71811e140..6310b05fe 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -65,7 +65,22 @@ Use the current standard template's [local testing harness](../../../../99-Micro 4. Inside the harness, invoke `deploy-lab.ps1` from this Hack's `labautomation` directory with all five parameters from the table above, using `DeploymentType` = `resourcegroup`, the existing test resource group, ordered region array, and learner object-ID array. Confirm that the reported selected region and RG **LabRegion** tag match the resources, not necessarily the RG's metadata location. Follow the standard harness invocation syntax and helper limitations. 5. Verify the returned credential fields (the platform dashboard shows these for hosted runs) and Key Vault access as the test learner, then follow Solution 1's **initial-handoff** readiness checks. Also verify that a completed rerun preserves learner state and does not restart migrated source guests. Syntax/build checks alone are not evidence of a live deployment. -The official source remains `microsoft/MicroHack` on `main`. For premerge fork testing, check out the fork's feature branch locally **and** set `MICROHACK_GITHUB_ACCOUNT` and `MICROHACK_GITHUB_BRANCH` in the harness/deployment environment to that owner and branch before invocation. These environment variables default to `microsoft` and `main` and select downloaded automation and demo-page assets via the existing Bicep `githubAccount` and `githubBranch` parameters. The local checkout and asset reference must match, and the branch must retain the Hack resource paths. Do not change the official repository identifiers in documentation or templates just to test a fork. +### Select the artifact source without runner access + +The script automatically reads [deployment-settings.json](../labautomation/deployment-settings.json) from its own directory. This branch is configured for the fork: + +```json +{ + "githubAccount": "ASchwarz1984", + "githubBranch": "aschwarz1984-migration-lab" +} +``` + +Edit these two nonsecret values in the repository and import/re-import that version of the `labautomation` folder. No runner environment changes are required. The selected owner and branch (or commit SHA) must contain the Hack's existing `resources/artifacts` paths, and the imported templates/settings must match that source version. The script passes the values through Bicep and Bootstrap to both guests and all demo-page asset downloads. + +Keep the file valid and present: missing/malformed settings fail instead of silently downloading another version. Do not add these fields to `lab-defaults.json`; the platform schema rejects additional properties. Optional `MICROHACK_GITHUB_ACCOUNT` and `MICROHACK_GITHUB_BRANCH` environment variables still override the corresponding JSON values when an operator controls the runner. + +For official upstream deployment, change the settings to `"githubAccount": "microsoft"` and `"githubBranch": "main"`. The repository name `MicroHack`, artifact paths, published defaults schema, and demo-page repository link remain unchanged. ## Resource lifecycle From 7a58450f8fa20a833b9fac362cdfc3f31d2781b3 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 08:38:14 +0100 Subject: [PATCH 03/20] Handle wrapped ARM deployment lookup errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deploy-lab.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 643674d76..126ad5778 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -250,9 +250,13 @@ if ($null -eq $deployment -or $deployment.ProvisioningState -ne 'Succeeded') { try { $deployment = Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName -Name $deploymentName -ErrorAction Stop } catch { - $notRegistered = $_.Exception.Response.StatusCode -eq 404 -or - $_.FullyQualifiedErrorId -match 'DeploymentNotFound|ResourceNotFound' -or + $notRegistered = $_.FullyQualifiedErrorId -match 'DeploymentNotFound|ResourceNotFound' -or $_.ErrorDetails.Message -match '"code"\s*:\s*"(DeploymentNotFound|ResourceNotFound)"' + # Az.Resources can wrap the HTTP response in an inner CloudException. + for ($lookupError = $_.Exception; $null -ne $lookupError -and -not $notRegistered; $lookupError = $lookupError.InnerException) { + $notRegistered = $lookupError.Response.StatusCode -eq 404 -or + $lookupError.Body.Code -in @('DeploymentNotFound', 'ResourceNotFound') + } if (-not $notRegistered) { throw } $deployment = $null } @@ -271,6 +275,9 @@ if ($null -eq $deployment -or $deployment.ProvisioningState -ne 'Succeeded') { if ((Get-Date).ToUniversalTime() -ge $armDeadline) { throw "ARM deployment '$deploymentName' timed out after 180 minutes. It may still be running; the RG is preserved and a rerun resumes polling." } + if ($null -eq $deployment) { + Write-Host "Waiting for ARM deployment '$deploymentName' to become visible; polling again in 30 seconds." + } Start-Sleep -Seconds 30 } while ($true) } From ded2f80b3b4931f396c664beb4d4f8d2375338f4 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 10:43:31 +0100 Subject: [PATCH 04/20] Surface ARM submission job errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deploy-lab.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 126ad5778..2258c7d10 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -270,7 +270,13 @@ if ($null -eq $deployment -or $deployment.ProvisioningState -ne 'Succeeded') { throw "ARM deployment ended as $($deployment.ProvisioningState). Inspect deployment operations; resources and the selected region are preserved." } } elseif ($null -ne $submissionJob -and $submissionJob.State -in @('Failed', 'Stopped')) { - throw "ARM submission failed before the deployment was registered: $($submissionJob.JobStateInfo.Reason)" + Write-Host "ARM submission job $($submissionJob.Id) ended as $($submissionJob.State); reading retained errors." + Receive-Job -Job $submissionJob -Keep -ErrorAction Stop | Out-Null + $reason = [string]$submissionJob.JobStateInfo.Reason + if ([string]::IsNullOrWhiteSpace($reason)) { + $reason = "Job $($submissionJob.Id) ended as $($submissionJob.State) with no retained error record. Inspect the runner's job diagnostics; resources are preserved." + } + throw "ARM submission failed before the deployment was registered: $reason" } if ((Get-Date).ToUniversalTime() -ge $armDeadline) { throw "ARM deployment '$deploymentName' timed out after 180 minutes. It may still be running; the RG is preserved and a rerun resumes polling." From 3a3c0db0d903f6e7e3ea67d86f8c3f02767127a8 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 11:27:00 +0100 Subject: [PATCH 05/20] Fix ARM password parameter serialization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 2258c7d10..2467881dd 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -231,7 +231,8 @@ if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningS labSuffix = $labSuffix participantObjectIds = $AllowedEntraUserIds location = $location - windowsAdminPassword = ConvertTo-SecureString $password -AsPlainText -Force + # Use a JSON string here; Bicep's @secure() retains ARM secret protection. + windowsAdminPassword = $password githubAccount = $githubAccount githubBranch = $githubBranch } From 1c2b98b0f0a8b9f985d4d14b5c8586ca64d24801 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 12:32:15 +0100 Subject: [PATCH 06/20] Apply learner RG security and cost exemptions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deploy-lab.ps1 | 7 ++++++- .../06_Migration_Secure_AI_Ready/resources/README.md | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 2467881dd..a1b788954 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -112,6 +112,11 @@ if ($location -and $location -notin $locations) { if (-not $location -and ($existingResources.Count -gt 0 -or $null -ne $deployment)) { throw 'Cannot determine the existing lab region from its LabRegion tag, host, or deployment parameters. Inspect the lab before retrying.' } +Write-Host "Applying RG policy exemptions on '$ResourceGroupName': SecurityControl=Ignore, CostControl=Ignore." +Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ + SecurityControl = 'Ignore' + CostControl = 'Ignore' +} | Out-Null if (-not $alreadyReady -and $groupTags['LabVerified']) { Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ LabVerified = 'pending' } | Out-Null } @@ -338,7 +343,7 @@ foreach ($name in $expected) { } Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ LabVerified = $labVersion } | Out-Null } else { - Write-Host 'Reusing the completed baseline without changing resources or restarting nested VMs. Later learner work is preserved.' + Write-Host 'Reusing the completed baseline without redeploying resources or restarting nested VMs. Later learner work is preserved.' } $outputs = $deployment.Outputs diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 6310b05fe..9e1a406cb 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -28,6 +28,10 @@ The platform first runs [shared-deploy-lab.ps1](../labautomation/shared-deploy-l Before per-learner invocation, the platform creates the resource group, authenticates Az PowerShell and Azure CLI, selects the subscription, and assigns the learner **Owner** on the resource group and **Reader** on the subscription. The hosted script neither signs in nor creates another resource group. Instructors must verify that the shared provider-registration hook succeeded, quota is sufficient, and any optional Defender plans were separately approved and enabled; do not grant subscription Owner to every learner. +**Approved training policy exemptions:** After validating the RG scope and existing-lab consistency, the deployer merges `SecurityControl=Ignore` and `CostControl=Ignore` into the learner RG's tags before submitting or resuming ARM. Completed reruns also apply these tags. Existing values for these two keys are replaced; unrelated tags are preserved. A tag-write failure stops the script before deployment or credential publication. + +Use this automation only for training RGs approved for these **RG-wide exemptions**, including later learner-created resources. The tags affect only governance controls that explicitly honor them; they are not general Azure Policy exemptions. Where supported, the security exemption avoids inherited public-IP `ipTags` injection that can trigger the `AllowBringYourOwnPublicIpAddress` feature requirement. The deployer does not register subscription features or change Defender plans. The cost-control exemption does **not** prevent Azure charges; the planning estimate and event cost management still apply. + [main.bicep](../labautomation/bicep/main.bicep) and its local modules are packaged together under `labautomation/bicep`, so importing or mounting only `labautomation` is sufficient for template deployment. The guest automation and demo-page assets remain under `resources/artifacts` and are downloaded from the selected repository/branch. The template deploys at **resource-group scope** into the existing group. For a fresh lab, the deployer tries `PreferredLocation` in order and warns when skipping a region. Preflight checks provider-advertised service regions, the documented Bastion Developer regional list, zone-1 availability of `Standard_D16s_v5` and `PremiumV2_LRS`, and available regional/DSv5 quota for a new host. These checks do not reserve capacity or guarantee that a later ARM deployment will succeed. With only the default `swedencentral` configured, there is no second candidate; instructors can configure an ordered list of suitable regions before deployment. From a7273f2c06a940f14cdbccf35e1844a83cda506a Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 14:07:14 +0100 Subject: [PATCH 07/20] Fix bootstrap shutdown schedule authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../bicep/clientVm/clientVm.bicep | 14 +++++ .../resources/README.md | 2 + .../resources/artifacts/Bootstrap.ps1 | 57 +++++++++++++------ 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep index ea29e1e95..9d6068586 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep @@ -229,12 +229,15 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { vmTagContributor vmDiskContributor vmScheduleContributor + vmShutdownTargetContributor ] properties: { publisher: 'Microsoft.Compute' type: 'CustomScriptExtension' typeHandlerVersion: '1.10' autoUpgradeMinorVersion: true + // Rerun failed Bootstrap for this fix without changing the tag on every deployment. + forceUpdateTag: 'bootstrap-shutdown-rbac-v1' protectedSettings: { fileUris: [ uri(templateBaseUrl, 'artifacts/Bootstrap.ps1') @@ -307,6 +310,17 @@ resource vmScheduleContributor 'Microsoft.Authorization/roleAssignments@2022-04- } } +// Schedule writes also authorize Microsoft.Compute/virtualMachines/write on the linked VM. +resource vmShutdownTargetContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (autoShutdownEnabled) { + name: guid(vm.id, 'shutdown-target-contributor') + scope: vm + properties: { + principalId: vm.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '9980e02c-c2be-4d73-94e8-173b1dc7cf3c') + } +} + resource autoShutdown 'Microsoft.DevTestLab/schedules@2018-09-15' = if (autoShutdownEnabled) { name: 'shutdown-computevm-${vm.name}' location: location diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 9e1a406cb..7d5cae90c 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -34,6 +34,8 @@ Use this automation only for training RGs approved for these **RG-wide exemption [main.bicep](../labautomation/bicep/main.bicep) and its local modules are packaged together under `labautomation/bicep`, so importing or mounting only `labautomation` is sufficient for template deployment. The guest automation and demo-page assets remain under `resources/artifacts` and are downloaded from the selected repository/branch. +When auto-shutdown is enabled, the host's managed identity receives **Virtual Machine Contributor on the host VM only**, in addition to Contributor on its shutdown schedule. Updating the schedule also requires `Microsoft.Compute/virtualMachines/write` on that linked VM. Bootstrap depends on both assignments and retries transient schedule authorization failures within its existing five-minute Azure-initialization budget; it receives no RG- or subscription-wide Contributor grant. A fixed extension update tag reruns Bootstrap once when this fix is applied to an incomplete lab; completed hosted labs are still reused without redeployment. + The template deploys at **resource-group scope** into the existing group. For a fresh lab, the deployer tries `PreferredLocation` in order and warns when skipping a region. Preflight checks provider-advertised service regions, the documented Bastion Developer regional list, zone-1 availability of `Standard_D16s_v5` and `PremiumV2_LRS`, and available regional/DSv5 quota for a new host. These checks do not reserve capacity or guarantee that a later ARM deployment will succeed. With only the default `swedencentral` configured, there is no second candidate; instructors can configure an ordered list of suitable regions before deployment. The selected region is recorded as **LabRegion** on the RG before submission. Its metadata location stays unchanged, even if resources deploy in a different preferred region. Existing labs retain their recorded region, checked against the host location and deployment parameters. A retry never moves partial resources to another region, deletes the RG, or changes the lab names. Include the recorded region in `PreferredLocation` when rerunning an existing lab; resolve inconsistent or missing region records before retrying. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 index de6fd4d85..cbb0ed3f5 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 @@ -31,19 +31,42 @@ trap { $failure = $_ Write-Error -ErrorRecord $failure -ErrorAction Continue Remove-Item -LiteralPath 'C:\MHBox\lab-ready.json' -Force -ErrorAction SilentlyContinue + $azFailureScriptPath = $null try { - if (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue) { - Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId -ErrorAction Stop | Out-Null - $groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" - foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { - try { - Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } -ErrorAction Stop | Out-Null - } - catch { Write-Warning "Could not report failure on '$target': $($_.Exception.Message)" } - } + # Keep Az isolated from the parent's package-management assemblies. + $azFailureScriptPath = Join-Path ([IO.Path]::GetTempPath()) "AzBootstrap-Failure-$([guid]::NewGuid().ToString('N')).ps1" + $azFailureScript = @' +param( + [string]$tenantId, + [string]$subscriptionId, + [string]$resourceGroup, + [string]$hostVmName +) + +$ErrorActionPreference = 'Stop' +Import-Module Az.Accounts -Force +Import-Module Az.Resources -Force +Connect-AzAccount -Identity -Tenant $tenantId -Subscription $subscriptionId | Out-Null +Set-AzContext -Subscription $subscriptionId | Out-Null +$groupId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup" +foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMachines/$hostVmName")) { + Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = 'Deployment Failed' } | Out-Null +} +'@ + $azFailureScript | Set-Content -LiteralPath $azFailureScriptPath -Encoding UTF8 + & "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $azFailureScriptPath ` + -tenantId $tenantId -subscriptionId $subscriptionId -resourceGroup $resourceGroup -hostVmName $hostVmName + if ($LASTEXITCODE -ne 0) { + Write-Warning "Isolated bootstrap failure reporting failed with exit code $LASTEXITCODE. The original bootstrap error is retained." + } + } + catch { Write-Warning "Could not report bootstrap failure: $($_.Exception.Message)" } + finally { + if ($azFailureScriptPath) { + Remove-Item -LiteralPath $azFailureScriptPath -Force -ErrorAction SilentlyContinue } } - catch { Write-Warning "Could not authenticate to report bootstrap failure: $($_.Exception.Message)" } exit 1 } @@ -229,13 +252,15 @@ try { $scheduleId = "$groupId/providers/Microsoft.DevTestLab/schedules/shutdown-computevm-$hostVmName" $Uri = "https://management.azure.com${scheduleId}?api-version=2018-09-15" - $Schedule = Invoke-AzRestMethod -Uri $Uri - if ($Schedule.StatusCode -ne 200) { throw "Reading shutdown schedule failed: HTTP $($Schedule.StatusCode). $($Schedule.Content)" } - $ScheduleSettings = $Schedule.Content | ConvertFrom-Json - $ScheduleSettings.properties.status = 'Disabled' + Invoke-InitialAzureOperation -Description "shutdown schedule access to '$scheduleId' and its target VM" -Operation { + $Schedule = Invoke-AzRestMethod -Uri $Uri + if ($Schedule.StatusCode -ne 200) { throw "Reading shutdown schedule failed: HTTP $($Schedule.StatusCode). $($Schedule.Content)" } + $ScheduleSettings = $Schedule.Content | ConvertFrom-Json + $ScheduleSettings.properties.status = 'Disabled' - $result = Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) - if ($result.StatusCode -notin @(200, 201, 202)) { throw "Disabling shutdown schedule failed: HTTP $($result.StatusCode). $($result.Content)" } + $result = Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) + if ($result.StatusCode -notin @(200, 201, 202)) { throw "Disabling shutdown schedule failed: HTTP $($result.StatusCode). $($result.Content)" } + } } Stop-Transcript From c7e062fd30bd9c8e6c6bc63df384143aa76f49c2 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 15:10:28 +0100 Subject: [PATCH 08/20] Fix bootstrap reboot and WinGet compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../bicep/clientVm/clientVm.bicep | 2 +- .../resources/README.md | 4 ++++ .../resources/artifacts/Bootstrap.ps1 | 21 +++++++++++++----- .../resources/artifacts/WinGet.ps1 | 22 ++++++++++++++----- .../resources/artifacts/dsc/common.dsc.yml | 6 +++++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep index 9d6068586..8e51f0dce 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep @@ -237,7 +237,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { typeHandlerVersion: '1.10' autoUpgradeMinorVersion: true // Rerun failed Bootstrap for this fix without changing the tag on every deployment. - forceUpdateTag: 'bootstrap-shutdown-rbac-v1' + forceUpdateTag: 'bootstrap-deferred-restart-v2' protectedSettings: { fileUris: [ uri(templateBaseUrl, 'artifacts/Bootstrap.ps1') diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 7d5cae90c..b431dd25f 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -36,6 +36,10 @@ Use this automation only for training RGs approved for these **RG-wide exemption When auto-shutdown is enabled, the host's managed identity receives **Virtual Machine Contributor on the host VM only**, in addition to Contributor on its shutdown schedule. Updating the schedule also requires `Microsoft.Compute/virtualMachines/write` on that linked VM. Bootstrap depends on both assignments and retries transient schedule authorization failures within its existing five-minute Azure-initialization budget; it receives no RG- or subscription-wide Contributor grant. A fixed extension update tag reruns Bootstrap once when this fix is applied to an incomplete lab; completed hosted labs are still reused without redeployment. +Bootstrap installs Hyper-V without restarting inside Custom Script Extension. Its final step schedules one SYSTEM restart two minutes later and exits successfully, allowing the extension to report success before the logon provisioning stage starts. Repeated incomplete runs replace the same restart task rather than creating duplicate tasks. Extension success still does not establish guest readiness. + +The WinGet CLI, `Microsoft.WinGet.Client`, and `Microsoft.WinGet.DSC` are pinned together to **1.29.380**. The common configuration also requires that exact DSC module version so an older cached module is not selected. This avoids mixing the [old named-pipe transport with the newer elevated RPC transport](https://github.com/microsoft/winget-cli/issues/6560). The logon task starts a fresh, profile-free PowerShell 7 process, validates the CLI version and COM package query before applying configurations, and stops on failures rather than continuing with missing software. Do not repair to `-Latest` independently of the module pins. + The template deploys at **resource-group scope** into the existing group. For a fresh lab, the deployer tries `PreferredLocation` in order and warns when skipping a region. Preflight checks provider-advertised service regions, the documented Bastion Developer regional list, zone-1 availability of `Standard_D16s_v5` and `PremiumV2_LRS`, and available regional/DSv5 quota for a new host. These checks do not reserve capacity or guarantee that a later ARM deployment will succeed. With only the default `swedencentral` configured, there is no second candidate; instructors can configure an ordered list of suitable regions before deployment. The selected region is recorded as **LabRegion** on the RG before submission. Its metadata location stays unchanged, even if resources deploy in a different preferred region. Existing labs retain their recorded region, checked against the host location and deployment parameters. A retry never moves partial resources to another region, deletes the RG, or changes the lab names. Include the recorded region in `PreferredLocation` when rerunning an existing lab; resolve inconsistent or missing region records before retrying. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 index cbb0ed3f5..969c32fcf 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/Bootstrap.ps1 @@ -517,7 +517,7 @@ if ($LASTEXITCODE -ne 0) { if ($flavor -eq 'ITPro') { # Creating scheduled task for WinGet.ps1 $Trigger = New-ScheduledTaskTrigger -AtLogOn - $Action = New-ScheduledTaskAction -Execute $ScheduledTaskExecutable -Argument $Env:MHBoxDir\WinGet.ps1 + $Action = New-ScheduledTaskAction -Execute $ScheduledTaskExecutable -Argument "-NoProfile -File $Env:MHBoxDir\WinGet.ps1" Register-ScheduledTask -TaskName 'WinGetLogonScript' -Trigger $Trigger -User $adminUsername -Action $Action -RunLevel Highest -Force | Out-Null # Creating scheduled task for MHServersLogonScript.ps1 @@ -531,11 +531,13 @@ Get-ScheduledTask -TaskName ServerManager | Disable-ScheduledTask | Out-Null if ($flavor -eq 'ITPro') { Write-Host "`n### Installing Hyper-V ###`n" - # Install Hyper-V and reboot - Write-Host 'Installing Hyper-V and restart' + Write-Host 'Installing Hyper-V without restarting the extension host' Enable-WindowsOptionalFeature -Online -FeatureName Containers -All -NoRestart | Out-Null Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -NoRestart | Out-Null - Install-WindowsFeature -Name Hyper-V -IncludeAllSubFeature -IncludeManagementTools -Restart + $hyperVInstallation = Install-WindowsFeature -Name Hyper-V -IncludeAllSubFeature -IncludeManagementTools + if (-not $hyperVInstallation.Success) { + throw "Hyper-V installation failed: $($hyperVInstallation.ExitCode)." + } } # Clean up Bootstrap.log @@ -544,5 +546,12 @@ Stop-Transcript $logSuppress = Get-Content "$Env:MHBoxLogsDir\Bootstrap.log" | Where-Object { $_ -notmatch "Host Application: $ScheduledTaskExecutable" } $logSuppress | Set-Content "$Env:MHBoxLogsDir\Bootstrap.log" -Force -# Restart computer -Restart-Computer +# Let CSE report success before the host reboots and starts the logon stage. +$restartAction = New-ScheduledTaskAction -Execute "$env:WINDIR\System32\shutdown.exe" ` + -Argument '/r /t 0 /d p:2:4 /c "MicroHack bootstrap completed"' +$restartTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) +$restartPrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest +Register-ScheduledTask -TaskName 'MHBox-BootstrapRestart' -Action $restartAction -Trigger $restartTrigger ` + -Principal $restartPrincipal -Force | Out-Null +Write-Host 'Bootstrap completed; the host restart is scheduled in two minutes.' +exit 0 diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 index 8b6f2d07e..d247b0af6 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 @@ -42,17 +42,27 @@ foreach ($target in @($groupId, "$groupId/providers/Microsoft.Compute/virtualMac Update-AzTag -ResourceId $target -Operation Merge -Tag @{ DeploymentProgress = $DeploymentProgressString } | Out-Null } -# Pinned to version 1.11.460 to avoid known issue: https://github.com/microsoft/winget-cli/issues/5826 -Install-PSResource -Name Microsoft.WinGet.Client -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version 1.11.460 -Install-PSResource -Name Microsoft.WinGet.DSC -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version 1.11.460 +# The CLI and modules must share the elevated COM transport introduced in 1.29.380. +$winGetVersion = '1.29.380' +if (@(Get-Module -Name Microsoft.WinGet.Client | Where-Object Version -NE ([version]$winGetVersion)).Count -gt 0) { + throw "Run WinGet.ps1 in a fresh PowerShell 7 process; another WinGet client version is already loaded." +} +Install-PSResource -Name Microsoft.WinGet.Client -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version $winGetVersion +Install-PSResource -Name Microsoft.WinGet.DSC -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Version $winGetVersion +Import-Module -Name Microsoft.WinGet.Client -RequiredVersion $winGetVersion -Force # Install DSC resources required for MHBox Install-PSResource -Name HyperVDsc -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Prerelease Install-PSResource -Name NetworkingDsc -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -# Update WinGet package manager to the latest version (running twice due to a known issue regarding WinAppSDK) -Repair-WinGetPackageManager -AllUsers -Force -Latest -Verbose -Repair-WinGetPackageManager -AllUsers -Force -Latest -Verbose +# Repeat repair for WinAppSDK registration without drifting to a newer CLI. +Repair-WinGetPackageManager -AllUsers -Force -Version $winGetVersion -Verbose +Repair-WinGetPackageManager -AllUsers -Force -Version $winGetVersion -Verbose +$actualWinGetVersion = (winget --version | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $actualWinGetVersion -ne "v$winGetVersion") { + throw "Expected WinGet v$winGetVersion; found '$actualWinGetVersion' (exit code $LASTEXITCODE)." +} +Get-WinGetPackage | Out-Null # Apply WinGet Configuration files winget configure --file C:\MHBox\DSC\common.dsc.yml --accept-configuration-agreements --disable-interactivity diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/dsc/common.dsc.yml b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/dsc/common.dsc.yml index 9fbe6177c..94e9ddded 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/dsc/common.dsc.yml +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/dsc/common.dsc.yml @@ -6,6 +6,8 @@ properties: id: AzureCLI directives: description: Install Azure CLI + module: Microsoft.WinGet.DSC + version: 1.29.380 settings: id: Microsoft.AzureCLI source: winget @@ -13,6 +15,8 @@ properties: id: edge directives: description: Install Microsoft Edge + module: Microsoft.WinGet.DSC + version: 1.29.380 settings: id: Microsoft.Edge source: winget @@ -20,6 +24,8 @@ properties: id: azcopy directives: description: Install azcopy + module: Microsoft.WinGet.DSC + version: 1.29.380 settings: id: Microsoft.Azure.AZCopy.10 source: winget From 1368fa4f9edbae585a819c4eb05c2896f36ec4a0 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 19:17:43 +0100 Subject: [PATCH 09/20] Add learner naming and manual deployment guide Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../06_Migration_Secure_AI_Ready/Readme.md | 10 +- .../challenges/challenge-01.md | 2 +- .../bicep/clientVm/clientVm.bicep | 6 +- .../labautomation/bicep/main.bicep | 17 +- .../bicep/mgmt/mgmtArtifactsDestination.bicep | 6 +- .../bicep/mgmt/mgmtArtifactsSource.bicep | 6 +- .../labautomation/deploy-lab.ps1 | 39 +++- .../resources/README.md | 187 +++++++++--------- .../walkthrough/challenge-01/solution-01.md | 16 +- .../walkthrough/challenge-02/solution-02.md | 4 +- .../walkthrough/challenge-05/solution-05.md | 2 +- 11 files changed, 176 insertions(+), 119 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md index 9ee674311..113ea1035 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md @@ -46,12 +46,12 @@ The Hack follows a coherent modernization storyline: 6. **Operate intelligently** with Azure Monitor telemetry, alerting, and AI-assisted investigation. 7. **Replatform** the selected static IIS or Apache workload to Azure App Service and evaluate the optimal production hosting target. -Each learner receives one pre-created resource group, shown as `` in these instructions. It contains both the simulated on-premises environment and the migration destination: the source VNet (`10.1.0.0/16`) hosts a Hyper-V server with four nested VMs, while the destination VNet (`10.2.0.0/16`) receives the migrated web VMs. Both networks retain NAT Gateway outbound connectivity and Azure Bastion access. Azure resource names use a deterministic eight-character hash of the subscription and resource-group scope, not the deployer's user principal name; the Hyper-V host is named `mh-<8charStableLabSuffix>-hv`. +Each learner receives one pre-created resource group, shown as `` in these instructions. It contains both the simulated on-premises environment and the migration destination: the source VNet (`10.1.0.0/16`) hosts a Hyper-V server with four nested VMs, while the destination VNet (`10.2.0.0/16`) receives the migrated web VMs. Both networks retain NAT Gateway outbound connectivity and Azure Bastion access. Fresh Console labs use the learner number from the RG name: for example, `rg-labuser-0013` receives the host `mh-0013-hv`. Existing Console labs and nonstandard Console RG names retain their deterministic hash-based names; retries never rename resources. For either deployment option, use the actual resource names supplied for your lab. -The platform predeploys only the Challenge 1 infrastructure and waits until all four nested VMs and both web dashboards are ready for the initial handoff. Completed deployment reruns reuse that baseline without restarting source guests that migration may have stopped. Discovery, assessment, migration, monitoring, and App Service remain learner exercises. In the final challenges, each team operates and manually replatforms either the migrated Windows/IIS workload or the migrated Ubuntu/Apache workload, keeping all user-controlled resources in the same lab resource group. +Instructors predeploy only the Challenge 1 infrastructure, using either Hackbox Console or the [manual Azure deployment](resources/README.md#option-2-manual-deployment), and verify all four nested VMs and both web dashboards before handoff. Completed Console deployment reruns reuse that baseline without restarting source guests that migration may have stopped. Discovery, assessment, migration, monitoring, and App Service remain learner exercises. In the final challenges, each team operates and manually replatforms either the migrated Windows/IIS workload or the migrated Ubuntu/Apache workload, keeping all user-controlled resources in the same lab resource group. > [!NOTE] -> Screenshots throughout the Hack illustrate portal workflows and may show older resource names. The lab dashboard's subscription, resource group, host name, and credentials are authoritative. Do not create separate source and destination resource groups or delete the lab resource group during Challenges 2–8. +> Screenshots throughout the Hack illustrate portal workflows and may show older resource names. The lab dashboard's subscription, resource group, host name, and credentials are authoritative. For manual deployments, use the equivalent details provided securely by your instructor wherever a challenge refers to the dashboard. Do not create separate source and destination resource groups or delete the lab resource group during Challenges 2–8. The Hyper-V discovery and migration design is described here: @@ -81,9 +81,9 @@ Complete these prerequisites before the session: * A Microsoft Entra ID tenant. * At least one Azure subscription. -* A Microsoft Entra learner account with **Owner** on its lab resource group and **Reader** on the subscription, assigned by the platform. The hosted shared hook registers required resource providers once per subscription without creating a shared group or enabling Defender. Instructors verify that preparation succeeded, arrange quota, and separately approve/enable any optional Defender plans; learners do not need subscription Owner. +* A Microsoft Entra learner account with **Owner** on its lab resource group and **Reader** on the subscription, assigned by Hackbox Console or the manual organizer. Instructors verify provider registration, arrange quota, and separately approve/enable any optional Defender plans; learners do not need subscription Owner. * A client network and browser that support the Azure portal, Azure Bastion, and Azure Cloud Shell. -* A ready lab deployment and access to the platform dashboard's connection details. Instructors should follow the [deployment and local validation instructions](resources/README.md) before the session; fresh predeployment can take 35–90+ minutes. +* A ready lab deployment and connection details from Hackbox Console or the instructor. Instructors should follow the [deployment instructions](resources/README.md) before the session; fresh predeployment can take 35–90+ minutes. Challenge 7 assumes Hack participants have Azure Copilot access, suitable RBAC, network access, and a supported region. It also includes a concise manual/KQL fallback if agent output is unavailable. No Azure OpenAI resource, model deployment, or model quota is required anywhere in this Hack. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md index 500f788e0..c437b23a1 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-01.md @@ -19,7 +19,7 @@ ## Success criteria - You have understood the concept and architecture for the Hack. -- Your one assigned resource group contains the source VNet (`10.1.0.0/16`), destination VNet (`10.2.0.0/16`), NAT Gateways, Bastion access, Key Vault, and Hyper-V host `mh-<8charStableLabSuffix>-hv`. +- Your one assigned resource group contains the source VNet (`10.1.0.0/16`), destination VNet (`10.2.0.0/16`), NAT Gateways, Bastion access, Key Vault, and the Hyper-V host named in your dashboard (for example, `mh-0013-hv` in a fresh `rg-labuser-0013` lab). - At initial handoff, the host VM's tags are `DeploymentProgress=Deployment Completed` and `LabReady=single-rg-v1`, the schema-version-1 marker `C:\MHBox\lab-ready.json` is present, and all four nested VMs are running. - The Windows/IIS and Ubuntu/Apache demo dashboards return HTTP success and show the correct hostname, platform, and web server. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep index 8e51f0dce..55cccd019 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/clientVm/clientVm.bicep @@ -22,7 +22,9 @@ param location string = resourceGroup().location @description('Resource Id of the subnet in the virtual network') param subnetId string -param labSuffix string +@minLength(1) +@maxLength(9) +param resourceNameSuffix string param keyVaultName string param participantObjectIds array param tags object = {} @@ -87,7 +89,7 @@ param zones string = '1' @description('Option to enable spot pricing for the ArcBox Client VM') param enableAzureSpotPricing bool = false -var namingPrefix = 'mh-${labSuffix}' +var namingPrefix = 'mh-${resourceNameSuffix}' @description('The name of your Virtual Machine') var hostName string = '${namingPrefix}-hv' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep index 363541432..3d9ac8a10 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep @@ -2,9 +2,14 @@ targetScope = 'resourceGroup' @minLength(8) @maxLength(8) -@description('Stable, lowercase alphanumeric suffix derived from the subscription and lab resource group.') +@description('Stable, lowercase hexadecimal lab identity derived from the subscription and lab resource group.') param labSuffix string +@minLength(1) +@maxLength(9) +@description('Resource-name suffix: the learner number for new hosted labs, or the stable lab identity for legacy labs.') +param resourceNameSuffix string = labSuffix + @minLength(1) @description('Entra user object IDs that need to read the host login secrets.') param participantObjectIds array @@ -69,7 +74,7 @@ param debugEnabled bool = false @maxLength(7) -@description('The naming prefix for the nested virtual machines and all Azure resources deployed. The maximum length for the naming prefix is 7 characters,example: `ArcBox-Win2k19`') +@description('The naming prefix for the nested virtual machines. The maximum length is 7 characters, example: `MHBox-Win2K22`.') param Prefix string = 'MHBox' @@ -91,7 +96,7 @@ param zones string = '1' var templateBaseUrl = 'https://raw.githubusercontent.com/${githubAccount}/MicroHack/${githubBranch}/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/' -var namingPrefix = 'mh-${labSuffix}' +var namingPrefix = 'mh-${resourceNameSuffix}' @description('Tags to assign for all MH resources') var resourceTags object = { @@ -102,7 +107,7 @@ var resourceTags object = { module clientVmDeployment 'clientVm/clientVm.bicep' = { name: '${namingPrefix}-clientVmDeployment' params: { - labSuffix: labSuffix + resourceNameSuffix: resourceNameSuffix participantObjectIds: participantObjectIds keyVaultName: mgmtArtifactsSourceDeployment.outputs.vaultName tags: resourceTags @@ -135,7 +140,7 @@ module clientVmDeployment 'clientVm/clientVm.bicep' = { module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { name: '${namingPrefix}-mgmtArtifactsSourceDeployment' params: { - labSuffix: labSuffix + resourceNameSuffix: resourceNameSuffix tags: resourceTags flavor: flavor deployBastion: deployBastion @@ -149,7 +154,7 @@ module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { module mgmtArtifactsDestinationDeployment 'mgmt/mgmtArtifactsDestination.bicep' = { name: '${namingPrefix}-mgmtArtifactsDestinationDeployment' params: { - labSuffix: labSuffix + resourceNameSuffix: resourceNameSuffix tags: resourceTags flavor: flavor deployBastion: deployBastion diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep index cc82caef6..c46dd43f3 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep @@ -22,10 +22,12 @@ param bastionSku string = 'Basic' @description('DNS Server configuration') param dnsServers array = [] -param labSuffix string +@minLength(1) +@maxLength(9) +param resourceNameSuffix string param tags object = {} -var namingPrefix = 'mh-${labSuffix}' +var namingPrefix = 'mh-${resourceNameSuffix}' @description('Name of the VNet') var virtualNetworkNameDST string = '${namingPrefix}-destination-VNet' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep index 4e86a405d..381702235 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep @@ -21,7 +21,9 @@ param bastionSku string = 'Basic' @description('DNS Server configuration') param dnsServers array = [] -param labSuffix string +@minLength(1) +@maxLength(9) +param resourceNameSuffix string param tags object = {} @description('Password for Windows account. Password must have 3 of the following: 1 lower case character, 1 upper case character, 1 number, and 1 special character. The value must be between 12 and 123 characters long') @@ -32,7 +34,7 @@ param windowsAdminPassword string? param windowsAdminUserName string? -var namingPrefix = 'mh-${labSuffix}' +var namingPrefix = 'mh-${resourceNameSuffix}' @description('Name of the VNet') var virtualNetworkName string = '${namingPrefix}-Source-VNet' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index a1b788954..4cedda2ff 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -70,7 +70,6 @@ if ($labHash -cnotmatch '^[a-f0-9]{12}$') { throw 'The platform returned an invalid stable lab suffix.' } $labSuffix = $labHash.Substring(0, 8) -$hostName = "mh-$labSuffix-hv" $deploymentName = "migration-$labSuffix" $labVersion = 'single-rg-v1' $hackName = '06_Migration_Secure_AI_Ready' @@ -87,6 +86,42 @@ if ($existingResources.Count -gt 0 -and $groupTags['Microhack'] -ne $hackName) { $deployments = @(Get-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName) $deployment = $deployments | Where-Object DeploymentName -EQ $deploymentName | Select-Object -First 1 +# Keep the resource-name suffix separate from the stable lab identity, including on legacy retries. +$recordedNameSuffixes = @( + if ($groupTags.ContainsKey('LabNameSuffix')) { [string]$groupTags['LabNameSuffix'] } + if ($null -ne $deployment) { + if ($null -ne $deployment.Parameters -and $deployment.Parameters.ContainsKey('resourceNameSuffix')) { + [string]$deployment.Parameters['resourceNameSuffix'].Value + } else { + $labSuffix + } + } + foreach ($resource in $existingResources) { + if ($resource.ResourceId -match '/providers/Microsoft.Compute/virtualMachines/mh-([a-z0-9]{1,9})-hv$' -or + $resource.ResourceId -match '/providers/Microsoft.Network/virtualNetworks/mh-([a-z0-9]{1,9})-(?:Source-VNet|destination-VNet)$') { + $Matches[1] + } + } +) | Select-Object -Unique +$recordedNameSuffixes = @($recordedNameSuffixes) +if ($recordedNameSuffixes.Count -gt 1 -or + @($recordedNameSuffixes | Where-Object { + $_ -isnot [string] -or ($_ -cnotmatch '\A[0-9]{1,9}\z' -and $_ -cne $labSuffix) + }).Count -gt 0) { + throw 'The existing lab has conflicting or invalid resource-name suffixes. Inspect LabNameSuffix, deployment parameters and resource names; no resources were changed.' +} +$resourceNameSuffix = $labSuffix +if ($recordedNameSuffixes.Count -eq 1) { + $resourceNameSuffix = $recordedNameSuffixes[0] +} elseif ($existingResources.Count -eq 0 -and -not $groupTags['LabId'] -and + $ResourceGroupName -match '^rg-labuser-([0-9]+)$') { + $resourceNameSuffix = $Matches[1] + if ($resourceNameSuffix.Length -gt 9) { + throw 'The learner number exceeds nine digits and would exceed the 15-character Windows hostname limit. No resources were changed.' + } +} +$hostName = "mh-$resourceNameSuffix-hv" +Write-Host "Resource name prefix: mh-$resourceNameSuffix; stable lab ID: $labSuffix." $hostVm = $null if ($existingResources.ResourceId -contains "$resourceGroupId/providers/Microsoft.Compute/virtualMachines/$hostName") { $hostVm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $hostName @@ -223,6 +258,7 @@ if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningS Update-AzTag -ResourceId $resourceGroupId -Operation Merge -Tag @{ Microhack = $hackName LabId = $labSuffix + LabNameSuffix = $resourceNameSuffix LabRegion = $location } | Out-Null Update-MhhToken | Out-Null @@ -234,6 +270,7 @@ if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningS AsJob = $true TemplateParameterObject = @{ labSuffix = $labSuffix + resourceNameSuffix = $resourceNameSuffix participantObjectIds = $AllowedEntraUserIds location = $location # Use a JSON string here; Bicep's @secure() retains ARM secret protection. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index b431dd25f..8aebf8b69 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -1,122 +1,131 @@ -# Instructor deployment and validation +# Deploy the migration lab -Learners start with a ready environment and follow [Solution 1](../walkthrough/challenge-01/solution-01.md) to verify it. They do not deploy the landing zone themselves. +Instructors prepare Challenge 1 before the session. Both deployment options use the same [Bicep template](../labautomation/bicep/main.bicep) and Bootstrap scripts: one learner resource group contains source and destination networks, Bastion, NAT Gateways, Key Vault, and a Hyper-V host with four nested VMs. Discovery, migration, monitoring, and App Service remain learner exercises. -## Hosted deployment - -The platform reads [lab-defaults.json](../labautomation/lab-defaults.json): - -| Default | Value | +| Option | Use when | | --- | --- | -| `groups` | `[]` | -| `deploymentType` | `resourcegroup` | -| `labsPerSubscription` | `2` | -| `preferredLocation` | `swedencentral` | -| `estimatedDailyCostsUsd` | `80.0` | - -The USD 80 daily value is a **conservative planning estimate for a running baseline, not a quote**. Check current Azure pricing, quota, learner count, and event duration before the event; later migration, monitoring, and App Service exercises can add charges. - -The platform first runs [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) **once per subscription** to register required resource providers. This hook creates no shared resource group and makes no Defender plan changes. It then invokes [deploy-lab.ps1](../labautomation/deploy-lab.ps1) once per learner. The per-learner entry point accepts exactly these platform parameters: - -| Parameter | Type | Meaning | -| --- | --- | --- | -| `DeploymentType` | `string` | `resourcegroup` for this Hack | -| `SubscriptionId` | `string` | The learner's assigned subscription | -| `ResourceGroupName` | `string` | The single, already-created learner resource group | -| `PreferredLocation` | `string[]` | Ordered deployment regions; default `swedencentral`. The RG's metadata location does not restrict its resources' region. | -| `AllowedEntraUserIds` | `string[]` | Learner Entra object IDs requiring lab access | +| [Hackbox Console](#option-1-hackbox-console) | The Console provisions learner scopes, runs automation, verifies readiness, and delivers connection details. | +| [Manual deployment](#option-2-manual-deployment) | An instructor deploys directly into Azure without the Console or its helper commands. | -Before per-learner invocation, the platform creates the resource group, authenticates Az PowerShell and Azure CLI, selects the subscription, and assigns the learner **Owner** on the resource group and **Reader** on the subscription. The hosted script neither signs in nor creates another resource group. Instructors must verify that the shared provider-registration hook succeeded, quota is sufficient, and any optional Defender plans were separately approved and enabled; do not grant subscription Owner to every learner. +## Before either deployment -**Approved training policy exemptions:** After validating the RG scope and existing-lab consistency, the deployer merges `SecurityControl=Ignore` and `CostControl=Ignore` into the learner RG's tags before submitting or resuming ARM. Completed reruns also apply these tags. Existing values for these two keys are replaced; unrelated tags are preserved. A tag-write failure stops the script before deployment or credential publication. +- Arrange permission to deploy resources and create role assignments in the lab RG, for example RG Owner. Creating an RG and registering providers require additional subscription permissions. Learners need RG Owner and subscription Reader, not subscription Owner; manual organizers assign these roles themselves. +- Check regional capacity and quota. The host needs **Standard_D16s_v5**, zone **1**, a **PremiumV2_LRS** data disk, and Bastion Developer support; the default region is **swedencentral**. Budget per learner for 30 regional vCPUs, including 16 DSv5 and 10 BS-family vCPUs, and five Standard public IPs across the Hack. See [quota requests](https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal). +- Resolve subscription policy restrictions and any required feature registrations with the organizer. Optional Defender plans must be separately approved and enabled; deployment does not change subscription-wide Defender plans. +- Allow **35-90+ minutes** for initial setup. The USD 80 daily baseline estimate in [lab-defaults.json](../labautomation/lab-defaults.json) is a conservative planning value, not a quote; later exercises add costs. -Use this automation only for training RGs approved for these **RG-wide exemptions**, including later learner-created resources. The tags affect only governance controls that explicitly honor them; they are not general Azure Policy exemptions. Where supported, the security exemption avoids inherited public-IP `ipTags` injection that can trigger the `AllowBringYourOwnPublicIpAddress` feature requirement. The deployer does not register subscription features or change Defender plans. The cost-control exemption does **not** prevent Azure charges; the planning estimate and event cost management still apply. +The Console deployer applies the approved training RG tags `SecurityControl=Ignore` and `CostControl=Ignore`. Direct Bicep deployment does **not** apply these exemptions. If required for a manual lab, obtain governance approval and apply the approved tags explicitly. Tags affect only controls that honor them; they are not universal policy exemptions and do not stop billing. -[main.bicep](../labautomation/bicep/main.bicep) and its local modules are packaged together under `labautomation/bicep`, so importing or mounting only `labautomation` is sufficient for template deployment. The guest automation and demo-page assets remain under `resources/artifacts` and are downloaded from the selected repository/branch. +## Option 1: Hackbox Console -When auto-shutdown is enabled, the host's managed identity receives **Virtual Machine Contributor on the host VM only**, in addition to Contributor on its shutdown schedule. Updating the schedule also requires `Microsoft.Compute/virtualMachines/write` on that linked VM. Bootstrap depends on both assignments and retries transient schedule authorization failures within its existing five-minute Azure-initialization budget; it receives no RG- or subscription-wide Contributor grant. A fixed extension update tag reruns Bootstrap once when this fix is applied to an incomplete lab; completed hosted labs are still reused without redeployment. +1. Import this Hack's complete `labautomation` folder, including its `bicep` subtree and both JSON files. Review [deployment-settings.json](../labautomation/deployment-settings.json) before importing; the configured source must contain the matching Bootstrap and guest artifacts. +2. Select **resourcegroup** deployment. Defaults are two labs per subscription in `swedencentral`; adjust the event settings and quota as needed. The Console supplies the existing learner RG, authentication, and learner RBAC. +3. Run predeployment. [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) registers providers once per subscription; [deploy-lab.ps1](../labautomation/deploy-lab.ps1) deploys and verifies each lab. +4. Hand out the Console's connection and credential fields only after verification succeeds. Learners then follow [Solution 1](../walkthrough/challenge-01/solution-01.md). -Bootstrap installs Hyper-V without restarting inside Custom Script Extension. Its final step schedules one SYSTEM restart two minutes later and exits successfully, allowing the extension to report success before the logon provisioning stage starts. Repeated incomplete runs replace the same restart task rather than creating duplicate tasks. Extension success still does not establish guest readiness. +Fresh `rg-labuser-0013` labs use `mh-0013-hv` and matching network prefixes. Leading zeros are preserved; learner numbers can contain one to nine digits. Existing labs and nonstandard RG names retain hash-based names. Keep the RG's `LabId`, `LabNameSuffix`, and `LabRegion` records intact: completed reruns preserve learner progress, and incomplete retries retain the original names and region. -The WinGet CLI, `Microsoft.WinGet.Client`, and `Microsoft.WinGet.DSC` are pinned together to **1.29.380**. The common configuration also requires that exact DSC module version so an older cached module is not selected. This avoids mixing the [old named-pipe transport with the newer elevated RPC transport](https://github.com/microsoft/winget-cli/issues/6560). The logon task starts a fresh, profile-free PowerShell 7 process, validates the CLI version and COM package query before applying configurations, and stops on failures rather than continuing with missing software. Do not repair to `-Latest` independently of the module pins. +## Option 2: Manual deployment -The template deploys at **resource-group scope** into the existing group. For a fresh lab, the deployer tries `PreferredLocation` in order and warns when skipping a region. Preflight checks provider-advertised service regions, the documented Bastion Developer regional list, zone-1 availability of `Standard_D16s_v5` and `PremiumV2_LRS`, and available regional/DSv5 quota for a new host. These checks do not reserve capacity or guarantee that a later ARM deployment will succeed. With only the default `swedencentral` configured, there is no second candidate; instructors can configure an ordered list of suitable regions before deployment. - -The selected region is recorded as **LabRegion** on the RG before submission. Its metadata location stays unchanged, even if resources deploy in a different preferred region. Existing labs retain their recorded region, checked against the host location and deployment parameters. A retry never moves partial resources to another region, deletes the RG, or changes the lab names. Include the recorded region in `PreferredLocation` when rerunning an existing lab; resolve inconsistent or missing region records before retrying. - -All user-controlled resources for the Hack stay in that one group: the source VNet (`10.1.0.0/16`), destination VNet (`10.2.0.0/16`), NAT Gateways, Bastion access, Key Vault, and Hyper-V host. - -Names use the first eight characters of a deterministic 12-character platform hash of the subscription and resource-group scope, not the deployer's UPN. Requesting 12 characters respects `Get-MhhStableHash`'s documented 12–64 range while retaining the eight-character naming suffix: - -| Resource | Name | -| --- | --- | -| Hyper-V host | `mh--hv` (14 characters) | -| Source VNet | `mh--Source-VNet` | -| Destination VNet | `mh--destination-VNet` | -| Key Vault | `mhkv` followed by `uniqueString(resourceGroup().id)` | +Use **PowerShell 7**, Azure CLI with Bicep support, and an interactive user login. Azure Cloud Shell's **PowerShell** environment also works after checking out the repository. Run the blocks below in order, starting at the repository root. Do not run the Console's `deploy-lab.ps1` in an ordinary shell: it requires platform helpers. -The deployer passes the Bicep parameters `labSuffix` and `participantObjectIds`. The main template exposes `hostVmName`, `keyVaultName`, `sourceVnetName`, `destinationVnetName`, and `clientVmLogonUserName` so automation can discover the actual deployed names. +### Sign in and prepare the subscription -Fresh predeployment prepares only Challenge 1, including the four running nested guests `MHBox-AzMigSrv`, `MHBox-SQL`, `MHBox-Win2K22`, and `MHBox-Ubuntu-01`. It does not configure discovery, migration, monitoring, or App Service. Bootstrap publishes the schema-version-1 ready marker at `C:\MHBox\lab-ready.json` and sets the host VM tags `LabReady=single-rg-v1` and `DeploymentProgress=Deployment Completed`. Before the initial handoff, the deployer waits for completion and independently checks the marker and four running guests; the marker records Bootstrap's HTTP/metadata checks of both dashboards. Only after independent verification succeeds does the deployer set `LabVerified=single-rg-v1` on the resource group. Failed verification is retried rather than treated as a completed handoff. +The provider list matches the shared preparation script. An organizer runs registration once; it is skipped for providers already registered. In Cloud Shell, omit `Invoke-Az login` because the session is already authenticated. -Allow **35–90+ minutes** for a fresh environment. ARM is submitted asynchronously and polled every 30 seconds with token refresh, bounded to **180 minutes per invocation**. An already-running deployment is polled rather than resubmitted. After ARM succeeds, the separate guest-readiness wait is bounded to **120 minutes**. ARM failure/cancellation, submission errors, and timeouts stop the script without publishing credentials. A timeout does not cancel ARM; resources are preserved, and a rerun resumes polling. Inspect deployment/provisioning diagnostics before retrying; no deployment failure triggers destructive cleanup or cross-region recreation. An ARM deployment success alone does not prove guest readiness. - -**Completed reruns preserve learner progress:** the deployer reuses an already-completed baseline without restarting the nested guests or requiring them to be running. A learner may have legitimately stopped source guests during migration. Only incomplete deployments receive an incremental deployment retry; rerunning is not a request to reset the lab. - -The platform dashboard provides the lab resource group, connection details, and login credential fields. The Key Vault retains the host login username and generated password. Keep those credentials private; do not paste generated passwords into deployment commands, shared notes, or screenshots. - -## Manual/local validation for instructors +```powershell +Set-Location '.\03-Azure\01-03-Infrastructure\06_Migration_Secure_AI_Ready' +$ErrorActionPreference = 'Stop' +function Invoke-Az { + $result = & az @args + if ($LASTEXITCODE -ne 0) { throw 'Azure CLI failed; inspect the error above before continuing.' } + $result +} +Invoke-Az login --output none +$subscriptionId = Read-Host 'Subscription ID' +Invoke-Az account set --subscription $subscriptionId +$participantIds = @((Invoke-Az ad signed-in-user show --query id --output tsv)) + +$providers = @( + 'Microsoft.Compute', 'Microsoft.Network', 'Microsoft.KeyVault', 'Microsoft.Storage', + 'Microsoft.DevTestLab', 'Microsoft.ManagedIdentity', 'Microsoft.Migrate', 'Microsoft.OffAzure', + 'Microsoft.RecoveryServices', 'Microsoft.Insights', 'Microsoft.OperationalInsights', + 'Microsoft.Monitor', 'Microsoft.AlertsManagement', 'Microsoft.Web', 'Microsoft.Security' +) +foreach ($provider in $providers) { + $state = Invoke-Az provider show --namespace $provider --query registrationState --output tsv + if ($state -ne 'Registered') { + Invoke-Az provider register --namespace $provider --wait --output none + } +} +``` -Use the current standard template's [local testing harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing), not an arbitrary PowerShell session. That harness supplies the platform helper stand-ins through the `ghcr.io/qxsch/adminpwsh:latest` container. Mount this Hack's `labautomation` directory, including its `bicep` subtree, at `/app` as shown by the harness. No sibling repository directories are required. The pinned Azure Verified Module for Key Vault must still be restored by Bicep, and downloaded guest artifacts must be reachable. +By default, the signed-in user receives Key Vault secret access. To deploy for other learners, replace `$participantIds` with their Entra **object IDs** and arrange their RG/subscription roles separately; the template does not assign learner RG Owner or subscription Reader. -1. Follow the harness's container setup and authenticate **both** Az PowerShell and Azure CLI locally; select the same intended instructor-owned test subscription in both contexts. Hosted runs already have those contexts. -2. Follow [testing the shared hook locally](../../../../99-MicroHack-Template/labautomation/README.md#testing-the-shared-hook-locally): from this Hack's `labautomation` directory, run `shared-deploy-lab.ps1` once for the test subscription before the per-learner script, using that subscription's preferred regions and learner object IDs. The instructor test identity needs permission to register providers; learner subscription Reader is intentionally insufficient. -3. Create a fresh, uniquely named test resource group before the per-learner invocation, normally in the first preferred region, and grant the test learner RG Owner and subscription Reader to reproduce learner access. Unlike hosted execution, local preparation is your responsibility. If a test group already exists, inspect it or choose a new name; do not delete a group as a retry shortcut. -4. Inside the harness, invoke `deploy-lab.ps1` from this Hack's `labautomation` directory with all five parameters from the table above, using `DeploymentType` = `resourcegroup`, the existing test resource group, ordered region array, and learner object-ID array. Confirm that the reported selected region and RG **LabRegion** tag match the resources, not necessarily the RG's metadata location. Follow the standard harness invocation syntax and helper limitations. -5. Verify the returned credential fields (the platform dashboard shows these for hosted runs) and Key Vault access as the test learner, then follow Solution 1's **initial-handoff** readiness checks. Also verify that a completed rerun preserves learner state and does not restart migrated source guests. Syntax/build checks alone are not evidence of a live deployment. +### Create a fresh lab and deploy -### Select the artifact source without runner access +Choose a new RG, not an existing or Console-managed lab. Keep the number as a quoted string to preserve leading zeros. The example reads the same source JSON as Console deployment; there is no hardcoded test account or branch. -The script automatically reads [deployment-settings.json](../labautomation/deployment-settings.json) from its own directory. This branch is configured for the fork: +Azure CLI will prompt securely for the omitted `windowsAdminPassword`. Use a unique 12-123 character password meeting Windows complexity requirements and retain it securely for recovery. It is not placed in the temporary parameters file or command history. -```json -{ - "githubAccount": "ASchwarz1984", - "githubBranch": "aschwarz1984-migration-lab" +```powershell +$resourceGroup = 'rg-migration-0013' +$number = '0013' +$location = 'swedencentral' +if ((Invoke-Az group exists --name $resourceGroup --output tsv) -ne 'false') { + throw 'The RG already exists. Inspect it or choose a fresh name; do not delete it to retry.' +} +$source = Get-Content '.\labautomation\deployment-settings.json' -Raw | ConvertFrom-Json +$templateFile = (Resolve-Path '.\labautomation\bicep\main.bicep').ProviderPath +$scope = "$subscriptionId/$resourceGroup".ToLowerInvariant() +$labId = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($scope))).Substring(0, 8).ToLowerInvariant() +$parameters = @{ + labSuffix = @{ value = $labId } + resourceNameSuffix = @{ value = $number } + participantObjectIds = @{ value = $participantIds } + location = @{ value = $location } + githubAccount = @{ value = $source.githubAccount } + githubBranch = @{ value = $source.githubBranch } } +Invoke-Az group create --name $resourceGroup --location $location --output none +$parameterFile = [IO.Path]::GetTempFileName() +try { + $parameters | ConvertTo-Json -Depth 5 | Set-Content $parameterFile -Encoding utf8NoBOM + $outputs = Invoke-Az deployment group create --name migration-manual ` + --resource-group $resourceGroup --mode Incremental ` + --template-file $templateFile ` + --parameters "@$parameterFile" --query properties.outputs --output json | + ConvertFrom-Json +} finally { + Remove-Item -LiteralPath $parameterFile +} +$outputs | Format-List ``` -Edit these two nonsecret values in the repository and import/re-import that version of the `labautomation` folder. No runner environment changes are required. The selected owner and branch (or commit SHA) must contain the Hack's existing `resources/artifacts` paths, and the imported templates/settings must match that source version. The script passes the values through Bicep and Bootstrap to both guests and all demo-page asset downloads. +Record the output host name, username, Key Vault, and network names. Connect to the host through Azure Bastion using the password entered above. Key Vault also stores `windowsAdminUserName` and `windowsAdminPassword`; do not copy secret values into shared notes or transcripts. -Keep the file valid and present: missing/malformed settings fail instead of silently downloading another version. Do not add these fields to `lab-defaults.json`; the platform schema rejects additional properties. Optional `MICROHACK_GITHUB_ACCOUNT` and `MICROHACK_GITHUB_BRANCH` environment variables still override the corresponding JSON values when an operator controls the runner. +Manual deployment does not run the Console's capacity preflight, automatic resume, or completed-baseline safeguards. If the command is interrupted, inspect the existing ARM deployment rather than rerunning the creation block: -For official upstream deployment, change the settings to `"githubAccount": "microsoft"` and `"githubBranch": "main"`. The repository name `MicroHack`, artifact paths, published defaults schema, and demo-page repository link remain unchanged. +```powershell +Invoke-Az deployment group show --resource-group $resourceGroup --name migration-manual ` + --query properties.provisioningState --output tsv +``` -## Resource lifecycle +An incomplete deployment retry must retain its RG, names, region, source version, and original password. Do not resubmit a healthy baseline, switch deployment methods in the same RG, or delete resources as a retry shortcut. -Keep the single lab resource group throughout Challenges 2–8, including the learner-created Azure Migrate, migrated VMs, monitoring resources, and App Service resources. Azure Monitor can create an Azure-managed `MA_*_managed` group for workspace internals; that service-managed exception is not another learner-created lab group. +## Verify readiness and hand off -Only after the lab owner approves the finish should you clean up. Use Azure Migrate's supported test-migration cleanup and complete-migration/stop-replication actions, and remove backup protection or retained recovery data through the relevant service's supported cleanup. Backup or replication dependencies can block resource-group deletion. Deleting the lab resource group removes **both** the simulated source and migrated resources, plus monitoring and App Service; it is not a way to remove just the source environment. +**ARM success alone is not lab readiness.** The host reboots after Bootstrap, then logon provisioning prepares the nested guests. Inspect `C:\MHBox\Logs` if setup fails. -## Quota information if multiple users deploy to the same subscription +For either option, complete [Solution 1's initial-handoff checks](../walkthrough/challenge-01/solution-01.md): host tags `DeploymentProgress=Deployment Completed` and `LabReady=single-rg-v1`, a valid schema-version-1 `C:\MHBox\lab-ready.json`, all four nested VMs running, and both live dashboards returning HTTP success with correct metadata. Console labs additionally have RG tag `LabVerified=single-rg-v1`; manual instructors perform the verification themselves, without that Console tag. -If you have multiple users deploying to the same subscription, you need to make sure you have enough quota for the deployments. -For a single-user deployment, you should make sure to have the following quotas requested upfront: +For manual labs, securely provide the subscription, RG, region, host, Key Vault, network names, and login instructions to learners. References to the "dashboard" in later challenges mean these instructor-provided details when no Console is used. -| Quota name | Needed quantity | -| ------------- | ------------- | -| Total Regional vCPUs | 30 | -| Standard DSv5 Family vCPUs | 16 | -| Standard BS Family vCPUs | 10 | -| Public IP Addresses - Standard | 5 | +Keep the RG through Challenges 2-8: it contains both source and migrated resources. Azure Monitor may create an Azure-managed `MA_*_managed` group for workspace internals; do not delete it separately. At the end, obtain approval, complete supported migration/replication and backup cleanup, then remove the lab RG. Deletion removes the entire lab, not just the source environment. -For five users who need to deploy individually to a region, make sure you have the following quotas in place: +## Testing changes -| Quota name | Needed quantity | -| ------------- | ------------- | -| Total Regional vCPUs | 150 | -| Standard DSv5 Family vCPUs | 80 | -| Standard BS Family vCPUs | 50 | -| Public IP Addresses - Standard | 25 | +Use a fresh test RG and a real learner account to exercise deployment, both dashboard variants, Challenges 2-8 with RG Owner/subscription Reader, and preservation of learner progress on a completed Console rerun. The [local harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing) is also available for testing Console automation; its helpers are local-development stand-ins, not the manual deployment requirement. -To request a quota increase, follow [Request quota increases in the Azure portal](https://learn.microsoft.com/en-us/azure/quotas/quickstart-increase-quota-portal). +To test unpublished changes, set `githubAccount` and `githubBranch` in [deployment-settings.json](../labautomation/deployment-settings.json) to a repository account and branch or commit containing the matching artifacts. The source must be publicly downloadable by the VMs. Re-import the Console package after edits; the manual example reads the JSON directly. Keep these fields out of `lab-defaults.json`. For official upstream deployment, use `microsoft` and `main` once the matching version is published there. Complete the Azure deployment and learner-flow tests before upstream integration; syntax checks alone are not sufficient. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md index 78a7e8d3e..584f7e80a 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md @@ -7,7 +7,7 @@ Duration: 15 minutes (verification of an already-provisioned lab) ## Prerequisites - Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. -- Your instructor/platform has completed [predeployment](../../resources/README.md), and your platform dashboard contains the subscription, lab resource group, connection details, and login credential fields. +- Your instructor has completed [predeployment](../../resources/README.md) through Hackbox Console or manually. Obtain the subscription, lab resource group, connection details, and login instructions from the Console dashboard or your instructor. - Your learner account has **Owner** on the assigned resource group and **Reader** on the subscription. > [!IMPORTANT] @@ -15,10 +15,10 @@ Duration: 15 minutes (verification of an already-provisioned lab) ### **Task 1: Receive the lab and verify host readiness** -1. Open your platform dashboard. Record the assigned subscription, ``, region, Hyper-V host name, and login credential fields. Do not copy passwords into shared notes. +1. Record the assigned subscription, ``, region, Hyper-V host name, and login instructions from your Console dashboard or instructor. Do not copy passwords into shared notes. 2. Sign in to the [Azure Portal](https://portal.azure.com) with your assigned learner account. Open that existing resource group; do not create a source or destination resource group. -3. Locate the Hyper-V host `mh-<8charStableLabSuffix>-hv`. The suffix is a deterministic eight-character hash of the subscription and resource-group scope, not a UPN. Use the actual host name shown by your dashboard. -4. On the host VM, check that **DeploymentProgress** is **Deployment Completed** and **LabReady** is **single-rg-v1**; the resource group's **LabVerified** tag must also be **single-rg-v1**. Bootstrap publishes `C:\MHBox\lab-ready.json` (schema version 1), which the deployer independently checks before handoff. You can inspect ARM deployment status under the lab resource group's **Deployments**, but ARM success alone is not the readiness gate. +3. Locate the supplied Hyper-V host. A fresh Console lab in `rg-labuser-0013` uses `mh-0013-hv`, preserving the learner number's leading zeros. Existing Console labs and nonstandard RG names retain hash-based names; manual deployments use the instructor's chosen suffix. Do not rename a working host. +4. On the host VM, check that **DeploymentProgress** is **Deployment Completed** and **LabReady** is **single-rg-v1**. Console deployments also require RG tag **LabVerified=single-rg-v1**; for manual deployment the instructor performs the initial-handoff checks directly, without this Console tag. Bootstrap publishes `C:\MHBox\lab-ready.json` (schema version 1). Confirm it names the actual host and four expected guests and records HTTP 200 for both workloads. You can inspect ARM deployment status under the lab resource group's **Deployments**, but ARM success alone is not the readiness gate. 5. Connect to the host using **Connect** > **Bastion** and the supplied host login credentials. You can also select **Password from Azure Key Vault** and use the login secrets stored in the lab Key Vault. > [!NOTE] @@ -27,9 +27,9 @@ Duration: 15 minutes (verification of an already-provisioned lab) ![Host VM DeploymentProgress tag example](./img/CS9.png) > [!IMPORTANT] -> Fresh predeployment can take **35–90+ minutes**, including nested-VM downloads and configuration. ARM submission is polled with token refresh for up to **180 minutes per invocation**; guest readiness then has a separate **120-minute post-deployment timeout**. Before the initial handoff, the deployer waits for the completion tags, ready marker, four running guests, and both dashboard HTTP checks. Use the dashboard's lab region, also recorded in the RG's **LabRegion** tag; the RG's own metadata location can differ. The instructor's deployer can skip an unsupported preferred region before initial deployment, but retries never move or delete an existing lab. If your lab is not ready, contact the instructor with its status; do not delete the resource group or redeploy into a different region. +> Fresh predeployment can take **35–90+ minutes**, including nested-VM downloads and configuration. Console automation polls ARM for up to **180 minutes per invocation**, then waits up to **120 minutes** for guest readiness and independently checks the baseline. Its **LabRegion** tag records the selected resource region, which can differ from the RG's metadata location. Manual instructors check readiness themselves. If your lab is not ready, contact the instructor with its status; do not delete the resource group or redeploy into a different region. -These are **initial-handoff** checks. If you revisit this walkthrough after migration, source guests may correctly be stopped. Completed deployment reruns preserve that state instead of restarting guests or requiring all four to be running; do not reset learner progress just to repeat Challenge 1. +These are **initial-handoff** checks. If you revisit this walkthrough after migration, source guests may correctly be stopped. Completed Console deployment reruns preserve that state instead of restarting guests or requiring all four to be running; do not reset learner progress just to repeat Challenge 1. ### **Task 2: Verify the deployed VM resources** Open *Hyper-V Manager* on the supplied host and ensure that four VMs are running. @@ -42,7 +42,7 @@ Open *Hyper-V Manager* on the supplied host and ensure that four VMs are running + MHBox-Win2K22 + This system hosts Windows Server 2022 with an installed IIS web server. -The following lab-only credentials are used inside the nested VMs. These are separate from the generated host login credentials delivered by the platform and stored in Key Vault. +The following lab-only credentials are used inside the nested VMs. These are separate from the host login credentials provided by the Console or instructor and stored in Key Vault. **Windows virtual machine credentials:** @@ -74,7 +74,7 @@ The **Hostname** value must match the VM you opened. ### **Task 3: Verify the deployed Azure resources** -Within **the same** ``, verify: +Within **the same** ``, verify the following. `` is the learner number (for example, `0013`) for fresh Console labs, the existing hash suffix for older Console labs and nonstandard RG names, or the instructor's chosen suffix for manual labs: | Component | Purpose | | --- | --- | diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md index 985cec63b..7a86fede0 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md @@ -33,12 +33,12 @@ Your previously created Azure Migrate project should be listed. Click on it to o ### **Task 2: Install the Azure Migrate appliance for Hyper-V discovery** -Hyper-V discovery uses an Azure Migrate appliance that connects to the Hyper-V host and continuously collects VM configuration, performance metadata, and workload inventory. For this Hack, install and register the appliance by running the PowerShell installation script on the existing **MHBox-AzMigSrv** nested server, and then connect it to the supplied Hyper-V host **`mh-<8charStableLabSuffix>-hv`**. +Hyper-V discovery uses an Azure Migrate appliance that connects to the Hyper-V host and continuously collects VM configuration, performance metadata, and workload inventory. For this Hack, install and register the appliance by running the PowerShell installation script on the existing **MHBox-AzMigSrv** nested server, and then connect it to the Hyper-V host named in your platform dashboard (for example, `mh-0013-hv` in a fresh `rg-labuser-0013` lab). > [!IMPORTANT] > Check the current [Hyper-V discovery prerequisites](https://learn.microsoft.com/en-us/azure/migrate/tutorial-discover-hyper-v?view=migrate#prerequisites) for the Azure Migrate appliance and Hyper-V host. -In the Azure Portal select *Virtual machines* from the navigation pane on the left. Select the host named in your dashboard (`mh-<8charStableLabSuffix>-hv`) and log on via Azure Bastion with your host credentials: +In the Azure Portal select *Virtual machines* from the navigation pane on the left. Select the host named in your dashboard and log on via Azure Bastion with your host credentials: > [!NOTE] > You can also select *Password from Azure Key Vault* under *Authentication Type* to select the secret stored in the Key Vault. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md index 2d38eb51e..36cffa135 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-05/solution-05.md @@ -15,7 +15,7 @@ Use the existing `` for the Azure Migrate project, repl ### **Task 1: Configure and register the Hyper-V replication providers** -In the Azure Portal, select *Virtual machines* from the navigation pane on the left. Select the supplied Hyper-V host **`mh-<8charStableLabSuffix>-hv`** and log on via Azure Bastion with your host credentials: +In the Azure Portal, select *Virtual machines* from the navigation pane on the left. Select the Hyper-V host named in your platform dashboard (for example, `mh-0013-hv` in a fresh `rg-labuser-0013` lab) and log on via Azure Bastion with your host credentials: > [!NOTE] > You can also select *Password from Azure Key Vault* under *Authentication Type* to select the secret stored in the Key Vault. From 49c889040eed569814915814ebd7c80945230ef2 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Tue, 3 Nov 2026 20:09:18 +0100 Subject: [PATCH 10/20] Fix WinGet fresh-install release lookup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../resources/artifacts/WinGet.ps1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 index d247b0af6..e84eae4d7 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 @@ -55,12 +55,13 @@ Import-Module -Name Microsoft.WinGet.Client -RequiredVersion $winGetVersion -For Install-PSResource -Name HyperVDsc -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -Prerelease Install-PSResource -Name NetworkingDsc -Scope AllUsers -Quiet -AcceptLicense -TrustRepository -# Repeat repair for WinAppSDK registration without drifting to a newer CLI. -Repair-WinGetPackageManager -AllUsers -Force -Version $winGetVersion -Verbose -Repair-WinGetPackageManager -AllUsers -Force -Version $winGetVersion -Verbose +# Repeat repair for WinAppSDK registration; fresh installs require the v-prefixed GitHub release tag. +$winGetReleaseTag = "v$winGetVersion" +Repair-WinGetPackageManager -AllUsers -Force -Version $winGetReleaseTag -Verbose +Repair-WinGetPackageManager -AllUsers -Force -Version $winGetReleaseTag -Verbose $actualWinGetVersion = (winget --version | Out-String).Trim() -if ($LASTEXITCODE -ne 0 -or $actualWinGetVersion -ne "v$winGetVersion") { - throw "Expected WinGet v$winGetVersion; found '$actualWinGetVersion' (exit code $LASTEXITCODE)." +if ($LASTEXITCODE -ne 0 -or $actualWinGetVersion -ne $winGetReleaseTag) { + throw "Expected WinGet $winGetReleaseTag; found '$actualWinGetVersion' (exit code $LASTEXITCODE)." } Get-WinGetPackage | Out-Null From 4b76998340ba053e42fdbe151eb5ae629f8db6bd Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Wed, 4 Nov 2026 08:49:11 +0100 Subject: [PATCH 11/20] Document subscription Owner requirements Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../06_Migration_Secure_AI_Ready/Readme.md | 5 ++++- .../challenges/challenge-06.md | 2 +- .../challenges/challenge-07.md | 4 ++-- .../challenges/challenge-08.md | 2 +- .../resources/README.md | 18 +++++++++++------- .../walkthrough/challenge-01/solution-01.md | 2 +- .../walkthrough/challenge-02/solution-02.md | 2 +- .../walkthrough/challenge-06/solution-06.md | 2 +- .../walkthrough/challenge-07/solution-07.md | 6 +++--- 9 files changed, 25 insertions(+), 18 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md index 113ea1035..b72d6ccfe 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/Readme.md @@ -81,10 +81,13 @@ Complete these prerequisites before the session: * A Microsoft Entra ID tenant. * At least one Azure subscription. -* A Microsoft Entra learner account with **Owner** on its lab resource group and **Reader** on the subscription, assigned by Hackbox Console or the manual organizer. Instructors verify provider registration, arrange quota, and separately approve/enable any optional Defender plans; learners do not need subscription Owner. +* A Microsoft Entra learner account with **Owner on the Hack subscription**, assigned by Hackbox Console or the manual organizer, so additional resource providers can be registered during the exercises. RG Owner with subscription Reader is not sufficient when providers are not preregistered. Instructors arrange quota and separately approve/enable any optional Defender plans. * A client network and browser that support the Azure portal, Azure Bastion, and Azure Cloud Shell. * A ready lab deployment and connection details from Hackbox Console or the instructor. Instructors should follow the [deployment instructions](resources/README.md) before the session; fresh predeployment can take 35–90+ minutes. +> [!WARNING] +> Subscription Owner also permits modifying other learners' resource groups and shared subscription settings. Work only in your assigned lab RG and follow the instructor's approval and cost guidance; separate RGs do not provide security isolation with this role. + Challenge 7 assumes Hack participants have Azure Copilot access, suitable RBAC, network access, and a supported region. It also includes a concise manual/KQL fallback if agent output is unavailable. No Azure OpenAI resource, model deployment, or model quota is required anywhere in this Hack. ## Challenges diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md index c25b579b4..0cd5f2317 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md @@ -11,7 +11,7 @@ The goal of this exercise is to ... ## Prerequisites * The instructor has approved and enabled the subscription-wide Defender CSPM and Defender for Servers plans on the shared subscription, including their costs. -* Learners retain **Owner** on `` and **Reader** on the subscription. Subscription-wide plan activation is an instructor task, not a reason to grant subscription Owner to each learner. +* Learners have **Owner on the Hack subscription**, but subscription-wide plan activation remains an instructor task. Do not change shared plan settings without instructor approval. ## Actions diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md index 861a08977..b677c60de 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md @@ -21,8 +21,8 @@ The Linux alert monitors every process whose `"process.executable.name"` is `apa * Challenge 5 is complete and both migrated web VMs in `` are running. * You can connect to both VMs through Azure Bastion and each site responds locally. -* You have RG Owner and subscription Reader. Before opening **Infrastructure monitoring**, create or reuse one user-assigned managed identity in the lab resource group for both migrated VMs. -* Grant that identity **Monitoring Reader** on the lab resource group for the migrated VMs and monitoring workspace. Do not assign roles at subscription scope. +* You have **Owner on the Hack subscription**. Before opening **Infrastructure monitoring**, create or reuse one user-assigned managed identity in the lab resource group for both migrated VMs. +* Grant that identity **Monitoring Reader** on the lab resource group for the migrated VMs and monitoring workspace. Do not grant the alert identity a subscription-scoped role. * This user-assigned identity authorizes OpenTelemetry query-based and recommended alerts. It is separate from the VM's system-assigned identity used by Azure Monitor Agent; the portal doesn't automatically create the user-assigned alert identity. * Azure Copilot access, a supported region, and the required telemetry permissions are available to Hack participants. No Azure OpenAI resource, model deployment, or model quota is required. * The client network allows WebSocket connections to `https://directline.botframework.com`. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md index 0c125202a..fe8090804 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-08.md @@ -24,7 +24,7 @@ Complete one path during the standard challenge time. If time permits, you may c * Challenge 7 is complete and both `W3SVC` and `apache2` have been restored. * You can connect to the source VM for your chosen path through Azure Bastion. -* You know the Hack subscription ID and have **Owner** on your assigned lab resource group and **Reader** on the subscription. +* You know the Hack subscription ID and have **Owner on the Hack subscription**. Keep the App Service resources in your assigned lab resource group despite the broader access. * You know the exact `` from the platform dashboard. It already contains the simulated source and migrated VMs; create the App Service resources in this same group. * The selected site content is available at `C:\inetpub\wwwroot` or `/var/www/html`. * Path A can use Azure Cloud Shell and an interactive browser session for Kudu ZIP upload, or the documented CLI alternative. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 8aebf8b69..e680239ac 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -9,19 +9,23 @@ Instructors prepare Challenge 1 before the session. Both deployment options use ## Before either deployment -- Arrange permission to deploy resources and create role assignments in the lab RG, for example RG Owner. Creating an RG and registering providers require additional subscription permissions. Learners need RG Owner and subscription Reader, not subscription Owner; manual organizers assign these roles themselves. +- Require **Owner on the Hack subscription** for the deployment account and learner accounts. This allows learners to register additional resource providers during the exercises; RG Owner with subscription Reader is not sufficient when those providers are not preregistered. Manual organizers assign subscription Owner themselves. +- Arrange any separate Microsoft Entra permissions needed for Azure Migrate appliance registration; subscription Owner does not grant directory roles. - Check regional capacity and quota. The host needs **Standard_D16s_v5**, zone **1**, a **PremiumV2_LRS** data disk, and Bastion Developer support; the default region is **swedencentral**. Budget per learner for 30 regional vCPUs, including 16 DSv5 and 10 BS-family vCPUs, and five Standard public IPs across the Hack. See [quota requests](https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal). - Resolve subscription policy restrictions and any required feature registrations with the organizer. Optional Defender plans must be separately approved and enabled; deployment does not change subscription-wide Defender plans. - Allow **35-90+ minutes** for initial setup. The USD 80 daily baseline estimate in [lab-defaults.json](../labautomation/lab-defaults.json) is a conservative planning value, not a quote; later exercises add costs. +> [!WARNING] +> Subscription Owner grants control of all resource groups, resources, role assignments, and subscription settings, including other learners' labs in a shared subscription. Learner RGs are not security-isolated. Use trusted training accounts, work only in the assigned lab RG, and obtain instructor approval before changing shared settings. Remove temporary subscription Owner assignments after the Hack. + The Console deployer applies the approved training RG tags `SecurityControl=Ignore` and `CostControl=Ignore`. Direct Bicep deployment does **not** apply these exemptions. If required for a manual lab, obtain governance approval and apply the approved tags explicitly. Tags affect only controls that honor them; they are not universal policy exemptions and do not stop billing. ## Option 1: Hackbox Console 1. Import this Hack's complete `labautomation` folder, including its `bicep` subtree and both JSON files. Review [deployment-settings.json](../labautomation/deployment-settings.json) before importing; the configured source must contain the matching Bootstrap and guest artifacts. -2. Select **resourcegroup** deployment. Defaults are two labs per subscription in `swedencentral`; adjust the event settings and quota as needed. The Console supplies the existing learner RG, authentication, and learner RBAC. -3. Run predeployment. [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) registers providers once per subscription; [deploy-lab.ps1](../labautomation/deploy-lab.ps1) deploys and verifies each lab. -4. Hand out the Console's connection and credential fields only after verification succeeds. Learners then follow [Solution 1](../walkthrough/challenge-01/solution-01.md). +2. Select **resourcegroup-with-subscriptionowner** deployment, overriding the supplied `resourcegroup` default. This keeps one lab RG per learner while granting both RG Owner and subscription Owner; plain `resourcegroup` grants only subscription Reader and does not meet this Hack's access requirement. Defaults are two labs per subscription in `swedencentral`; adjust the event settings and quota as needed. +3. Run predeployment. [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) registers baseline providers once per subscription; [deploy-lab.ps1](../labautomation/deploy-lab.ps1) deploys and verifies each lab. Learners can register additional providers as required by later exercises. +4. Confirm each learner has subscription Owner, then hand out the Console's connection and credential fields only after verification succeeds. Learners then follow [Solution 1](../walkthrough/challenge-01/solution-01.md). Fresh `rg-labuser-0013` labs use `mh-0013-hv` and matching network prefixes. Leading zeros are preserved; learner numbers can contain one to nine digits. Existing labs and nonstandard RG names retain hash-based names. Keep the RG's `LabId`, `LabNameSuffix`, and `LabRegion` records intact: completed reruns preserve learner progress, and incomplete retries retain the original names and region. @@ -31,7 +35,7 @@ Use **PowerShell 7**, Azure CLI with Bicep support, and an interactive user logi ### Sign in and prepare the subscription -The provider list matches the shared preparation script. An organizer runs registration once; it is skipped for providers already registered. In Cloud Shell, omit `Invoke-Az login` because the session is already authenticated. +Sign in with a subscription Owner account. The provider list matches the shared preparation script and covers baseline deployment, not every provider used by later exercises. An organizer runs registration once; it is skipped for providers already registered. Learners register additional providers when needed using their subscription Owner access. In Cloud Shell, omit `Invoke-Az login` because the session is already authenticated. ```powershell Set-Location '.\03-Azure\01-03-Infrastructure\06_Migration_Secure_AI_Ready' @@ -60,7 +64,7 @@ foreach ($provider in $providers) { } ``` -By default, the signed-in user receives Key Vault secret access. To deploy for other learners, replace `$participantIds` with their Entra **object IDs** and arrange their RG/subscription roles separately; the template does not assign learner RG Owner or subscription Reader. +By default, the signed-in user receives Key Vault secret access. To deploy for other learners, replace `$participantIds` with their Entra **object IDs** and separately assign them **Owner on the Hack subscription**; the template does not assign this role. ### Create a fresh lab and deploy @@ -126,6 +130,6 @@ Keep the RG through Challenges 2-8: it contains both source and migrated resourc ## Testing changes -Use a fresh test RG and a real learner account to exercise deployment, both dashboard variants, Challenges 2-8 with RG Owner/subscription Reader, and preservation of learner progress on a completed Console rerun. The [local harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing) is also available for testing Console automation; its helpers are local-development stand-ins, not the manual deployment requirement. +Use a fresh test RG and a real learner account with **subscription Owner** to exercise deployment, both dashboard variants, provider registration during Challenges 2-8, and preservation of learner progress on a completed Console rerun. The [local harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing) is also available for testing Console automation; its helpers are local-development stand-ins, not the manual deployment requirement. To test unpublished changes, set `githubAccount` and `githubBranch` in [deployment-settings.json](../labautomation/deployment-settings.json) to a repository account and branch or commit containing the matching artifacts. The source must be publicly downloadable by the VMs. Re-import the Console package after edits; the manual example reads the JSON directly. Keep these fields out of `lab-defaults.json`. For official upstream deployment, use `microsoft` and `main` once the matching version is published there. Complete the Azure deployment and learner-flow tests before upstream integration; syntax checks alone are not sufficient. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md index 584f7e80a..ebc4c5ba7 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md @@ -8,7 +8,7 @@ Duration: 15 minutes (verification of an already-provisioned lab) - Please ensure that you successfully verified the [General prerequisites](../../Readme.md#general-prerequisites) before continuing with this challenge. - Your instructor has completed [predeployment](../../resources/README.md) through Hackbox Console or manually. Obtain the subscription, lab resource group, connection details, and login instructions from the Console dashboard or your instructor. -- Your learner account has **Owner** on the assigned resource group and **Reader** on the subscription. +- Your learner account has **Owner on the Hack subscription**, including access to the assigned resource group and permission to register additional providers. On shared subscriptions, this also grants access to other learners' labs; work only in your assigned RG. > [!IMPORTANT] > **Many of the resource deployments in this Hack are adapted from the [Jumpstart ArcBox for IT Pros](https://jumpstart.azure.com/azure_jumpstart_arcbox/ITPro). Special thanks to the [Jumpstart Team](https://aka.ms/arcjumpstart) for their excellent work.** diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md index 7a86fede0..76e835f65 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-02/solution-02.md @@ -6,7 +6,7 @@ Duration: 60 minutes ### **Task 1: Create an Azure Migrate project** -Open the [Azure Portal](https://portal.azure.com) with your assigned learner account: **Owner** on `` and **Reader** on the subscription. +Open the [Azure Portal](https://portal.azure.com) with your assigned learner account: **Owner on the Hack subscription**. Azure Migrate can register additional providers during project creation; RG Owner alone cannot register them at subscription scope. > [!NOTE] > Screenshots illustrate the portal workflow and may show older resource names. Use the lab resource group and Hyper-V host from your platform dashboard throughout; all learner-created resources belong in that group. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md index 0a22228fe..ca63bd1e3 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md @@ -9,7 +9,7 @@ Duration: 40 minutes In [Challenge 5](../challenge-05/solution-05.md) we migrated two servers to Azure in ``. This challenge uses advanced services such as "Cloud Security Posture Management (CSPM)" and "Cloud Workload Protection (CWP)" (Defender for Servers), beyond the basic Defender for Cloud services. > [!IMPORTANT] -> Defender plan activation changes subscription-wide settings and costs on a shared subscription. An authorized **instructor** must approve and enable the required plans before this exercise. Learners have RG Owner and subscription Reader, not subscription Owner; do not broaden every learner's permissions to enable a plan. If the plans are unavailable, ask the instructor or skip this optional challenge. +> Defender plan activation changes subscription-wide settings and costs on a shared subscription. An authorized **instructor** must approve and enable the required plans before this exercise. Although learners have subscription Owner, do not change shared plan settings without instructor approval. If the plans are unavailable, ask the instructor or skip this optional challenge. For instructor setup, open *Defender for Cloud* > *Management* > *Environment settings* and select the shared subscription. Learners can inspect the resulting settings without changing subscription-wide configuration. diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md index ac8e64ff5..0616cb5ef 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md @@ -44,7 +44,7 @@ Opening each page before the incident both proves the baseline and warms IIS/Apa Azure Copilot access is treated as available for this Hack. Confirm: 1. The portal **Copilot** entry opens and the current region is listed in the [Observability Agent regions](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview#regions). -2. Your account has **Owner** on the lab resource group and **Reader** on the subscription. RG Owner allows creation of the monitoring resources and lab-scoped **Monitoring Reader** assignments; Contributor alone can't create role assignments. Do not request subscription Owner or make subscription-level assignments. +2. Your account has **Owner on the Hack subscription**, allowing provider registration, monitoring-resource creation, and role assignments. Keep monitoring resources and the alert identity's **Monitoring Reader** assignments scoped to your lab resource group; the identity does not need your subscription-wide access. 3. The client network allows WebSocket connections to `https://directline.botframework.com`. 4. Your account can create or reuse a user-assigned managed identity in the lab group and grant it **Monitoring Reader** on that group for the query-based metric alerts. @@ -77,7 +77,7 @@ Use the recommended OpenTelemetry experience for both VMs. Windows also needs Lo Azure Monitor automatically creates an `MA___managed` resource group containing its workspace-managed DCR and DCE. This documented **Azure-managed** exception is not an extra learner-created lab group. Do not move those internals or delete their group separately. User-controlled VM collection DCRs still belong in the lab group. > [!IMPORTANT] -> Review every resource-group selection before enabling monitoring. If this preview's wizard cannot select the lab workspaces or keep user-controlled generated resources in the lab group, stop and ask the instructor for a compatible portal configuration. Do not accept an unrelated default group, create another learner group, or broaden permissions to subscription Owner. +> Review every resource-group selection before enabling monitoring. If this preview's wizard cannot select the lab workspaces or keep user-controlled generated resources in the lab group, stop and ask the instructor for a compatible portal configuration. Your subscription Owner access is not a reason to accept an unrelated default group or create another learner group. ### Create the alert-query identity before onboarding @@ -85,7 +85,7 @@ Create the identity before opening **Infrastructure monitoring** on either VM: 1. In the Azure portal, search for **Managed Identities**, select **Create**, and create a user-assigned managed identity such as `id-mh-monitor-alerts` in `` and the same region as the migrated VMs. 2. Open the lab resource group, select **Access control (IAM)** > **Add role assignment**, choose **Monitoring Reader**, and assign it to the new user-assigned managed identity. This lab-scoped assignment covers both migrated VMs and the monitoring workspaces in the group. -3. For a query-based alert scoped to the Azure Monitor workspace, confirm the identity's inherited **Monitoring Reader** access on that workspace. No subscription-level role assignment is needed. +3. For a query-based alert scoped to the Azure Monitor workspace, confirm the identity's inherited **Monitoring Reader** access on that workspace. No subscription-level role assignment is needed for the alert identity. 4. Reuse this identity for both migrated VMs. The [query-based metric alert identity requirements](https://learn.microsoft.com/azure/azure-monitor/alerts/alerts-query-based-metric-alerts-overview#managed-identities-for-query-based-alerts) require read access to the alert scope. This user-assigned identity authorizes OpenTelemetry alert queries. It is different from the VM's system-assigned identity that Azure Monitor Agent can use. The Infrastructure monitoring wizard doesn't create the user-assigned alert identity, so create and authorize it before selecting it in the wizard. From b5a22e767cee87262cf3bb73c12c4faa1024eaee Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Wed, 4 Nov 2026 14:25:14 +0100 Subject: [PATCH 12/20] Publish guest VM credentials on learner dashboards Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deploy-lab.ps1 | 7 +++++++ .../06_Migration_Secure_AI_Ready/resources/README.md | 2 ++ .../walkthrough/challenge-01/solution-01.md | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index 4cedda2ff..df681440e 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -400,3 +400,10 @@ if ($outputs['hostVmName'].Value -ne $hostName) { @{ HackboxCredential = @{ name = 'Migration Source VNet'; value = $outputs['sourceVnetName'].Value } } @{ HackboxCredential = @{ name = 'Migration Destination VNet'; value = $outputs['destinationVnetName'].Value } } @{ HackboxCredential = @{ name = 'Migration Lab Region'; value = $location } } + +# Public training-image defaults, separate from the generated per-lab host credentials. +$guestInitialPassword = 'JS123!!' +@{ HackboxCredential = @{ name = 'Migration Windows Guest Username'; value = 'Administrator'; note = 'Windows OS login for MHBox-AzMigSrv, MHBox-SQL, and MHBox-Win2K22.' } } +@{ HackboxCredential = @{ name = 'Migration Windows Guest Initial Password'; value = $guestInitialPassword; note = 'Initial Windows guest image password, not the host password. If changed, use your updated password.' } } +@{ HackboxCredential = @{ name = 'Migration Ubuntu Guest Username'; value = 'jumpstart'; note = 'Ubuntu OS login for MHBox-Ubuntu-01; use sudo for administrative commands.' } } +@{ HackboxCredential = @{ name = 'Migration Ubuntu Guest Initial Password'; value = $guestInitialPassword; note = 'Initial Ubuntu guest image password, not the host password. If changed, use your updated password.' } } diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index e680239ac..0d683a259 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -27,6 +27,8 @@ The Console deployer applies the approved training RG tags `SecurityControl=Igno 3. Run predeployment. [shared-deploy-lab.ps1](../labautomation/shared-deploy-lab.ps1) registers baseline providers once per subscription; [deploy-lab.ps1](../labautomation/deploy-lab.ps1) deploys and verifies each lab. Learners can register additional providers as required by later exercises. 4. Confirm each learner has subscription Owner, then hand out the Console's connection and credential fields only after verification succeeds. Learners then follow [Solution 1](../walkthrough/challenge-01/solution-01.md). +The dashboard includes **Migration Windows Guest** username and initial-password fields for `MHBox-AzMigSrv`, `MHBox-SQL`, and `MHBox-Win2K22`, plus **Migration Ubuntu Guest** fields for `MHBox-Ubuntu-01`. These are the supplied guest images' lab-only defaults, separate from the generated host login; they are not changed by deployment reruns. If a learner changes a guest password, the dashboard's initial password no longer applies. To add these fields to an already verified Console lab, re-import the updated automation package and rerun its deployment; the completed baseline is reused without restarting guests or resetting learner progress. + Fresh `rg-labuser-0013` labs use `mh-0013-hv` and matching network prefixes. Leading zeros are preserved; learner numbers can contain one to nine digits. Existing labs and nonstandard RG names retain hash-based names. Keep the RG's `LabId`, `LabNameSuffix`, and `LabRegion` records intact: completed reruns preserve learner progress, and incomplete retries retain the original names and region. ## Option 2: Manual deployment diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md index ebc4c5ba7..20c20117f 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-01/solution-01.md @@ -42,7 +42,7 @@ Open *Hyper-V Manager* on the supplied host and ensure that four VMs are running + MHBox-Win2K22 + This system hosts Windows Server 2022 with an installed IIS web server. -The following lab-only credentials are used inside the nested VMs. These are separate from the host login credentials provided by the Console or instructor and stored in Key Vault. +The Console dashboard lists **Migration Windows Guest** username and initial-password fields for the three Windows VMs, and **Migration Ubuntu Guest** fields for the Ubuntu VM. These are separate from the host login credentials provided by the Console or instructor and stored in Key Vault. The following lab-only image defaults also apply to manual deployments; if you change a guest password, use your updated password rather than the dashboard's initial value. **Windows virtual machine credentials:** From e4651d69d9659b62d03a4dc5d438c071654b3662 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Wed, 4 Nov 2026 16:26:33 +0100 Subject: [PATCH 13/20] Clarify Challenge 6 access and policy-enabled Defender plans Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../challenges/challenge-06.md | 9 ++++++--- .../walkthrough/challenge-06/solution-06.md | 11 +++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md index 0cd5f2317..91b01726b 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-06.md @@ -10,12 +10,15 @@ The goal of this exercise is to ... ## Prerequisites -* The instructor has approved and enabled the subscription-wide Defender CSPM and Defender for Servers plans on the shared subscription, including their costs. -* Learners have **Owner on the Hack subscription**, but subscription-wide plan activation remains an instructor task. Do not change shared plan settings without instructor approval. +* Learners have **Owner on the Hack subscription**. Subscription-wide plan changes affect every learner, so do not change shared settings without instructor approval. +* The instructor has confirmed that the shared subscription's Defender CSPM and Defender for Servers plans are enabled and has approved their costs. + +> [!NOTE] +> In the hosted environment, Defender plans are likely already enabled on the shared subscription through Azure Policy enforcement. Verify the current status of both required plans before requesting activation; do not disable or downgrade policy-managed plans. ## Actions -* Verify the instructor-enabled Defender plans, inspect endpoint protection, and review alerts and recommendations for your lab resources. +* Check the existing Defender plans first. If a required plan is missing, ask the instructor to confirm policy enforcement or approve activation. Then inspect endpoint protection and review alerts and recommendations for your lab resources. ## Success criteria diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md index ca63bd1e3..f00eca455 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-06/solution-06.md @@ -4,18 +4,21 @@ Duration: 40 minutes -## **Task 1: Verify instructor-enabled Defender plans** +## **Task 1: Verify existing Defender plans** In [Challenge 5](../challenge-05/solution-05.md) we migrated two servers to Azure in ``. This challenge uses advanced services such as "Cloud Security Posture Management (CSPM)" and "Cloud Workload Protection (CWP)" (Defender for Servers), beyond the basic Defender for Cloud services. > [!IMPORTANT] -> Defender plan activation changes subscription-wide settings and costs on a shared subscription. An authorized **instructor** must approve and enable the required plans before this exercise. Although learners have subscription Owner, do not change shared plan settings without instructor approval. If the plans are unavailable, ask the instructor or skip this optional challenge. +> Learners have **Owner on the Hack subscription**, not just read-only access. Defender plan changes affect subscription-wide settings and costs for all learners. Do not change shared plan settings without instructor approval. If a required plan is unavailable, ask the instructor or skip this optional challenge. -For instructor setup, open *Defender for Cloud* > *Management* > *Environment settings* and select the shared subscription. Learners can inspect the resulting settings without changing subscription-wide configuration. +> [!NOTE] +> In the hosted environment, Defender plans are likely already enabled on the shared subscription through Azure Policy enforcement. Verify both required plans and their settings rather than assuming every feature is enabled. Do not disable or downgrade policy-managed plans. + +Open *Defender for Cloud* > *Management* > *Environment settings* and select the assigned Hack subscription. Check the existing plan status before making any changes. ![image](./img/Def-environment-settings.jpg) -The instructor enables the approved *Defender CSPM* and *Defender for Servers* plans, reviews their features through *Settings*, and saves the configuration. Learners verify that the expected protection is active for their migrated VMs. Screenshots illustrate the workflow; use the actual subscription and lab resource names from your dashboard. +Check whether *Defender CSPM* and *Defender for Servers* are already enabled, and review their features through *Settings*. If both plans are enabled, leave the configuration unchanged and continue with the protection checks for your migrated VMs. If a required plan is missing, ask the instructor to confirm whether Azure Policy should enable it or whether manual activation is approved. Only the instructor enables missing plans after reviewing the shared subscription's requirements and costs. Screenshots illustrate the workflow; use the actual subscription and lab resource names from your dashboard. Verify the *Defender CSPM* settings and monitoring details. From 503679628e5a2a64c4603142c92a26cd3c8ce160 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 08:07:05 +0100 Subject: [PATCH 14/20] Expand Challenge 7 monitoring setup and guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../challenges/challenge-07.md | 27 ++- .../walkthrough/challenge-07/monitoring.bicep | 132 ++++++++++++ .../walkthrough/challenge-07/solution-07.md | 201 +++++++++++++++--- 3 files changed, 316 insertions(+), 44 deletions(-) create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/monitoring.bicep diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md index b677c60de..e879e867c 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md @@ -2,7 +2,7 @@ [Previous Challenge](challenge-06.md) - **[Home](../Readme.md)** - [Next Challenge](challenge-08.md) -Duration: 70 minutes +Duration: 90 minutes (allow extra time for first-time agent installation and ingestion) ## Goal @@ -17,19 +17,25 @@ Operate both migrated web workloads through a closed-loop incident exercise. Ena The Linux alert monitors every process whose `"process.executable.name"` is `apache2`. It becomes true only when all matching Apache worker and parent process series are absent for the configured window. The Windows alert intentionally uses the Service Control Manager event instead of `w3wp.exe`; IIS application-pool idle shutdown can stop a worker process while the web service remains healthy. +The Windows rule detects a service-state transition, not continuous availability: its stopped event can age out and the alert can resolve even while IIS remains stopped. The Apache absence rule can also indicate a VM/telemetry failure. Prove service recovery using service state, local HTTP `200`, and telemetry, not alert status alone. + ## Prerequisites and access assumptions * Challenge 5 is complete and both migrated web VMs in `` are running. * You can connect to both VMs through Azure Bastion and each site responds locally. -* You have **Owner on the Hack subscription**. Before opening **Infrastructure monitoring**, create or reuse one user-assigned managed identity in the lab resource group for both migrated VMs. +* Your learner account has **Owner on the Hack subscription**, not just subscription Reader. This allows provider registration and resource/RBAC creation; it also grants access to other learners' RGs, so work only in your assigned lab group. +* Before opening **Infrastructure monitoring**, create or reuse one user-assigned managed identity in the lab resource group for both migrated VMs. The [walkthrough](../walkthrough/challenge-07/solution-07.md#option-a-create-the-monitoring-foundation-with-code) includes creation commands and a small Bicep template. * Grant that identity **Monitoring Reader** on the lab resource group for the migrated VMs and monitoring workspace. Do not grant the alert identity a subscription-scoped role. * This user-assigned identity authorizes OpenTelemetry query-based and recommended alerts. It is separate from the VM's system-assigned identity used by Azure Monitor Agent; the portal doesn't automatically create the user-assigned alert identity. -* Azure Copilot access, a supported region, and the required telemetry permissions are available to Hack participants. No Azure OpenAI resource, model deployment, or model quota is required. +* For the AI-assisted path, confirm Azure Copilot access, preview region availability, telemetry permissions, and instructor-approved credits. Use the manual investigation fallback if unavailable. No Azure OpenAI resource, model deployment, or model quota is required. * The client network allows WebSocket connections to `https://directline.botframework.com`. > [!IMPORTANT] > Azure Copilot Observability Agent, deep investigations, Azure Monitor issues, and query-based metric alerts include preview experiences whose portal labels and availability can vary by region and tenant. A deep investigation can consume Azure Agent Credits. Follow your instructor's lab access and cost guidance. +> [!NOTE] +> The lab defaults to `swedencentral`, which is not listed on the current [Observability Agent regions page](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview#regions). Do not assume preview access or move the lab to work around it. Confirm availability with the instructor; use the walkthrough's manual investigation fallback when unavailable. Query-based metric alerts have a separate supported-region list. + > [!NOTE] > On-demand chat and deep investigations use the signed-in user's identity and require no `Microsoft.Monitor/observabilityAgents` resource. Do not provision an autonomous Observability Agent resource for this VM-only Hack. The portal preview scopes that resource to Application Insights, and virtual-machine scope isn't a supported new configuration. @@ -44,21 +50,22 @@ The Linux alert monitors every process whose `"process.executable.name"` is `apa | Strength | Consistent Windows/Linux schema and low-latency metrics | Multi-VM views and same-workspace metric/log correlation | | Limitation | Separate queries are required to correlate with logs; built-in multi-VM views are limited | Platform-specific counters and higher ingestion cost | -Enable metrics-based OpenTelemetry monitoring for both VMs. Use classic log collection only on Windows where the authoritative `W3SVC` service event must be delivered to Log Analytics. +Enable metrics-based OpenTelemetry monitoring for both VMs. Collect the Windows `W3SVC` event through a separate Windows Event DCR into Log Analytics; classic performance collection is not required. ## Actions * Warm and validate both web pages before changing either service. -* Create one reusable user-assigned alert identity before opening Infrastructure monitoring, grant its required **Monitoring Reader** access, and select that existing identity in the portal's **Identity** field for both VMs. -* As part of this exercise, explicitly choose or create an Azure Monitor workspace and a compatible regional Log Analytics workspace through the portal **in the lab resource group**, then select those workspaces in Infrastructure monitoring. +* Create the monitoring foundation using the walkthrough's code or portal path: an Azure Monitor workspace, Log Analytics workspace, alert identity, RG-scoped Monitoring Reader assignment, email action group, and Windows event DCR/association. +* Select the pre-created alert identity in the portal's **Identity** field when opening Infrastructure monitoring for both VMs. +* Enable **Use resource or workspace permissions** on the Azure Monitor workspace so VM-resource-scoped PromQL works. Verify the selected VM scope before running queries. * Keep all user-controlled workspaces, identities, DCRs, alert rules, and action groups in the lab resource group. Verify the proposed scope before enabling monitoring; do not accept resources in another user-controlled group. -* Enable OpenTelemetry metrics, per-process metrics, built-in Grafana dashboards, and recommended infrastructure alerts. +* Enable default OpenTelemetry metrics on both VMs, per-process metrics on Linux, and recommended infrastructure alerts; use the built-in Grafana dashboards without creating Azure Managed Grafana. * Confirm query-based and recommended alerts use the pre-created identity and that it retains the required monitoring read access. * Locate the Linux VM's generated `MSVMOtel--` DCR and ensure that `process.uptime` is collected. -* Reuse the selected compatible Log Analytics workspace in the lab resource group for Windows events and associate a focused Event DCR. +* Send Windows events to the Log Analytics workspace through the focused Event DCR. Classic log-based performance collection is optional, not a prerequisite for collecting these events. * Create an Apache PromQL absence alert and an IIS KQL scheduled-query alert. * Stop Apache and IIS in a controlled manner, prove local HTTP failure, and wait for both alerts. -* Start a deep investigation from each fired alert, evaluate evidence and ruled-out hypotheses, and save useful context as an Azure Monitor issue when available. +* Where preview access and credits are available, start a deep investigation from each fired alert and save useful context as an Azure Monitor issue. Otherwise use the manual fallback; in either path, evaluate evidence and ruled-out hypotheses. * Restore both services manually, validate both pages and telemetry recovery, confirm alert resolution, and request a post-incident summary and runbook improvements. ## Success criteria @@ -80,6 +87,8 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Use classic log coll * [Metrics experience for virtual machines](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-opentelemetry-guest) * [Enable enhanced monitoring for an Azure VM](https://learn.microsoft.com/azure/azure-monitor/vm/tutorial-enable-monitoring) * [Manage an Azure Monitor workspace](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage) +* [Azure Monitor workspace access modes](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage-access) +* [Enable VM monitoring with CLI, PowerShell, ARM, or Bicep](https://learn.microsoft.com/azure/azure-monitor/vm/vm-enable-monitoring) * [Customize OpenTelemetry metrics for VMs](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-opentelemetry-guest-modify) * [OpenTelemetry guest OS metrics reference](https://learn.microsoft.com/azure/azure-monitor/vm/metrics-guest-reference) * [PromQL for system and guest OS metrics](https://learn.microsoft.com/azure/azure-monitor/metrics/prometheus-system-metrics-best-practices) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/monitoring.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/monitoring.bicep new file mode 100644 index 000000000..25216b021 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/monitoring.bicep @@ -0,0 +1,132 @@ +targetScope = 'resourceGroup' + +@description('Use the region of the migrated VMs, not the resource group metadata location.') +param location string + +@description('Existing migrated Windows web VM in this resource group.') +@minLength(1) +param windowsVmName string + +@description('An email inbox the learner can actually read.') +@minLength(3) +param notificationEmail string + +var labTags = { + microhackChallenge: '07' +} +var monitoringReaderRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '43d0d8ad-25c7-4714-9337-8ba259a9fe05') + +resource metricsWorkspace 'Microsoft.Monitor/accounts@2025-10-03' = { + name: 'amw-mh-${uniqueString(resourceGroup().id)}' + location: location + tags: labTags + properties: { + metrics: { + enableAccessUsingResourcePermissions: true + } + } +} + +resource logsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: 'law-mh-monitoring' + location: location + tags: labTags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + } +} + +resource alertIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'id-mh-monitor-alerts' + location: location + tags: labTags +} + +resource alertReader 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, alertIdentity.id, monitoringReaderRoleId) + properties: { + principalId: alertIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: monitoringReaderRoleId + } +} + +resource operators 'Microsoft.Insights/actionGroups@2023-01-01' = { + name: 'ag-mh-operators' + location: 'global' + tags: labTags + properties: { + groupShortName: 'MHOperators' + enabled: true + emailReceivers: [ + { + name: 'LabOperator' + emailAddress: notificationEmail + useCommonAlertSchema: true + } + ] + } +} + +resource serviceEvents 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { + name: 'dcr-iis-service-events' + location: location + kind: 'Windows' + tags: labTags + properties: { + dataSources: { + windowsEventLogs: [ + { + name: 'serviceStateChanges' + streams: [ + 'Microsoft-Event' + ] + xPathQueries: [ + 'System!*[System[(EventID=7036)]]' + ] + } + ] + } + destinations: { + logAnalytics: [ + { + name: 'labLogs' + workspaceResourceId: logsWorkspace.id + } + ] + } + dataFlows: [ + { + streams: [ + 'Microsoft-Event' + ] + destinations: [ + 'labLogs' + ] + } + ] + } +} + +resource windowsVm 'Microsoft.Compute/virtualMachines@2024-11-01' existing = { + name: windowsVmName +} + +resource serviceEventsAssociation 'Microsoft.Insights/dataCollectionRuleAssociations@2023-03-11' = { + name: 'mh-iis-service-events' + scope: windowsVm + properties: { + dataCollectionRuleId: serviceEvents.id + } +} + +output azureMonitorWorkspaceId string = metricsWorkspace.id +output logAnalyticsWorkspaceId string = logsWorkspace.id +output alertIdentityId string = alertIdentity.id +output actionGroupId string = operators.id +output windowsEventDcrId string = serviceEvents.id +output windowsEventAssociationId string = serviceEventsAssociation.id +output monitoringReaderAssignmentId string = alertReader.id diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md index 0616cb5ef..2cabd91af 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-07/solution-07.md @@ -1,10 +1,10 @@ # Walkthrough Challenge 7 - Operate the migrated workloads with intelligent observability -[Previous Challenge Solution](../challenge-06/solution-06.md) - **[Home](../../Readme.md)** - [Next Challenge Solution](../challenge-08/solution-08.md) +[Previous Challenge Solution](../challenge-06/solution-06.md) - [**Home**](../../Readme.md) - [Next Challenge Solution](../challenge-08/solution-08.md) -Duration: 70 minutes +Duration: 90 minutes (allow extra time for first-time agent installation and ingestion) -## Task 1: Validate both workloads and complete preflight (5 minutes) +## Task 1: Validate both workloads and complete preflight (10 minutes) Record both VM names, their region, and their Azure resource IDs in ``. Use the actual lab group from the platform dashboard throughout. Connect to each VM with Azure Bastion. Monitoring is a learner exercise; it was not preconfigured during Challenge 1. @@ -32,8 +32,13 @@ Run in Bash: set -euo pipefail sudo systemctl is-active apache2 -curl --fail --silent --show-error --output /dev/null \ - --write-out 'HTTP %{http_code}\n' http://localhost/ +http_status=$(curl --fail --silent --show-error --max-time 10 --output /dev/null \ + --write-out '%{http_code}' http://localhost/) +printf 'HTTP %s\n' "$http_status" +if [[ "$http_status" != 200 ]]; then + echo "Expected HTTP 200 from the local Apache site." >&2 + exit 1 +fi pgrep -a -x apache2 ``` @@ -41,10 +46,10 @@ Opening each page before the incident both proves the baseline and warms IIS/Apa ### Azure Copilot and permissions -Azure Copilot access is treated as available for this Hack. Confirm: +Monitoring setup requires the Owner and identity permissions below in both paths. For the AI-assisted path, also check Copilot, regional availability, and network access. If the preview or credits are unavailable, complete the same monitoring/incident exercise and use the manual fallback in Task 5. 1. The portal **Copilot** entry opens and the current region is listed in the [Observability Agent regions](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview#regions). -2. Your account has **Owner on the Hack subscription**, allowing provider registration, monitoring-resource creation, and role assignments. Keep monitoring resources and the alert identity's **Monitoring Reader** assignments scoped to your lab resource group; the identity does not need your subscription-wide access. +2. Your learner account has **Owner on the Hack subscription**, not just subscription Reader. This allows provider registration, monitoring-resource creation, and role assignments, including access to other learners' RGs. Work only in your assigned lab group. Keep the alert identity's **Monitoring Reader** assignments scoped to that group; the identity does not need your subscription-wide access. 3. The client network allows WebSocket connections to `https://directline.botframework.com`. 4. Your account can create or reuse a user-assigned managed identity in the lab group and grant it **Monitoring Reader** on that group for the query-based metric alerts. @@ -53,7 +58,9 @@ Azure Copilot access is treated as available for this Hack. Confirm: No Azure OpenAI deployment or autonomous Observability Agent resource is required. -## Task 2: Enable modern infrastructure monitoring (15 minutes) +The default lab region, `swedencentral`, is not listed on the current Observability Agent regions page. Check whether **Investigate** is available for your lab and confirm access with the instructor. Use the manual fallback in Task 5 if unavailable; do not move VMs or workspaces merely to obtain preview access. Monitoring/alert availability and Observability Agent availability are separate checks. + +## Task 2: Enable modern infrastructure monitoring (25 minutes) Azure Monitor provides two different guest-monitoring models: @@ -68,13 +75,113 @@ Azure Monitor provides two different guest-monitoring models: Use the recommended OpenTelemetry experience for both VMs. Windows also needs Log Analytics for the authoritative service-control event; this challenge doesn't query classic performance tables. -### Choose the workspaces explicitly in the lab resource group +### What you are creating + +| Resource | Purpose | +| --- | --- | +| Azure Monitor workspace `amw-mh-` | Stores OpenTelemetry metrics for PromQL; enable resource-context access. | +| Log Analytics workspace `law-mh-monitoring` | Stores Windows events in the `Event` table for KQL. | +| User-assigned identity `id-mh-monitor-alerts` | Lets alert rules read monitoring data independently of your user account. | +| Monitoring Reader assignment on the lab RG | Covers the identity's access to both VMs and the workspaces; never assign Owner to this identity. | +| Action group `ag-mh-operators` | Sends notifications to an inbox you can actually read. A training-account UPN might not receive mail. | +| DCR `dcr-iis-service-events` and its Windows VM association | Collect System event 7036 into Log Analytics. | + +Choose **one** foundation setup path below. Both paths still require VM onboarding and service-specific alert creation later. Neither changes Defender plans, grants subscription roles to identities, or creates another learner RG. + +Azure Monitor automatically creates an `MA___managed` resource group containing its workspace-managed DCR and DCE. The code uses a stable per-RG workspace suffix to avoid managed-group name collisions between learners in a shared subscription; in the portal, use a unique name such as `amw-mh-0013`. This **Azure-managed** exception is not an extra learner-created lab group. Do not move those internals or delete their group separately. + +Logs, additional process metrics, and alert rules can incur charges. Use the instructor-approved budget and keep user-controlled VM collection DCRs in the lab group. + +### Option A: Create the monitoring foundation with code + +Use **Azure Cloud Shell in PowerShell mode** or PowerShell 7 with Azure CLI. Cloud Shell is already authenticated; locally, run `az login` with the learner account first. Download [monitoring.bicep](monitoring.bicep) from the same Hack version and upload it using Cloud Shell's **Manage files > Upload**, or use the file from your checkout. Run from the folder containing that file. No extra Azure CLI extension or PowerShell module is needed; Azure CLI needs Bicep support. + +First select the exact subscription, existing lab RG, and **migrated Azure web VMs**, not the Hyper-V host or the source nested guests: + +```powershell +$ErrorActionPreference = 'Stop' +function Invoke-LabAz { + $result = & az @args + if ($LASTEXITCODE -ne 0) { throw 'Azure CLI failed; inspect the error above before continuing.' } + $result +} +$subscriptionId = Read-Host 'Hack subscription ID' +$labRg = Read-Host 'Existing lab resource group' +$windowsVmName = Read-Host 'Migrated Windows web VM name' +$linuxVmName = Read-Host 'Migrated Ubuntu web VM name' +foreach ($value in @($subscriptionId, $labRg, $windowsVmName, $linuxVmName)) { + if ([string]::IsNullOrWhiteSpace($value) -or $value -match '[<>]') { + throw 'Use the actual dashboard subscription/RG and migrated Azure VM names.' + } +} +Invoke-LabAz account set --subscription $subscriptionId +$account = Invoke-LabAz account show --output json | ConvertFrom-Json +if ($account.id -ine $subscriptionId -or $account.user.type -ne 'user') { + throw 'Sign in with the intended learner account and exact subscription ID.' +} +$groupId = Invoke-LabAz group show --name $labRg --query id --output tsv +$windowsVm = Invoke-LabAz vm show --resource-group $labRg --name $windowsVmName --output json | ConvertFrom-Json +$linuxVm = Invoke-LabAz vm show --resource-group $labRg --name $linuxVmName --output json | ConvertFrom-Json +if ($windowsVm.storageProfile.osDisk.osType -ne 'Windows' -or + $linuxVm.storageProfile.osDisk.osType -ne 'Linux' -or + $windowsVm.location -ne $linuxVm.location) { + throw 'Select the Windows and Linux web VMs in the same migration target region.' +} +$location = $windowsVm.location +$windowsVmId = $windowsVm.id +$linuxVmId = $linuxVm.id +[pscustomobject]@{ + User = $account.user.name; Subscription = $account.id; ResourceGroup = $labRg + Location = $location; WindowsVM = $windowsVmId; LinuxVM = $linuxVmId +} | Format-List +``` + +Review the printed scope. The next block registers missing monitoring providers subscription-wide, previews the deployment, and creates the dedicated resources only after confirmation. It refuses to adopt same-named portal-created/shared resources; use Option B for an existing portal setup. Rerunning this template's own setup reuses its resource names and deterministic role assignment. Do not mix the two creation paths for the same resources. + +```powershell +$templateFile = (Resolve-Path '.\monitoring.bicep').ProviderPath +$notificationEmail = Read-Host 'Notification email address you can read' +if ($notificationEmail -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + throw 'Supply a usable email address, not an unverified training-account UPN.' +} +$plannedNames = @('law-mh-monitoring', 'id-mh-monitor-alerts', + 'ag-mh-operators', 'dcr-iis-service-events') +$conflicts = @(Invoke-LabAz resource list --resource-group $labRg --output json | + ConvertFrom-Json | Where-Object { + ($_.name -in $plannedNames -or + ($_.type -eq 'Microsoft.Monitor/accounts' -and $_.name -like 'amw-mh-*')) -and + $_.tags.microhackChallenge -ne '07' + }) +if ($conflicts.Count -gt 0) { + throw 'A planned resource name already exists outside this template. Use the portal reuse path.' +} +foreach ($provider in @('Microsoft.Monitor', 'Microsoft.OperationalInsights', + 'Microsoft.Insights', 'Microsoft.ManagedIdentity')) { + $state = Invoke-LabAz provider show --namespace $provider --query registrationState --output tsv + if ($state -ne 'Registered') { + Invoke-LabAz provider register --namespace $provider --wait --output none + } +} +$parameters = @("location=$location", "windowsVmName=$windowsVmName", + "notificationEmail=$notificationEmail") +Invoke-LabAz deployment group what-if --resource-group $labRg --template-file $templateFile --parameters @parameters +if ((Read-Host 'Review the proposed resources, region and RG; type DEPLOY to continue') -cne 'DEPLOY') { + throw 'Deployment not confirmed; no template was submitted.' +} +$monitoring = Invoke-LabAz deployment group create --name challenge07-monitoring ` + --resource-group $labRg --mode Incremental --template-file $templateFile ` + --parameters @parameters --query properties.outputs --output json | ConvertFrom-Json +$monitoring | Format-List +``` + +Record the output resource IDs. The template creates the event DCR and association, **not** the Azure Monitor Agent extension, OpenTelemetry DCRs, recommended alerts, or the two service alerts. Continue with VM onboarding below. Do not interpret ARM success as proof of telemetry ingestion. If region availability, Azure Policy, or private-network requirements block deployment, involve the instructor rather than changing shared policy or disabling network controls. + +### Option B: Create or reuse the foundation in the portal 1. In the portal, open **Azure Monitor workspaces**. Select a suitable existing workspace in ``, or select **Create**, choose the Hack subscription and that exact resource group, and create an Azure Monitor workspace in a supported region for the migrated VMs. Follow [Manage an Azure Monitor workspace](https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-manage). 2. Open **Log Analytics workspaces** and choose or create a workspace in **the same lab resource group**. Use a regional workspace supported by the Windows Infrastructure monitoring flow, normally in the VM's region. A workspace in another region is usable only if the current portal flow supports it; compatibility is not implied by placing it in the same RG. Azure Monitor workspaces hold metrics; Log Analytics workspaces hold logs and aren't interchangeable. 3. Record both workspace resource IDs. During onboarding, explicitly select these workspaces rather than accepting a default in another resource group. Keep all user-controlled DCRs, alert rules, action groups, and identities in the lab group too. - -Azure Monitor automatically creates an `MA___managed` resource group containing its workspace-managed DCR and DCE. This documented **Azure-managed** exception is not an extra learner-created lab group. Do not move those internals or delete their group separately. User-controlled VM collection DCRs still belong in the lab group. +4. Open the Azure Monitor workspace's **Settings > Properties**, turn on **Enable access using resource permissions**, and apply. This is **Use resource or workspace permissions** access mode; old workspaces can require workspace permissions instead. > [!IMPORTANT] > Review every resource-group selection before enabling monitoring. If this preview's wizard cannot select the lab workspaces or keep user-controlled generated resources in the lab group, stop and ask the instructor for a compatible portal configuration. Your subscription Owner access is not a reason to accept an unrelated default group or create another learner group. @@ -83,28 +190,36 @@ Azure Monitor automatically creates an `MA___managed` Create the identity before opening **Infrastructure monitoring** on either VM: +If you used Option A, the identity and RG-scoped assignment already exist; verify them instead of creating duplicates. + 1. In the Azure portal, search for **Managed Identities**, select **Create**, and create a user-assigned managed identity such as `id-mh-monitor-alerts` in `` and the same region as the migrated VMs. 2. Open the lab resource group, select **Access control (IAM)** > **Add role assignment**, choose **Monitoring Reader**, and assign it to the new user-assigned managed identity. This lab-scoped assignment covers both migrated VMs and the monitoring workspaces in the group. -3. For a query-based alert scoped to the Azure Monitor workspace, confirm the identity's inherited **Monitoring Reader** access on that workspace. No subscription-level role assignment is needed for the alert identity. +3. Confirm the identity's inherited **Monitoring Reader** access on both VMs and the Azure Monitor workspace. No subscription-level role assignment is needed for the alert identity. 4. Reuse this identity for both migrated VMs. The [query-based metric alert identity requirements](https://learn.microsoft.com/azure/azure-monitor/alerts/alerts-query-based-metric-alerts-overview#managed-identities-for-query-based-alerts) require read access to the alert scope. This user-assigned identity authorizes OpenTelemetry alert queries. It is different from the VM's system-assigned identity that Azure Monitor Agent can use. The Infrastructure monitoring wizard doesn't create the user-assigned alert identity, so create and authorize it before selecting it in the wizard. +RBAC propagation can take a few minutes. If the wizard cannot validate the identity immediately, recheck its **principal ID**, assignment scope, and workspace access mode; do not work around the delay by granting it subscription Owner. + +### Continue here after either foundation path + +The workspace IDs, alert identity, and its Monitoring Reader assignment must be ready before onboarding. Use the VM resource IDs recorded above for all query/alert filters; for Option B, copy them from each migrated VM's **Properties > Resource ID**. + ### Onboard the Windows VM 1. Open the Windows VM and select **Monitor**. If enhanced monitoring isn't enabled, select **Configure**. 2. Select **Customize infrastructure monitoring**. 3. Keep **OpenTelemetry metrics** selected. 4. Optionally select the OpenTelemetry detailed or per-process option to explore the cross-platform schema. The Windows alert doesn't use process metrics, and enabling them can incur ingestion cost. -5. Also select **Classic Log-based metrics** for the Windows VM and explicitly select the compatible regional Log Analytics workspace in the lab group for the Windows Event DCR and Logs-scoped agent experience. This also enables classic performance ingestion as a side effect and can incur Log Analytics cost. +5. Leave **Classic Log-based metrics** off for this guided path. The separate Windows Event DCR sends service events to Log Analytics without collecting classic performance data. Optionally enable the classic experience for comparison, select the lab Log Analytics workspace explicitly, and accept its additional performance-ingestion cost. 6. Select the Azure Monitor workspace chosen above and confirm the generated user-controlled DCR is placed in ``. Do not accept a regional default workspace or collection rule in another user-controlled group. -7. Keep **Dashboards with Grafana** and **Recommended alerts** selected when offered. Recommended alerts cover infrastructure health; they don't replace the service-specific alerts created in Task 3. +7. Keep **Recommended alerts** selected when offered. Recommended alerts cover infrastructure health; they don't replace the service-specific alerts created in Task 3. Built-in **Dashboards with Grafana** are available without provisioning a separate Azure Managed Grafana resource. 8. In **Identity**, select the pre-created `id-mh-monitor-alerts` user-assigned identity. Confirm its lab-RG **Monitoring Reader** assignment covers the VM and Azure Monitor workspace. 9. Review all user-controlled DCR, alert, and action-group resource-group selections, keep them in the lab group, and enable monitoring. -Before continuing, confirm that the review page names the selected Azure Monitor workspace, compatible regional Log Analytics workspace, and generated VM collection DCR **in the lab group**. If no Log Analytics workspace appears, return to customization and confirm **Classic Log-based metrics** is selected. If your selected regional workspace is not offered, ask the instructor to check region/service compatibility before proceeding; don't silently accept an out-of-scope default. +Before continuing, confirm that the review page names the selected Azure Monitor workspace and generated VM collection DCR **in the lab group**. A Log Analytics selector is expected only if you chose the optional classic experience; its absence does not prevent the separate Windows Event DCR from collecting events. Do not silently accept an out-of-scope default. -The final configuration should show both **OpenTelemetry metrics** and **Classic Log-based metrics**, your selected workspaces, recommended alerts, and the pre-created user-assigned identity. Portal labels can change; the resource IDs and lab RG are the authoritative checks. +The final configuration should show **OpenTelemetry metrics**, your selected Azure Monitor workspace, recommended alerts, and the pre-created user-assigned identity. The Windows Event DCR targets your Log Analytics workspace separately. Portal labels can change; the resource IDs and lab RG are the authoritative checks. > [!NOTE] > As part of Azure Monitor Agent installation, Azure can enable a system-assigned identity on the VM. That VM identity is separate from the pre-created user-assigned identity selected for alert queries; the wizard doesn't create the user-assigned alert identity. @@ -116,10 +231,10 @@ Repeat the Infrastructure monitoring flow for the Linux VM: 1. Keep **OpenTelemetry metrics** and its detailed/per-process option selected. 2. Leave **Classic Log-based metrics** off; Apache uses an OpenTelemetry process signal and doesn't require a Log Analytics workspace. 3. Explicitly select the same Azure Monitor workspace in the lab resource group. -4. Keep the generated VM collection DCR, dashboard configuration, and recommended alert/action-group resources in the lab resource group. +4. Keep the generated VM collection DCR and recommended alert/action-group resources in the lab resource group. Use the built-in Grafana dashboard after onboarding. 5. Select the same pre-created user-assigned alert identity and enable monitoring. -The portal installs `AzureMonitorWindowsAgent` or `AzureMonitorLinuxAgent`. Provisioning, DCR association, and the first metric samples take several minutes. +The portal installs `AzureMonitorWindowsAgent` or `AzureMonitorLinuxAgent`. Provisioning, DCR association, and the first metric samples take several minutes. An existing agent can collect from multiple DCRs; do not remove policy-managed agents or unrelated DCR associations to make this exercise work. ### Verify and customize the generated OpenTelemetry DCR @@ -134,7 +249,7 @@ Default OpenTelemetry metrics such as `system.uptime`, `system.cpu.time`, and `s ### Verify metrics on both VMs -From each VM, open **Metrics** and choose **View Azure Monitor workspace metrics in editor**, or open the Azure Monitor workspace PromQL editor. When the query is launched from a VM resource, Azure applies that VM's resource scope. +From each VM, open **Metrics** and choose **View AMW metrics in editor** or **Add with editor**. Verify that the editor's **Scope** is that single VM, not the workspace, RG, or subscription. Resource-context access must be enabled on the workspace. The queries below rely on this VM scope; a workspace-wide query can accidentally include other machines and mask an Apache outage. Run: @@ -153,13 +268,15 @@ The first query must return recent data for each VM. The Linux query must return > [!NOTE] > Azure Monitor PromQL uses Prometheus 3 UTF-8 syntax. Metric and label names containing dots are quoted inside braces, for example `{"process.uptime", "process.executable.name"="apache2"}`. -## Task 3: Create service-specific alert paths (15 minutes) +## Task 3: Create service-specific alert paths (20 minutes) + +If you used Option A, `ag-mh-operators` already exists. Otherwise, open **Monitor > Alerts > Action groups > Create**, choose the Hack subscription and lab RG, name it `ag-mh-operators`, use display name `MHOperators`, and add an **Email** notification to an inbox you can read. Enable the common alert schema when offered. Select **Review + create**, then use **Test action group** to confirm delivery. Do not rely on an automatically selected training-account email address unless it receives mail. -Reuse the action group created by recommended monitoring alerts **in the lab group**, or create `ag-mh-operators` in `` with an email receiver you can check. Save both service-specific alert rules below in that same group. +Save both service-specific alert rules below in the lab group and select this action group on their **Actions** tab. An action-group test checks notification delivery, not VM telemetry or the alert condition. ### Windows/IIS: collect the authoritative service event -Create a focused DCR: +Option A already created `dcr-iis-service-events` and associated it with the Windows VM. Verify it rather than creating a second copy. For Option B, create a focused DCR: 1. Open **Monitor** > **Data Collection Rules** and select **Create**. 2. Name it `dcr-iis-service-events`, use ``, the VM's region, and platform type **Windows**. @@ -171,7 +288,7 @@ Create a focused DCR: System!*[System[(EventID=7036)]] ``` -6. Set the destination to **Azure Monitor Logs** and select the compatible regional Log Analytics workspace in the lab group chosen during Windows onboarding. +6. Set the destination to **Azure Monitor Logs** and select the Log Analytics workspace from the monitoring foundation, not the Azure Monitor metrics workspace. 7. Create the DCR and verify its VM association. Confirm that the DCR's **Resources** page shows the Windows VM. You can use the Log Analytics workspace's **Logs** page to inspect any service-control records collected after the association: @@ -185,7 +302,7 @@ Event | order by TimeGenerated desc ``` -Zero rows are valid when no service has changed state since the DCR became active. The deterministic ingestion gate is the `W3SVC` stopped event generated and queried in Task 4; don't create a false service interruption only to populate this preflight query. +Zero rows are valid when the `Event` table exists but no matching service event has arrived. A missing `Event` table is not the same as zero rows: first verify the AMA extension, event DCR association, and destination. If this is the workspace's first event ingestion, use the controlled IIS stop from Task 4 as the first event and wait for ingestion. Restore IIS using Task 6, finish creating the alert below, then repeat Task 4 with a fresh stop time. This avoids losing the initial event outside the ten-minute alert window while configuring the rule. Do not fabricate events or stop unrelated services. Create a **Custom log search** scheduled-query alert from the workspace: @@ -208,20 +325,23 @@ Configure: | Alert name | `IIS W3SVC stopped` | | Measure | Table rows | | Aggregation | Count over 10 minutes | +| Azure Resource ID column | `_ResourceId`, so the fired alert targets the Windows VM rather than just the workspace | | Condition | Greater than `0` | | Evaluation frequency | 5 minutes | | Severity | 2 - Warning | | Action group | `ag-mh-operators` | | Enable | Selected | -Enable automatic resolution when offered. Do not alert on `w3wp.exe` absence: application-pool idle timeout can remove the worker process while `W3SVC` and the site remain healthy. +Enable automatic resolution when offered. If the **Identity** section is shown, select `id-mh-monitor-alerts`; its lab-RG Monitoring Reader assignment includes the Log Analytics workspace. Run the KQL with the actual Windows VM resource ID, select **Continue editing alert**, configure the table above, and check the resource group on **Details** before creating the rule. These service-name/state filters assume the supplied English Windows image; adapt them to the actual SCM event if using a localized image. Do not alert on `w3wp.exe` absence: application-pool idle timeout can remove the worker process while `W3SVC` and the site remain healthy. + +This is a **transition alert**: the stopped event eventually leaves the ten-minute window, even if IIS stays stopped. Its automatic resolution is not proof of recovery. The running event, service check, and HTTP `200` in Task 6 provide that proof. A production persistent-outage alert needs a suitable continuous service-state signal or synthetic availability check. ### Linux/Apache: alert only when every Apache process disappears -Open **Monitor** > **Alerts** > **Create** > **Alert rule** and scope the rule to the Linux VM. Select the query-based metric alert condition and use: +Open the **Linux VM > Alerts > Create > Alert rule**. Confirm that **Scope** is the Linux VM resource ID. On **Condition**, choose the query-based/PromQL metric condition, not a classic platform-metric condition, and use: ```promql -absent_over_time({"process.uptime", "process.executable.name"="apache2"}[5m]) +absent_over_time({"process.uptime", "process.executable.name"="apache2"}[5m]) > 0 ``` Configure: @@ -229,7 +349,7 @@ Configure: | Setting | Value | | --- | --- | | Alert name | `Apache processes absent` | -| Condition | Query result greater than `0` | +| Condition | The PromQL expression returns a series; `> 0` is already part of the expression | | Evaluation frequency | 1 minute | | Failing period | 1 minute | | Auto resolution | Enabled | @@ -239,10 +359,16 @@ Configure: `apache2` normally has a parent and multiple workers, each with its own `process.uptime` series. `absent_over_time` returns no result while any matching series exists and returns `1` only after all matching series have been absent for the full five-minute window. Allow up to 10 minutes after the stop command for the final sample, absence window, collection, and alert evaluation before treating a missing alert as a configuration problem. +On **Identity**, select `id-mh-monitor-alerts`; on **Actions**, select `ag-mh-operators`; on **Details**, use the lab RG and a query-alert-supported region. Verify the VM scope again on **Review + create**. An AMW-scoped absence query without a VM filter could stay healthy because another VM still runs Apache. + +An absence alert also fires when the VM or telemetry pipeline stops reporting; it does not by itself prove a service-only outage. Require the healthy preflight sample, local HTTP failure, and continuing host metrics before making that diagnosis. + ## Task 4: Induce and prove both controlled incidents (10 minutes) Warm both pages once more before stopping either service. Process/service telemetry proves component state, while the local HTTP request proves user-visible impact in this private lab. Neither signal is a substitute for production synthetic availability monitoring. +Record each stop command and its UTC time (`(Get-Date).ToUniversalTime().ToString('o')` on Windows; `date -u +%FT%TZ` on Linux). Azure Activity Log records Azure control-plane operations, not guest `Stop-Service` or `systemctl` commands. Service events/process metrics prove the state change, not which person or command caused it; use your controlled-incident record as ground truth. + ### Stop IIS and prove impact Run in elevated Windows PowerShell: @@ -315,7 +441,7 @@ The count disappears after the final process series stops reporting. The absence ## Task 5: Investigate, decide, and preserve context with AI (15 minutes) -Open each fired alert and select **Investigate**. Review the Azure Agent Credit notice, select **Start chat**, and let the Observability Agent complete its deep investigation. +Open each fired alert and select **Investigate** when available. Review the Azure Agent Credit notice, verify the resource and incident time window in the temporary chat, and confirm the proposed investigation or provide the prompts below. Some portal versions first show **Start chat**. A deep investigation can consume up to 300 Azure Agent Credits; obtain approval for both investigations before starting. If the entry point, region, tenant access, or credits are unavailable, use the manual fallback below. ### Investigate the IIS alert @@ -341,7 +467,7 @@ and timestamps. Recommend the safest human-executed remediation, rollback, valid and alert-tuning or prevention improvements. Do not change resources or start services. ``` -Then ask from either investigation: +For the comparison, provide both VM resource IDs, incident times, and the other investigation's findings, or explicitly add the second VM to the investigation scope. An alert-scoped investigation does not automatically have both incidents' context. Then ask: ```text Compare the two incidents. Did they share a platform, network, capacity, or regional @@ -428,8 +554,13 @@ set -euo pipefail sudo systemctl enable --now apache2 systemctl is-active --quiet apache2 pgrep -a -x apache2 -curl --fail --silent --show-error --output /dev/null \ - --write-out 'HTTP %{http_code}\n' http://localhost/ +http_status=$(curl --fail --silent --show-error --max-time 10 --output /dev/null \ + --write-out '%{http_code}' http://localhost/) +printf 'HTTP %s\n' "$http_status" +if [[ "$http_status" != 200 ]]; then + echo "Expected HTTP 200 after Apache recovery." >&2 + exit 1 +fi ``` Verify that process telemetry returns: @@ -440,9 +571,9 @@ count({"process.uptime", "process.executable.name"="apache2"}) The value must again be greater than zero. Use Bastion to validate both web pages and confirm their hostname, platform, and web-server details remain correct. -The Apache alert can resolve after process samples return. The IIS alert resolves only after the stopped event leaves its query window and a later evaluation succeeds. Review the alert timelines rather than forcing closure. +The Apache alert can resolve after process samples return. The IIS transition alert can auto-resolve after the stopped event leaves its query window, regardless of actual service recovery. Require the running event and HTTP `200` before calling IIS recovered. Review both alert timelines rather than forcing closure. -Ask the agent in the saved investigation or issue: +If agent access is available, ask in the saved investigation or issue. Otherwise, write the same summary from your manual evidence: ```text Create a concise post-incident summary for both service failures. Include impact, @@ -459,7 +590,7 @@ Keep monitoring while completing Challenge 8. After the Hack: * Remove the two lab service alerts, their action-group links, and the Windows service-event DCR if they aren't shared. * Remove additional per-process metrics if they were enabled only for this lab and their ongoing cost isn't justified. * Don't delete portal-created Azure Monitor or Log Analytics workspaces, generated DCRs, dashboards, or action groups until you confirm no other resources use them. -* Don't delete the shared user-assigned alert identity until you confirm no other alert rules use it. +* Don't delete the shared user-assigned alert identity until you confirm no other alert rules use it; remove its lab-specific RG role assignment when it is no longer needed. Continue to Challenge 8, where you select either recovered workload for the guided replatforming exercise. From e092ce72287f08fd6417b58ae6446d6eef79b635 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 08:32:11 +0100 Subject: [PATCH 15/20] Default migration lab Bastion hosts to Basic SKU Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/bicep/main.bicep | 4 ++-- .../labautomation/deploy-lab.ps1 | 14 -------------- .../resources/README.md | 6 +++++- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep index 3d9ac8a10..49df74f01 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep @@ -55,13 +55,13 @@ param githubBranch string = 'main' @description('Choice to deploy Bastion to connect to the client VM') param deployBastion bool = true -@description('Bastion host Sku name. The Developer SKU is currently supported in a limited number of regions: https://learn.microsoft.com/azure/bastion/quickstart-developer-sku') +@description('Bastion host SKU. Basic provides dedicated portal-based RDP and SSH access by default.') @allowed([ 'Basic' 'Standard' 'Developer' ]) -param bastionSku string = 'Developer' +param bastionSku string = 'Basic' @description('User github account where they have forked https://github.com/Azure/jumpstart-apps') param githubUser string = 'Azure' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 index df681440e..0b6e328d2 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -181,23 +181,9 @@ if (-not $alreadyReady -and ($null -eq $deployment -or $deployment.ProvisioningS } $candidateLocations = if ($location) { @($location) } else { $locations } $location = $null - # Developer SKU coverage is narrower than bastionHosts provider coverage. - # https://learn.microsoft.com/azure/bastion/bastion-sku-comparison#regional-availability - $bastionDeveloperLocations = @( - 'brazilsouth', 'canadacentral', 'canadaeast', 'centralus', 'centraluseuap', - 'eastus2', 'eastus2euap', 'mexicocentral', 'northcentralus', 'westcentralus', 'westus', - 'francecentral', 'germanywestcentral', 'italynorth', 'northeurope', 'norwayeast', - 'spaincentral', 'swedencentral', 'switzerlandnorth', 'uksouth', 'ukwest', 'westeurope', - 'australiacentral', 'australiaeast', 'australiasoutheast', 'centralindia', 'eastasia', - 'japaneast', 'japanwest', 'koreacentral', 'koreasouth', 'southindia', 'southeastasia', - 'southafricanorth', 'uaenorth' - ) foreach ($candidate in $candidateLocations) { Update-MhhToken | Out-Null $reasons = [Collections.Generic.List[string]]::new() - if ($candidate -notin $bastionDeveloperLocations) { - $reasons.Add('Bastion Developer is not in the documented regional availability list') - } foreach ($requiredType in @( @{ Provider = 'Microsoft.Network'; Type = 'virtualNetworks' }, @{ Provider = 'Microsoft.Network'; Type = 'natGateways' }, diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 0d683a259..b445d81d2 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -11,10 +11,14 @@ Instructors prepare Challenge 1 before the session. Both deployment options use - Require **Owner on the Hack subscription** for the deployment account and learner accounts. This allows learners to register additional resource providers during the exercises; RG Owner with subscription Reader is not sufficient when those providers are not preregistered. Manual organizers assign subscription Owner themselves. - Arrange any separate Microsoft Entra permissions needed for Azure Migrate appliance registration; subscription Owner does not grant directory roles. -- Check regional capacity and quota. The host needs **Standard_D16s_v5**, zone **1**, a **PremiumV2_LRS** data disk, and Bastion Developer support; the default region is **swedencentral**. Budget per learner for 30 regional vCPUs, including 16 DSv5 and 10 BS-family vCPUs, and five Standard public IPs across the Hack. See [quota requests](https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal). +- Check regional capacity and quota. The host needs **Standard_D16s_v5**, zone **1**, a **PremiumV2_LRS** data disk, and Azure Bastion support; the default region is **swedencentral**. Budget per learner for 30 regional vCPUs, including 16 DSv5 and 10 BS-family vCPUs, and five Standard public IPs across the Hack. See [quota requests](https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal). - Resolve subscription policy restrictions and any required feature registrations with the organizer. Optional Defender plans must be separately approved and enabled; deployment does not change subscription-wide Defender plans. - Allow **35-90+ minutes** for initial setup. The USD 80 daily baseline estimate in [lab-defaults.json](../labautomation/lab-defaults.json) is a conservative planning value, not a quote; later exercises add costs. +New deployments use **two paid Basic SKU Bastion hosts**, one per VNet, each with a Standard static public IP and a dedicated `/26` `AzureBastionSubnet`. Basic supports the lab's portal-based RDP/SSH connections and concurrent sessions; native-client tunneling and IP-based connections require Standard or higher. Review [Bastion pricing](https://azure.microsoft.com/pricing/details/azure-bastion/), since Basic is billed hourly even when not connected. + +Updating the package does not change Bastion in an already verified Console lab: completed reruns reuse the existing baseline. Upgrade existing Developer hosts separately using the [supported SKU upgrade procedure](https://learn.microsoft.com/azure/bastion/upgrade-sku); do not clear readiness records or redeploy the whole lab just to change Bastion. + > [!WARNING] > Subscription Owner grants control of all resource groups, resources, role assignments, and subscription settings, including other learners' labs in a shared subscription. Learner RGs are not security-isolated. Use trusted training accounts, work only in the assigned lab RG, and obtain instructor approval before changing shared settings. Remove temporary subscription Owner assignments after the Hack. From 196cce7fb8e7019f4940870b5748d8aeee40d4e5 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 10:08:34 +0100 Subject: [PATCH 16/20] Explain observability and SRE agent models in Challenge 7 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../challenges/challenge-07.md | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md index e879e867c..2a18716b0 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md @@ -8,6 +8,38 @@ Duration: 90 minutes (allow extra time for first-time agent installation and ing Operate both migrated web workloads through a closed-loop incident exercise. Enable modern OpenTelemetry monitoring, create an alert from the authoritative signal for each workload, induce controlled service failures, investigate the fired alerts with Azure Copilot Observability Agent, restore service under human control, and prove recovery. +## Before you start: understand the agents + +Azure Copilot Observability Agent has **two operating models**. Azure SRE Agent is a **separate service**, not a third mode or SKU of Observability Agent. + +### Two models for Azure Copilot Observability Agent + +| Aspect | On-demand chat and deep investigations | Deployed Observability Agent resource (preview) | +| --- | --- | --- | +| Setup | No agent resource to create. Start from an Azure Monitor alert's **Investigate** action or a supported chat entry point. | Create a `Microsoft.Monitor/observabilityAgents` resource with durable scope and configuration. | +| Identity | Uses the signed-in user's Azure permissions. | Uses the resource's managed identity for autonomous runs. | +| Behavior | Investigates when a user asks; correlates telemetry and recommends next steps. | Can run opted-in background alert correlation, issue creation, and investigations, with persistent custom instructions. | +| VM investigations | Deep investigations support virtual machines, subject to preview availability and telemetry access. | New VM-only scope is **not a supported configuration** in the current preview; the resource currently targets Application Insights. | +| Used in this Hack? | **Yes, in Task 5.** | **No. Do not deploy this resource for Challenge 7.** | + +Microsoft notes that existing legacy VM scopes on deployed Observability Agent resources aren't blocked, but aren't a tested path. That exception is not a supported basis for a new VM-only lab deployment. + +In **both** Observability Agent models, investigation and recommendations do not mean automatic remediation. Even with autonomous operations enabled, the agent does not restart services, change workload configuration, or resolve the incident on its own. Humans decide and execute recovery. + +### How Azure SRE Agent differs + +**Azure SRE Agent** is a broader operations teammate. It connects Azure resources, observability data, incident-management tools, source repositories, and runbooks to investigate incidents and automate operational workflows. + +Unlike Observability Agent's recommendation-and-handoff model, SRE Agent can **execute permitted mitigations** using configured tools and access. Its permissions and approval controls are separate: in **Review** mode, Azure infrastructure write actions require approval; **Autonomous** mode can execute permitted actions without waiting for per-action approval. RBAC, tool policies, and configured access still constrain what it can do. Autonomous mode does not grant extra permissions or bypass governance. + +SRE Agent requires its own setup, access configuration, and cost review. **It is not deployed or required in this Hack.** Using SRE Agent for VM operations would be a separate exercise with its own scoped permissions, tools, and approval design; it does not change the Observability Agent resource's preview limitations. + +### What you will use in Challenge 7 + +Tasks 2-4 prepare telemetry and alerts. Task 5 invokes the **on-demand Observability Agent** from the fired alerts. Task 6 is **human-executed recovery**. If preview access is unavailable, the manual fallback still exercises monitoring and incident analysis, but **does not demonstrate the Observability Agent**. + +Do not confuse these AI services with **Azure Monitor Agent (AMA)**, which runs on the VMs to collect telemetry. Likewise, `id-mh-monitor-alerts` authorizes alert queries; it is not an Observability Agent resource or the identity used by on-demand investigations. + ## Workloads and authoritative signals | Workload | Service | Authoritative alert signal | @@ -36,9 +68,6 @@ The Windows rule detects a service-state transition, not continuous availability > [!NOTE] > The lab defaults to `swedencentral`, which is not listed on the current [Observability Agent regions page](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview#regions). Do not assume preview access or move the lab to work around it. Confirm availability with the instructor; use the walkthrough's manual investigation fallback when unavailable. Query-based metric alerts have a separate supported-region list. -> [!NOTE] -> On-demand chat and deep investigations use the signed-in user's identity and require no `Microsoft.Monitor/observabilityAgents` resource. Do not provision an autonomous Observability Agent resource for this VM-only Hack. The portal preview scopes that resource to Application Insights, and virtual-machine scope isn't a supported new configuration. - ## Monitoring models | | OpenTelemetry metrics-based | Classic logs-based | @@ -56,7 +85,7 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Collect the Windows * Warm and validate both web pages before changing either service. * Create the monitoring foundation using the walkthrough's code or portal path: an Azure Monitor workspace, Log Analytics workspace, alert identity, RG-scoped Monitoring Reader assignment, email action group, and Windows event DCR/association. -* Select the pre-created alert identity in the portal's **Identity** field when opening Infrastructure monitoring for both VMs. +* Select the pre-created identity for query-based alert rules and, when offered, recommended-alert setup. This is alert authorization, not a setting in the metrics/DCR customization pane. * Enable **Use resource or workspace permissions** on the Azure Monitor workspace so VM-resource-scoped PromQL works. Verify the selected VM scope before running queries. * Keep all user-controlled workspaces, identities, DCRs, alert rules, and action groups in the lab resource group. Verify the proposed scope before enabling monitoring; do not accept resources in another user-controlled group. * Enable default OpenTelemetry metrics on both VMs, per-process metrics on Linux, and recommended infrastructure alerts; use the built-in Grafana dashboards without creating Azure Managed Grafana. @@ -98,4 +127,7 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Collect the Windows * [Collect Windows events with Azure Monitor Agent](https://learn.microsoft.com/azure/azure-monitor/vm/data-collection-windows-events) * [Create Azure Monitor log search alert rules](https://learn.microsoft.com/azure/azure-monitor/alerts/alerts-create-log-alert-rule) * [Azure Copilot Observability Agent overview](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-overview) +* [Observability Agent resource: operating models and preview limitations](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-resource) * [Deep investigations with Observability Agent](https://learn.microsoft.com/azure/azure-monitor/aiops/observability-agent-deep-investigations) +* [Azure SRE Agent overview](https://learn.microsoft.com/azure/sre-agent/overview) +* [Azure SRE Agent run modes and approval controls](https://learn.microsoft.com/azure/sre-agent/run-modes) From 248566eb75d2393ab6a114b09dce436d22b62b87 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 15:32:21 +0100 Subject: [PATCH 17/20] Parallelize Bastion and migration host deployment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/bicep/main.bicep | 27 ++++++++++ .../labautomation/bicep/mgmt/bastion.bicep | 51 +++++++++++++++++++ .../bicep/mgmt/mgmtArtifactsDestination.bicep | 41 ++------------- .../bicep/mgmt/mgmtArtifactsSource.bicep | 40 ++------------- .../resources/README.md | 2 + 5 files changed, 88 insertions(+), 73 deletions(-) create mode 100644 03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/bastion.bicep diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep index 49df74f01..1e0c1d6e1 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/main.bicep @@ -164,6 +164,33 @@ module mgmtArtifactsDestinationDeployment 'mgmt/mgmtArtifactsDestination.bicep' } +// Bastion depends on networking, but must not delay host VM startup. +module sourceBastionDeployment 'mgmt/bastion.bicep' = if (deployBastion) { + name: '${namingPrefix}-sourceBastionDeployment' + params: { + bastionName: mgmtArtifactsSourceDeployment.outputs.bastionName + bastionSku: bastionSku + location: location + tags: resourceTags + virtualNetworkId: mgmtArtifactsSourceDeployment.outputs.vnetId + bastionSubnetId: mgmtArtifactsSourceDeployment.outputs.bastionSubnetId + publicIpAddressId: mgmtArtifactsSourceDeployment.outputs.bastionPublicIpId + } +} + +module destinationBastionDeployment 'mgmt/bastion.bicep' = if (deployBastion) { + name: '${namingPrefix}-destinationBastionDeployment' + params: { + bastionName: mgmtArtifactsDestinationDeployment.outputs.bastionName + bastionSku: bastionSku + location: location + tags: resourceTags + virtualNetworkId: mgmtArtifactsDestinationDeployment.outputs.vnetId + bastionSubnetId: mgmtArtifactsDestinationDeployment.outputs.bastionSubnetId + publicIpAddressId: mgmtArtifactsDestinationDeployment.outputs.bastionPublicIpId + } +} + output clientVmLogonUserName string = windowsAdminUsername output hostVmName string = clientVmDeployment.outputs.vmName output keyVaultName string = mgmtArtifactsSourceDeployment.outputs.vaultName diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/bastion.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/bastion.bicep new file mode 100644 index 000000000..0b36953c9 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/bastion.bicep @@ -0,0 +1,51 @@ +targetScope = 'resourceGroup' + +param bastionName string +param location string = resourceGroup().location +param tags object = {} + +@allowed([ + 'Basic' + 'Standard' + 'Developer' +]) +param bastionSku string = 'Basic' + +param virtualNetworkId string + +@description('Dedicated Bastion subnet ID; unused for Developer SKU.') +param bastionSubnetId string + +@description('Dedicated Bastion public IP ID; unused for Developer SKU.') +param publicIpAddressId string + +resource bastionHost 'Microsoft.Network/bastionHosts@2024-05-01' = { + name: bastionName + location: location + tags: tags + sku: { + name: bastionSku + } + properties: { + virtualNetwork: bastionSku == 'Developer' + ? { + id: virtualNetworkId + } + : null + ipConfigurations: bastionSku != 'Developer' + ? [ + { + name: 'IpConf' + properties: { + publicIPAddress: { + id: publicIpAddressId + } + subnet: { + id: bastionSubnetId + } + } + } + ] + : null + } +} diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep index c46dd43f3..882b3724f 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep @@ -8,7 +8,7 @@ param flavor string = 'ITPro' @description('Azure Region to deploy the Log Analytics Workspace') param location string = resourceGroup().location -@description('Choice to deploy Bastion to connect to the client VM') +@description('Deploy network prerequisites for Bastion access to the client VM') param deployBastion bool = false @description('Bastion host Sku name') @@ -326,42 +326,9 @@ resource publicIpAddressDST 'Microsoft.Network/publicIPAddresses@2024-05-01' = i } } -resource bastionHostDST 'Microsoft.Network/bastionHosts@2024-05-01' = if (deployBastion == true) { - name: bastionDSTName - location: location - tags: tags - dependsOn: [ - - ] - sku: { - name: bastionSku - } - properties: { - virtualNetwork: bastionSku == 'Developer' - ? { - id: destinationVirtualNetwork.id - } - : null - ipConfigurations: bastionSku != 'Developer' - ? [ - { - name: 'IpConf' - properties: { - publicIPAddress: { - id: publicIpAddressDST.id - } - subnet: { - id: bastionDSTSubnetRef - } - } - } - ] - : null - } -} - - - output vnetId string = destinationVirtualNetwork.id output subnetId string = destinationVirtualNetwork.properties.subnets[0].id output vnetName string = destinationVirtualNetwork.name +output bastionName string = bastionDSTName +output bastionSubnetId string = (deployBastion && bastionSku != 'Developer') ? bastionDSTSubnetRef : '' +output bastionPublicIpId string = (deployBastion && bastionSku != 'Developer') ? publicIpAddressDST!.id : '' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep index 381702235..63a39de03 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep @@ -7,7 +7,7 @@ param flavor string = 'ITPro' @description('Azure Region to deploy the Log Analytics Workspace') param location string = resourceGroup().location -@description('Choice to deploy Bastion to connect to the client VM') +@description('Deploy network prerequisites for Bastion access to the client VM') param deployBastion bool = false @description('Bastion host Sku name') @@ -323,41 +323,6 @@ resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2024-05-01' = if ( } } -resource bastionHost 'Microsoft.Network/bastionHosts@2024-05-01' = if (deployBastion == true) { - name: bastionName - location: location - tags: tags - dependsOn: [ - - ] - sku: { - name: bastionSku - } - properties: { - virtualNetwork: bastionSku == 'Developer' - ? { - id: sourceVirtualNetwork.id - } - : null - ipConfigurations: bastionSku != 'Developer' - ? [ - { - name: 'IpConf' - properties: { - publicIPAddress: { - id: publicIpAddress.id - } - subnet: { - id: bastionSubnetRef - } - } - } - ] - : null - } -} - - module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { name: '${namingPrefix}-kvsrc' dependsOn: [ @@ -405,3 +370,6 @@ output vnetId string = sourceVirtualNetwork.id output subnetId string = sourceVirtualNetwork.properties.subnets[0].id output vnetName string = sourceVirtualNetwork.name output vaultName string = kv.name +output bastionName string = bastionName +output bastionSubnetId string = (deployBastion && bastionSku != 'Developer') ? bastionSubnetRef : '' +output bastionPublicIpId string = (deployBastion && bastionSku != 'Developer') ? publicIpAddress!.id : '' diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index b445d81d2..1b0e5998c 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -17,6 +17,8 @@ Instructors prepare Challenge 1 before the session. Both deployment options use New deployments use **two paid Basic SKU Bastion hosts**, one per VNet, each with a Standard static public IP and a dedicated `/26` `AzureBastionSubnet`. Basic supports the lab's portal-based RDP/SSH connections and concurrent sessions; native-client tunneling and IP-based connections require Standard or higher. Review [Bastion pricing](https://azure.microsoft.com/pricing/details/azure-bastion/), since Basic is billed hourly even when not connected. +Once source networking and Key Vault are ready, the Hyper-V host starts deploying without waiting for Bastion. Each Bastion host provisions independently after its network prerequisites complete, overlapping host and guest preparation. This does not speed up Bastion itself: wait for the entire ARM deployment and host/guest readiness checks before handing over the lab. + Updating the package does not change Bastion in an already verified Console lab: completed reruns reuse the existing baseline. Upgrade existing Developer hosts separately using the [supported SKU upgrade procedure](https://learn.microsoft.com/azure/bastion/upgrade-sku); do not clear readiness records or redeploy the whole lab just to change Bastion. > [!WARNING] From 6c8caf1d2e1e8deaf4b6f1d83337f931bfad7bae Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 15:48:38 +0100 Subject: [PATCH 18/20] Retry transient WinGet repair download failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../resources/artifacts/WinGet.ps1 | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 index e84eae4d7..a8dc59643 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/artifacts/WinGet.ps1 @@ -57,8 +57,31 @@ Install-PSResource -Name NetworkingDsc -Scope AllUsers -Quiet -AcceptLicense -Tr # Repeat repair for WinAppSDK registration; fresh installs require the v-prefixed GitHub release tag. $winGetReleaseTag = "v$winGetVersion" -Repair-WinGetPackageManager -AllUsers -Force -Version $winGetReleaseTag -Verbose -Repair-WinGetPackageManager -AllUsers -Force -Version $winGetReleaseTag -Verbose +foreach ($repairPass in 1..2) { + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Repair-WinGetPackageManager -AllUsers -Force -Version $winGetReleaseTag -Verbose -ErrorAction Stop + break + } + catch { + $statusCode = '' + for ($exception = $_.Exception; $null -ne $exception; $exception = $exception.InnerException) { + $statusCode = [string]$exception.StatusCode + if (-not $statusCode) { $statusCode = [string]$exception.Response.StatusCode } + if ($statusCode) { break } + } + # Some module errors preserve only HttpClient's status message. + if (-not $statusCode -and $_.Exception.Message -match 'Response status code does not indicate success:\s*(\d{3})\b') { + $statusCode = $Matches[1] + } + $transient = $statusCode -match '^(408|429|500|502|503|504|RequestTimeout|TooManyRequests|InternalServerError|BadGateway|ServiceUnavailable|GatewayTimeout)$' + if (-not $transient -or $attempt -eq 3) { throw } + $retryDelay = 30 * $attempt + Write-Warning "WinGet repair pass $repairPass, attempt $attempt/3 failed with HTTP $statusCode. Retrying in $retryDelay seconds." + Start-Sleep -Seconds $retryDelay + } + } +} $actualWinGetVersion = (winget --version | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or $actualWinGetVersion -ne $winGetReleaseTag) { throw "Expected WinGet $winGetReleaseTag; found '$actualWinGetVersion' (exit code $LASTEXITCODE)." From a720bc24ac18a70d7a6083b3597ffeabd142c0a0 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 17:31:37 +0100 Subject: [PATCH 19/20] Restore official upstream deployment sources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../labautomation/deployment-settings.json | 4 ++-- .../06_Migration_Secure_AI_Ready/resources/README.md | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json index a4d90c49d..1ee0de795 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json @@ -1,4 +1,4 @@ { - "githubAccount": "ASchwarz1984", - "githubBranch": "aschwarz1984-migration-lab" + "githubAccount": "microsoft", + "githubBranch": "main" } diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md index 1b0e5998c..d2127bbdc 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/resources/README.md @@ -76,7 +76,7 @@ By default, the signed-in user receives Key Vault secret access. To deploy for o ### Create a fresh lab and deploy -Choose a new RG, not an existing or Console-managed lab. Keep the number as a quoted string to preserve leading zeros. The example reads the same source JSON as Console deployment; there is no hardcoded test account or branch. +Choose a new RG, not an existing or Console-managed lab. Keep the number as a quoted string to preserve leading zeros. The example reads the same deployment-source settings as Console deployment. Azure CLI will prompt securely for the omitted `windowsAdminPassword`. Use a unique 12-123 character password meeting Windows complexity requirements and retain it securely for recovery. It is not placed in the temporary parameters file or command history. @@ -136,8 +136,6 @@ For manual labs, securely provide the subscription, RG, region, host, Key Vault, Keep the RG through Challenges 2-8: it contains both source and migrated resources. Azure Monitor may create an Azure-managed `MA_*_managed` group for workspace internals; do not delete it separately. At the end, obtain approval, complete supported migration/replication and backup cleanup, then remove the lab RG. Deletion removes the entire lab, not just the source environment. -## Testing changes +## Deployment source -Use a fresh test RG and a real learner account with **subscription Owner** to exercise deployment, both dashboard variants, provider registration during Challenges 2-8, and preservation of learner progress on a completed Console rerun. The [local harness](../../../../99-MicroHack-Template/labautomation/README.md#local-testing) is also available for testing Console automation; its helpers are local-development stand-ins, not the manual deployment requirement. - -To test unpublished changes, set `githubAccount` and `githubBranch` in [deployment-settings.json](../labautomation/deployment-settings.json) to a repository account and branch or commit containing the matching artifacts. The source must be publicly downloadable by the VMs. Re-import the Console package after edits; the manual example reads the JSON directly. Keep these fields out of `lab-defaults.json`. For official upstream deployment, use `microsoft` and `main` once the matching version is published there. Complete the Azure deployment and learner-flow tests before upstream integration; syntax checks alone are not sufficient. +[deployment-settings.json](../labautomation/deployment-settings.json) selects the official `microsoft/MicroHack` repository and `main` branch for both deployment options. Import the complete `labautomation` folder from upstream `main` so the Bicep package and downloaded artifacts match. Re-import the Console package after upstream updates; already verified labs continue to reuse their existing baseline. From efc9a253338787588a881a719d1d0aaed7a44742 Mon Sep 17 00:00:00 2001 From: Andreas Schwarz Date: Thu, 5 Nov 2026 17:40:25 +0100 Subject: [PATCH 20/20] Avoid false Markdown link in runtime regex Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a830ff91-1d60-4a2d-8630-4ec834c57984 --- .../walkthrough/challenge-08/solution-08.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md index ab74f4ba9..81a591244 100644 --- a/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/walkthrough/challenge-08/solution-08.md @@ -453,7 +453,7 @@ import re raw = os.environ["MH_RUNTIME_OUTPUT"] pattern = re.compile( - r"(?i)(?