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..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 @@ -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. 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. + +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. 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: @@ -76,8 +81,12 @@ 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 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. 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..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 @@ -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 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. ## 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..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 @@ -6,11 +6,19 @@ 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 + +* 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 -* Enable Defender for Cloud. +* 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/challenges/challenge-07.md b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/challenges/challenge-07.md index 1910ae2a5..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 @@ -2,12 +2,44 @@ [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 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 | @@ -17,21 +49,24 @@ 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 `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. +* 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] -> 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. +> 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. ## Monitoring models @@ -44,37 +79,45 @@ 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. -* Let Infrastructure monitoring select or create the regional default Azure Monitor workspace and generated OpenTelemetry DCR; do not pre-create a custom workspace. -* Enable OpenTelemetry metrics, per-process metrics, built-in Grafana dashboards, and recommended infrastructure alerts. +* 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 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. * 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. +* 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 -* 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) +* [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) @@ -84,4 +127,7 @@ Enable metrics-based OpenTelemetry monitoring for both VMs. Use classic log coll * [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) 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..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,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 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. * 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 66% 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..55cccd019 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,13 @@ param location string = resourceGroup().location @description('Resource Id of the subnet in the virtual network') param subnetId string +@minLength(1) +@maxLength(9) +param resourceNameSuffix string +param keyVaultName string +param participantObjectIds array +param tags object = {} + param spnAuthority string = environment().authentication.loginEndpoint @description('Your Microsoft Entra tenant Id') @@ -82,16 +89,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-${resourceNameSuffix}' @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 +104,7 @@ var PublicIPNoBastion = { resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = { name: networkInterfaceName location: location + tags: tags properties: { ipConfigurations: [ { @@ -118,6 +124,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 +138,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 +156,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 +169,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { } storageProfile: { osDisk: { - name: '${vmName}-OSDisk' + name: '${hostName}-OSDisk' caching: 'ReadWrite' createOption: 'FromImage' managedDisk: { @@ -192,7 +201,7 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { ] } osProfile: { - computerName: vmName + computerName: hostName adminUsername: windowsAdminUsername adminPassword: windowsAdminPassword windowsConfiguration: { @@ -216,69 +225,108 @@ resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { tags: { displayName: 'config-bootstrap' } + dependsOn: [ + vmVaultReader + vmResourceReader + 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-deferred-restart-v2' protectedSettings: { 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: 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 - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '00482a5a-887f-4fb3-b363-3b7fe8e74483') principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7') + } +} +resource vmTagContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, vm.id, 'tag-contributor') + properties: { + principalId: vm.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4a9ae827-6dc8-4573-8ac7-8239d42aa03f') } } -// 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 vmDiskContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vmDisk.id, vm.id, 'contributor') + scope: vmDisk 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', 'b24988ac-6180-42a0-ab88-20f7382dd24c') } } -// 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 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', '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() +// 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 - roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') 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 + tags: tags properties: { status: 'Enabled' taskType: 'ComputeVmShutdownTask' @@ -299,3 +347,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 60% 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..1e0c1d6e1 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,18 @@ -targetScope = 'subscription' +targetScope = 'resourceGroup' + +@minLength(8) +@maxLength(8) +@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 @description('Your Microsoft Entra tenant Id') param tenantId string = tenant().tenantId @@ -10,7 +24,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 @@ -41,26 +55,26 @@ 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' @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 @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' @@ -82,34 +96,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-${resourceNameSuffix}' @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: { + resourceNameSuffix: resourceNameSuffix + participantObjectIds: participantObjectIds + keyVaultName: mgmtArtifactsSourceDeployment.outputs.vaultName + tags: resourceTags + Prefix: Prefix windowsAdminUsername: windowsAdminUsername windowsAdminPassword: windowsAdminPassword tenantId: tenantId @@ -137,8 +139,9 @@ module clientVmDeployment 'clientVm/clientVm.bicep' = { module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { name: '${namingPrefix}-mgmtArtifactsSourceDeployment' - scope: sourceRg params: { + resourceNameSuffix: resourceNameSuffix + tags: resourceTags flavor: flavor deployBastion: deployBastion bastionSku: bastionSku @@ -150,8 +153,9 @@ module mgmtArtifactsSourceDeployment 'mgmt/mgmtArtifactsSource.bicep' = { module mgmtArtifactsDestinationDeployment 'mgmt/mgmtArtifactsDestination.bicep' = { name: '${namingPrefix}-mgmtArtifactsDestinationDeployment' - scope: destinationRg params: { + resourceNameSuffix: resourceNameSuffix + tags: resourceTags flavor: flavor deployBastion: deployBastion bastionSku: bastionSku @@ -160,4 +164,37 @@ 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 +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/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/resources/bicep/mgmt/mgmtArtifactsDestination.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsDestination.bicep similarity index 87% 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..882b3724f 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 @@ -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') @@ -22,13 +22,12 @@ 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' +@minLength(1) +@maxLength(9) +param resourceNameSuffix 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-${resourceNameSuffix}' @description('Name of the VNet') var virtualNetworkNameDST string = '${namingPrefix}-destination-VNet' @@ -86,6 +85,7 @@ var bastionDSTSubnet = bastionSku != 'Developer' resource destinationVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01' = { name: virtualNetworkNameDST location: location + tags: tags dependsOn: [ ] @@ -112,6 +112,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 +126,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 +144,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 +180,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 +309,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: [ ] @@ -321,40 +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 - 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/resources/bicep/mgmt/mgmtArtifactsSource.bicep b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/bicep/mgmt/mgmtArtifactsSource.bicep similarity index 87% 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..63a39de03 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 @@ -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') @@ -21,9 +21,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' +@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') @minLength(12) @@ -33,9 +34,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-${resourceNameSuffix}' @description('Name of the VNet') var virtualNetworkName string = '${namingPrefix}-Source-VNet' @@ -53,8 +52,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 +96,7 @@ var bastionSubnet = bastionSku != 'Developer' resource sourceVirtualNetwork 'Microsoft.Network/virtualNetworks@2024-07-01' = { name: virtualNetworkName location: location + tags: tags dependsOn: [ ] @@ -124,6 +123,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 +137,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 +154,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 +177,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 +306,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: [ ] @@ -319,40 +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 - 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: [ @@ -361,8 +331,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 +368,8 @@ 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 +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/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..0b6e328d2 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deploy-lab.ps1 @@ -0,0 +1,395 @@ +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) + +$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 +} +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) +$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 +# 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 +} +$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.' +} +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 +} + +$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 + foreach ($candidate in $candidateLocations) { + Update-MhhToken | Out-Null + $reasons = [Collections.Generic.List[string]]::new() + 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." + } + + 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 @{ + Microhack = $hackName + LabId = $labSuffix + LabNameSuffix = $resourceNameSuffix + 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 + resourceNameSuffix = $resourceNameSuffix + participantObjectIds = $AllowedEntraUserIds + location = $location + # Use a JSON string here; Bicep's @secure() retains ARM secret protection. + windowsAdminPassword = $password + 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 = $_.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 + } + 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')) { + 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." + } + 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) +} + +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 redeploying 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 } } + +# 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/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..1ee0de795 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/06_Migration_Secure_AI_Ready/labautomation/deployment-settings.json @@ -0,0 +1,4 @@ +{ + "githubAccount": "microsoft", + "githubBranch": "main" +} 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..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 @@ -1,29 +1,141 @@ -# Deploy the Landing Zone for the Hack +# Deploy the migration lab -Download the \*.bicep files to your local PC and follow [Solution 1](../walkthrough/challenge-01/solution-01.md). +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. -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. +| Option | Use when | +| --- | --- | +| [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 either deployment -## Quota information if multiple users deploy to the same subscription +- 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 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. -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: +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. -| Quota name | Needed quantity | -| ------------- | ------------- | -| Total Regional vCPUs | 30 | -| Standard DSv5 Family vCPUs | 16 | -| Standard BS Family vCPUs | 10 | -| Public IP Addresses - Standard | 5 | +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. -For five users who need to deploy individually to a region, make sure you have the following quotas in place: +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. -| Quota name | Needed quantity | -| ------------- | ------------- | -| Total Regional vCPUs | 150 | -| Standard DSv5 Family vCPUs | 80 | -| Standard BS Family vCPUs | 50 | -| Public IP Addresses - Standard | 25 | +> [!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. -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). +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-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). + +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 + +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. + +### Sign in and prepare the subscription + +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' +$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 + } +} +``` + +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 + +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. + +```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 +``` + +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. + +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: + +```powershell +Invoke-Az deployment group show --resource-group $resourceGroup --name migration-manual ` + --query properties.provisioningState --output tsv +``` + +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. + +## Verify readiness and hand off + +**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. + +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. + +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. + +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. + +## Deployment source + +[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. 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..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 @@ -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,57 @@ param ( [string]$autoShutdownEnabled ) +$ErrorActionPreference = 'Stop' + +trap { + $failure = $_ + Write-Error -ErrorRecord $failure -ErrorAction Continue + Remove-Item -LiteralPath 'C:\MHBox\lab-ready.json' -Force -ErrorAction SilentlyContinue + $azFailureScriptPath = $null + try { + # 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 + } + } + 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 +89,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 +138,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 +164,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 +184,8 @@ param( [string]$tenantId, [string]$subscriptionId, [string]$resourceGroup, + [string]$hostVmName, + [string]$keyVaultName, [string]$adminUsername, [string]$vmAutologon, [string]$autoShutdownEnabled, @@ -116,24 +200,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 +249,39 @@ 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" - - $Schedule = Invoke-AzRestMethod -Uri $Uri - $ScheduleSettings = $Schedule.Content | ConvertFrom-Json - $ScheduleSettings.properties.status = 'Disabled' - - Invoke-AzRestMethod -Uri $Uri -Method PUT -Payload ($ScheduleSettings | ConvertTo-Json -Depth 10) | Out-Null + $scheduleId = "$groupId/providers/Microsoft.DevTestLab/schedules/shutdown-computevm-$hostVmName" + $Uri = "https://management.azure.com${scheduleId}?api-version=2018-09-15" + + 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)" } + } } 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 +296,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 +319,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 +403,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 +453,7 @@ param( [string]$tenantId, [string]$subscriptionId, [string]$resourceGroup, + [string]$hostVmName, [string]$deploymentProgressString, [string]$logPath ) @@ -342,21 +468,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 +506,7 @@ $azProgressScript | Set-Content -Path $azProgressScriptPath -Encoding UTF8 -tenantId $tenantId ` -subscriptionId $subscriptionId ` -resourceGroup $resourceGroup ` + -hostVmName $hostVmName ` -deploymentProgressString $DeploymentProgressString ` -logPath $azProgressLogPath @@ -381,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 @@ -395,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 @@ -408,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/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..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 @@ -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,42 +27,75 @@ $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 +# 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; fresh installs require the v-prefixed GitHub release tag. +$winGetReleaseTag = "v$winGetVersion" +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)." +} +Get-WinGetPackage | Out-Null # 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/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 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..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 @@ -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 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 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.** -### **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. 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 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] -> 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. 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. -![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 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** -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 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:** @@ -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 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 | +| --- | --- | +| 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..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,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 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. 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 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 *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 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..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 @@ -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 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. @@ -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..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,22 @@ Duration: 40 minutes -## **Task 1: Enable Defender for Cloud for Servers** +## **Task 1: Verify existing 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] +> 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. -![image](./img/Def-environment-settings.jpg) +> [!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. -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*. +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) +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. ![image](./img/Def-CSPM-monitoring.png) @@ -68,7 +72,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 +102,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/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 782a48316..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,12 +1,12 @@ # 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. 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 @@ -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,19 +46,21 @@ 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 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 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 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. 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,34 +75,151 @@ 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. +### 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. +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. + ### 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. +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. 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 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. -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. +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 **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. -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 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. -![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 **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. @@ -106,16 +230,16 @@ 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 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 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. @@ -125,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: @@ -144,16 +268,18 @@ 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) -Reuse the action group created by recommended monitoring alerts, or create `ag-mh-operators` in `destination-rg` with an email receiver you can check. +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. + +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 `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 +288,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 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: @@ -176,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: @@ -199,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: @@ -220,20 +349,26 @@ 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 | | 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. +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: @@ -306,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 @@ -332,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 @@ -419,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: @@ -431,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, @@ -450,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. 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..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 @@ -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 )" @@ -453,7 +453,7 @@ import re raw = os.environ["MH_RUNTIME_OUTPUT"] pattern = re.compile( - r"(?i)(?' ` --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