From c5105ea689ab2b24bb35931fe63ea00535753ad7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 19:46:07 -0400 Subject: [PATCH 1/8] citrix lab: script the domain build and ISO staging; record the licence limits --- .../flow/scripts/citrix_lab/05_fetch_iso.ps1 | 18 +++++++++++ .../flow/scripts/citrix_lab/10_promote_dc.ps1 | 19 ++++++++++++ .../flow/scripts/citrix_lab/12_lab_users.ps1 | 23 ++++++++++++++ .../scripts/citrix_lab/15_join_domain.ps1 | 16 ++++++++++ packages/flow/scripts/citrix_lab/README.md | 31 ++++++++++++++----- packages/flow/scripts/citrix_lab/run_ps.sh | 25 +++++++++++++++ packages/flow/scripts/citrix_lab/stage_iso.sh | 24 ++++++++++++++ 7 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 packages/flow/scripts/citrix_lab/05_fetch_iso.ps1 create mode 100644 packages/flow/scripts/citrix_lab/10_promote_dc.ps1 create mode 100644 packages/flow/scripts/citrix_lab/12_lab_users.ps1 create mode 100644 packages/flow/scripts/citrix_lab/15_join_domain.ps1 create mode 100755 packages/flow/scripts/citrix_lab/run_ps.sh create mode 100755 packages/flow/scripts/citrix_lab/stage_iso.sh diff --git a/packages/flow/scripts/citrix_lab/05_fetch_iso.ps1 b/packages/flow/scripts/citrix_lab/05_fetch_iso.ps1 new file mode 100644 index 0000000000..faa1a4a983 --- /dev/null +++ b/packages/flow/scripts/citrix_lab/05_fetch_iso.ps1 @@ -0,0 +1,18 @@ +# Run ON vm-ddc and vm-vda via run_ps.sh with PROTECTED="SasUrl=". +# Downloads the CVAD ISO that stage_iso.sh put in the lab's private blob +# container and checks its SHA-256 against the local copy's. +param( + [Parameter(Mandatory = $true)][string]$Password, # unused; run_ps.sh always sends it + [Parameter(Mandatory = $true)][string]$SasUrl, + [Parameter(Mandatory = $true)][string]$Sha256, + [string]$IsoPath = "C:\cvad.iso" +) +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +if (-not ((Test-Path $IsoPath) -and ((Get-FileHash $IsoPath -Algorithm SHA256).Hash -eq $Sha256.ToUpper()))) { + Invoke-WebRequest -Uri $SasUrl -OutFile $IsoPath -UseBasicParsing +} +$got = (Get-FileHash $IsoPath -Algorithm SHA256).Hash +if ($got -ne $Sha256.ToUpper()) { throw "ISO hash mismatch: $got" } +Write-Output "ISO at $IsoPath, SHA-256 $got" diff --git a/packages/flow/scripts/citrix_lab/10_promote_dc.ps1 b/packages/flow/scripts/citrix_lab/10_promote_dc.ps1 new file mode 100644 index 0000000000..a56017cb91 --- /dev/null +++ b/packages/flow/scripts/citrix_lab/10_promote_dc.ps1 @@ -0,0 +1,19 @@ +# Run ON vm-ddc via `az vm run-command create --protected-parameters`. +# Promotes the box to the single domain controller for citrixlab.local +# (NetBIOS CITRIXLAB). The VM reboots on its own when promotion finishes. +# Run 12_lab_users.ps1 after the reboot to create the ICA logon user. +param( + [Parameter(Mandatory = $true)][string]$Password, + [string]$Domain = "citrixlab.local", + [string]$NetBios = "CITRIXLAB" +) +$ErrorActionPreference = "Stop" + +if ((Get-CimInstance Win32_ComputerSystem).DomainRole -ge 4) { + Write-Output "Already a domain controller." + exit 0 +} +Install-WindowsFeature AD-Domain-Services, DNS -IncludeManagementTools | Out-Null +$safe = ConvertTo-SecureString $Password -AsPlainText -Force +Install-ADDSForest -DomainName $Domain -DomainNetbiosName $NetBios ` + -SafeModeAdministratorPassword $safe -InstallDns -Force -NoRebootOnCompletion:$false diff --git a/packages/flow/scripts/citrix_lab/12_lab_users.ps1 b/packages/flow/scripts/citrix_lab/12_lab_users.ps1 new file mode 100644 index 0000000000..903ab57868 --- /dev/null +++ b/packages/flow/scripts/citrix_lab/12_lab_users.ps1 @@ -0,0 +1,23 @@ +# Run ON vm-ddc after promotion. Creates the ICA logon user and the group +# the Delivery Group publishes to. Also forwards DNS to Azure so the VNet +# keeps resolving public names (SQL Express download, Windows Update). +param( + [Parameter(Mandatory = $true)][string]$Password, + [string]$User = "clinician" +) +$ErrorActionPreference = "Stop" +Import-Module ActiveDirectory + +if (-not (Get-DnsServerForwarder).IPAddress) { + Add-DnsServerForwarder -IPAddress 168.63.129.16 +} +if (-not (Get-ADGroup -Filter "Name -eq 'CitrixUsers'")) { + New-ADGroup -Name CitrixUsers -GroupScope Global +} +if (-not (Get-ADUser -Filter "SamAccountName -eq '$User'")) { + New-ADUser -Name $User -SamAccountName $User -Enabled $true ` + -AccountPassword (ConvertTo-SecureString $Password -AsPlainText -Force) ` + -PasswordNeverExpires $true +} +Add-ADGroupMember CitrixUsers -Members $User +Get-ADDomain | Select-Object DNSRoot, NetBIOSName | Format-List diff --git a/packages/flow/scripts/citrix_lab/15_join_domain.ps1 b/packages/flow/scripts/citrix_lab/15_join_domain.ps1 new file mode 100644 index 0000000000..119ce6b288 --- /dev/null +++ b/packages/flow/scripts/citrix_lab/15_join_domain.ps1 @@ -0,0 +1,16 @@ +# Run ON vm-vda via `az vm run-command create --protected-parameters`. +# Joins the session host to citrixlab.local and reboots. +param( + [Parameter(Mandatory = $true)][string]$Password, + [string]$Domain = "citrixlab.local", + [string]$AdminUser = "CITRIXLAB\azureadmin" +) +$ErrorActionPreference = "Stop" + +if ((Get-CimInstance Win32_ComputerSystem).PartOfDomain) { + Write-Output "Already joined to $((Get-CimInstance Win32_ComputerSystem).Domain)." + exit 0 +} +$cred = New-Object System.Management.Automation.PSCredential( + $AdminUser, (ConvertTo-SecureString $Password -AsPlainText -Force)) +Add-Computer -DomainName $Domain -Credential $cred -Restart -Force diff --git a/packages/flow/scripts/citrix_lab/README.md b/packages/flow/scripts/citrix_lab/README.md index b9fe62324e..b11e1ed0e5 100644 --- a/packages/flow/scripts/citrix_lab/README.md +++ b/packages/flow/scripts/citrix_lab/README.md @@ -20,27 +20,44 @@ validating OpenAdapt's vision-only pixel ladder against genuine Citrix. |---|---|---| | `provision_citrix_lab.sh` | your shell (az) | create RG + IP-locked network + 2 Windows VMs; set VNet DNS to the DC | | `lifecycle.sh` | your shell (az) | `start` / `stop` (deallocate) / `status` / `teardown` | +| `run_ps.sh` | your shell (az) | run one lab `.ps1` on a VM as a managed run command; the password goes in as a protected parameter | +| `10_promote_dc.ps1` | vm-ddc | promote to the domain controller for `citrixlab.local` (reboots) | +| `12_lab_users.ps1` | vm-ddc | DNS forwarder, `CitrixUsers` group, `clinician` ICA logon user | +| `15_join_domain.ps1` | vm-vda | join `citrixlab.local` (reboots) | +| `stage_iso.sh` + `05_fetch_iso.ps1` | your shell → both VMs | copy the downloaded ISO to `C:\cvad.iso` through the lab's private blob container, hash-checked | | `20_install_ddc.ps1` | vm-ddc | SQL Express + CVAD Delivery Controller/StoreFront/Studio (trial mode) | | `30_install_vda.ps1` | vm-vda | multi-session (Server OS) VDA, registers with the DDC | -AD DS promotion + domain-join are done via `az vm run-command` during -provisioning (see the design-doc build log for the exact PowerShell). +The admin password lives in the macOS keychain item `openadapt-citrix-lab` +(account `azureadmin`); `run_ps.sh` reads it from there when `ADMIN_PW` is unset. ## The one gated dependency The **CVAD ISO is an authenticated Citrix-account download** (no anonymous URL). Download CVAD 7 2507 LTSR (or 7 2603) from with a free -Citrix account and place it at `C:\cvad.iso` on both VMs before running the -`*_install_*.ps1` scripts. Everything else is automated. +Citrix account, save it on the operator machine, and run +`bash stage_iso.sh ` with the VMs started. Everything else is automated. + +## Licence: results are internal + +The Cloud Software Group End User Agreement (v2.5) treats "related performance +test results derived by Customer" as Confidential Information (section 1.8), +bars using the products "for competitive benchmarking or analysis" (2.7(x)), +and limits trials to internal demonstration, test or evaluation (14.1). Keep +lab measurements out of public copy unless counsel or Citrix clears it. ## Quickstart ```bash -export ADMIN_PW='' +export ADMIN_PW="$(security find-generic-password -s openadapt-citrix-lab -a azureadmin -w)" bash provision_citrix_lab.sh # ~10 min; prints public IPs -# ... promote DC + domain-join (run-command, see design-doc build log) ... -# [HUMAN] place C:\cvad.iso on vm-ddc and vm-vda +bash run_ps.sh vm-ddc 10_promote_dc.ps1 # reboots +bash run_ps.sh vm-ddc 12_lab_users.ps1 +az vm restart -g openadapt-citrix-lab -n vm-vda # pick up the DC as DNS +bash run_ps.sh vm-vda 15_join_domain.ps1 # reboots +# [HUMAN] download the CVAD ISO, then: +bash stage_iso.sh ~/path/to/CVAD.iso # on vm-ddc (elevated PS): .\20_install_ddc.ps1 ; then create Site in Web Studio (trial mode) # on vm-vda (elevated PS): .\30_install_vda.ps1 ; then Machine Catalog + Delivery Group + publish bash lifecycle.sh stop # deallocate when idle diff --git a/packages/flow/scripts/citrix_lab/run_ps.sh b/packages/flow/scripts/citrix_lab/run_ps.sh new file mode 100755 index 0000000000..6d7e7e8b9f --- /dev/null +++ b/packages/flow/scripts/citrix_lab/run_ps.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Run one of the lab PowerShell scripts on a VM through a managed run command. +# The lab password goes in as a protected parameter, so Azure does not echo it +# in the run command's instance view. Reads it from $ADMIN_PW, or from the +# macOS keychain item `openadapt-citrix-lab` when unset. Set PROTECTED to one +# more `Name=value` secret (for example a SAS URL) to pass it the same way. +# +# bash run_ps.sh vm-ddc 10_promote_dc.ps1 [Name=Value ...] +set -euo pipefail +RG="${RG:-openadapt-citrix-lab}" +vm="${1:?vm name}"; script="${2:?script path}"; shift 2 +here="$(cd "$(dirname "$0")" && pwd)" +[ -f "$script" ] || script="$here/$script" +pw="${ADMIN_PW:-$(security find-generic-password -s openadapt-citrix-lab -a azureadmin -w)}" +name="oa-$(basename "$script" .ps1 | tr '_' '-')-$(date +%s)" + +args=(--resource-group "$RG" --vm-name "$vm" --name "$name" + --script "@$script" --timeout-in-seconds 5400 --async-execution false + --protected-parameters "Password=$pw" ${PROTECTED:+"$PROTECTED"} --output none) +[ $# -gt 0 ] && args+=(--parameters "$@") + +az vm run-command create "${args[@]}" || true +az vm run-command show --resource-group "$RG" --vm-name "$vm" --name "$name" \ + --instance-view --query "instanceView.{state:executionState,exit:exitCode,out:output,err:error}" -o json +az vm run-command delete --resource-group "$RG" --vm-name "$vm" --name "$name" --yes --no-wait diff --git a/packages/flow/scripts/citrix_lab/stage_iso.sh b/packages/flow/scripts/citrix_lab/stage_iso.sh new file mode 100755 index 0000000000..0d2219b943 --- /dev/null +++ b/packages/flow/scripts/citrix_lab/stage_iso.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copy the founder-downloaded CVAD ISO from this machine to C:\cvad.iso on +# vm-ddc and vm-vda, through the lab's private, IP-locked blob container. +# The SAS URL is short-lived and goes to the VMs as a protected parameter; +# it is never printed. +# +# bash stage_iso.sh /path/to/CVAD.iso +set -euo pipefail +RG="${RG:-openadapt-citrix-lab}" +SA="${SA:-oacitrixlab152a16}" +iso="${1:?path to the CVAD .iso}" +here="$(cd "$(dirname "$0")" && pwd)" + +sha=$(shasum -a 256 "$iso" | cut -d' ' -f1) +echo "ISO $(basename "$iso") sha256=$sha" +key=$(az storage account keys list -g "$RG" -n "$SA" --query "[0].value" -o tsv) +az storage blob upload --account-name "$SA" --account-key "$key" -c media -n cvad.iso \ + -f "$iso" --overwrite --only-show-errors -o none +expiry=$(date -u -v+3H '+%Y-%m-%dT%H:%MZ' 2>/dev/null || date -u -d '+3 hours' '+%Y-%m-%dT%H:%MZ') +sas=$(az storage blob generate-sas --account-name "$SA" --account-key "$key" -c media -n cvad.iso \ + --permissions r --expiry "$expiry" --https-only --full-uri -o tsv) +for vm in vm-ddc vm-vda; do + PROTECTED="SasUrl=$sas" bash "$here/run_ps.sh" "$vm" 05_fetch_iso.ps1 "Sha256=$sha" +done From e25c63372a1c77b52837c6b4b41f8fa10a97025e Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 28 Oct 2026 20:01:15 -0400 Subject: [PATCH 2/8] avd lab: provision a one-host pooled AVD lab with OpenEMR and Patient Notes --- .../flow/scripts/avd_lab/provision_avd_lab.sh | 90 +++++++++++++++++++ .../scripts/avd_lab/register_session_host.sh | 39 ++++++++ packages/flow/scripts/avd_lab/setup_apps.sh | 49 ++++++++++ packages/flow/scripts/avd_lab/setup_client.sh | 46 ++++++++++ 4 files changed, 224 insertions(+) create mode 100755 packages/flow/scripts/avd_lab/provision_avd_lab.sh create mode 100755 packages/flow/scripts/avd_lab/register_session_host.sh create mode 100755 packages/flow/scripts/avd_lab/setup_apps.sh create mode 100755 packages/flow/scripts/avd_lab/setup_client.sh diff --git a/packages/flow/scripts/avd_lab/provision_avd_lab.sh b/packages/flow/scripts/avd_lab/provision_avd_lab.sh new file mode 100755 index 0000000000..75f9e5fb6b --- /dev/null +++ b/packages/flow/scripts/avd_lab/provision_avd_lab.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Provision a one-host Azure Virtual Desktop lab: a pooled host pool with one +# Entra-joined Windows 11 multi-session host, a Windows Server client VM that +# runs Windows App (the harness drives that window), and a Linux VM serving +# OpenEMR 7.0.3 on the private network. +# +# COST running: session host D4s_v4 ($0.376/hr) + client D2s_v4 ($0.188/hr) +# + OpenEMR D2s_v4 Linux ($0.096/hr) = ~$0.66/hr. DEALLOCATE WHEN IDLE +# (bash lifecycle.sh stop). One resource group, so teardown is one command. +# +# Prereqs: az logged in; `az extension add -n desktopvirtualization`; the +# admin password in the macOS keychain item `openadapt-avd-lab` (account +# azureadmin) or in $ADMIN_PW. Session-host registration runs afterwards with +# register_session_host.sh. +set -euo pipefail + +RG="${RG:-openadapt-avd-lab}" +LOC="${LOC:-eastus}" +MYIP="${MYIP:-$(curl -s https://api.ipify.org)}" +ADMIN_USER="${ADMIN_USER:-azureadmin}" +ADMIN_PW="${ADMIN_PW:-$(security find-generic-password -s openadapt-avd-lab -a azureadmin -w)}" +HOST_IMG="MicrosoftWindowsDesktop:windows-11:win11-25h2-avd:latest" +CLIENT_IMG="MicrosoftWindowsServer:WindowsServer:2022-datacenter-azure-edition:latest" +here="$(cd "$(dirname "$0")" && pwd)" +echo "Resource group: $RG Region: $LOC Allowed IP: $MYIP" + +az group create -n "$RG" -l "$LOC" -o none \ + --tags project=openadapt purpose=avd-lab autostop=deallocate-when-idle +az network vnet create -g "$RG" -n avdlab-vnet -o none \ + --address-prefix 10.30.0.0/16 --subnet-name lab --subnet-prefix 10.30.1.0/24 +az network nsg create -g "$RG" -n avdlab-nsg -o none +# RDP to the client VM only, and only from the operator. The session host +# needs no inbound rule: AVD connections are reverse-connect through the +# AVD gateway. +az network nsg rule create -g "$RG" --nsg-name avdlab-nsg -n allow-rdp-from-me -o none \ + --priority 100 --source-address-prefixes "${MYIP}/32" --destination-port-ranges 3389 \ + --access Allow --protocol Tcp --direction Inbound + +common=(--vnet-name avdlab-vnet --subnet lab --nsg avdlab-nsg --public-ip-sku Standard + --storage-sku StandardSSD_LRS --nic-delete-option Delete --os-disk-delete-option Delete -o none) + +# Session host: Entra-joined, so it needs a system identity and the +# AADLoginForWindows extension. +az vm create -g "$RG" -n vm-avdhost --image "$HOST_IMG" --size Standard_D4s_v4 \ + --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PW" --license-type Windows_Client \ + --private-ip-address 10.30.1.4 --assign-identity --os-disk-size-gb 128 "${common[@]}" +az vm extension set -g "$RG" --vm-name vm-avdhost -n AADLoginForWindows \ + --publisher Microsoft.Azure.ActiveDirectory -o none + +az vm create -g "$RG" -n vm-client --image "$CLIENT_IMG" --size Standard_D2s_v4 \ + --admin-username "$ADMIN_USER" --admin-password "$ADMIN_PW" \ + --private-ip-address 10.30.1.5 --os-disk-size-gb 128 "${common[@]}" + +# OpenEMR: the same compose file as benchmark/openemr_live, run by cloud-init. +# OpenEMR is GPL and runs as an external app; nothing from it is vendored. +init="$(mktemp)" +cat > "$init" </dev/null || date -u -d '+4 hours' '+%Y-%m-%dT%H:%M:%SZ') +token=$(az desktopvirtualization hostpool update -g "$RG" -n "$POOL" \ + --registration-info expiration-time="$expiry" registration-token-operation=Update \ + --query registrationInfo.token -o tsv) + +script=$(mktemp) +cat > "$script" <<'PS' +param([Parameter(Mandatory = $true)][string]$Token) +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +if (Get-Service RDAgentBootLoader -ErrorAction SilentlyContinue) { + Write-Output "Agent already installed."; exit 0 +} +$agent = "C:\RDAgent.msi"; $boot = "C:\RDAgentBootLoader.msi" +Invoke-WebRequest "https://go.microsoft.com/fwlink/?linkid=2310011" -OutFile $agent -UseBasicParsing +Invoke-WebRequest "https://go.microsoft.com/fwlink/?linkid=2311028" -OutFile $boot -UseBasicParsing +$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList "/i $agent /quiet /norestart REGISTRATIONTOKEN=$Token" +if ($p.ExitCode -ne 0) { throw "RDAgent install exit $($p.ExitCode)" } +$p = Start-Process msiexec.exe -Wait -PassThru -ArgumentList "/i $boot /quiet /norestart" +if ($p.ExitCode -ne 0) { throw "BootLoader install exit $($p.ExitCode)" } +Write-Output "Agent and boot loader installed." +PS + +name="oa-register-$(date +%s)" +az vm run-command create -g "$RG" --vm-name "$VM" --name "$name" --script "@$script" \ + --protected-parameters "Token=$token" --timeout-in-seconds 1800 --async-execution false -o none || true +az vm run-command show -g "$RG" --vm-name "$VM" --name "$name" --instance-view \ + --query "instanceView.{state:executionState,exit:exitCode,out:output,err:error}" -o json +az vm run-command delete -g "$RG" --vm-name "$VM" --name "$name" --yes --no-wait +rm -f "$script" diff --git a/packages/flow/scripts/avd_lab/setup_apps.sh b/packages/flow/scripts/avd_lab/setup_apps.sh new file mode 100755 index 0000000000..3c674ee138 --- /dev/null +++ b/packages/flow/scripts/avd_lab/setup_apps.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Install the two published apps on vm-avdhost and publish them as RemoteApps: +# - OpenEMR in Edge app mode, served by vm-emr on the private network; +# - Patient Notes, the WinForms app from scripts/desktop (SQLite record). +# Python is installed for pn_db.py, which owns the Patient Notes record. +set -euo pipefail +RG="${RG:-openadapt-avd-lab}" +VM="${VM:-vm-avdhost}" +EMR_URL="${EMR_URL:-http://10.30.1.6:8390/interface/login/login.php?site=default}" +here="$(cd "$(dirname "$0")" && pwd)" +desk="$here/../desktop" +PY="C:\\Program Files\\Python312\\python.exe" + +script=$(mktemp) +cat > "$script" <