diff --git a/.devcontainer/link-playwright-chrome.sh b/.devcontainer/link-playwright-chrome.sh index 50960d97..3d6a858d 100755 --- a/.devcontainer/link-playwright-chrome.sh +++ b/.devcontainer/link-playwright-chrome.sh @@ -2,11 +2,16 @@ set -euo pipefail browser_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}" -chrome_bin="$( - find "$browser_root" -path '*/chrome-linux/chrome' -type f 2>/dev/null | - sort -V | - tail -n 1 -)" +chrome_bin="" +if [[ -d "$browser_root" ]]; then + chrome_bin="$( + find "$browser_root" \ + \( -path '*/chrome-linux/chrome' -o -path '*/chrome-linux64/chrome' \) \ + -type f 2>/dev/null | + sort -V | + tail -n 1 + )" +fi if [[ -z "$chrome_bin" ]]; then for candidate in chromium-browser chromium google-chrome chrome; do diff --git a/.env.azure.development.example b/.env.azure.development.example new file mode 100644 index 00000000..4178d33e --- /dev/null +++ b/.env.azure.development.example @@ -0,0 +1,33 @@ +# Azure VM Remote SSH development scaffold. +# Copy to .env.azure.development, then set the three required Azure values. +# Keep secrets in session variables or .env.azure.development.local. + +AZURE_DEV_VM_ALLOWED_SSH_CIDR=auto +AZURE_DEV_VM_AUTO_STOP_ENABLED=true +AZURE_DEV_VM_AUTO_STOP_TIME=2200 +AZURE_DEV_VM_AUTO_STOP_TIME_ZONE=UTC +AZURE_DEV_VM_CONNECTIVITY_MODE=public-ssh +AZURE_DEV_VM_DATA_DISK_GIB=256 +AZURE_DEV_VM_ENVIRONMENT_ID=personal +AZURE_DEV_VM_FALLBACK_SIZE=Standard_D8as_v5 +AZURE_DEV_VM_LOCATION=eastus2 +AZURE_DEV_VM_NAME=krav-dev-vm +AZURE_DEV_VM_NAME_PREFIX=krav-dev +AZURE_DEV_VM_RESOURCE_GROUP=rg-krav-dev-personal +AZURE_DEV_VM_SIZE=Standard_D8s_v5 +AZURE_DEV_VM_SSH_HOST_ALIAS=kravhantering-azure-dev +AZURE_DEV_VM_SSH_PRIVATE_KEY_PATH=~/.ssh/kravhantering_azure_dev_ed25519 +AZURE_DEV_VM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 + +# Optional Azure CLI service-principal automation. +# Set these in the shell or .env.azure.development.local when needed. +# If all three are set, real Azure commands use the service principal before +# any existing Azure CLI user login. +# AZURE_TENANT_ID= +# AZURE_CLIENT_ID= +# AZURE_CLIENT_SECRET= + +# Optional Tailscale mode. +# AZURE_DEV_VM_CONNECTIVITY_MODE=tailscale +# AZURE_DEV_TAILSCALE_AUTH_KEY= +# AZURE_DEV_TAILSCALE_TAILNET= diff --git a/.gitignore b/.gitignore index 06c9d23b..084b83e9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ certificates .env.sqlserver .env.local .env.*.local +.env.azure.development +.azure/ # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/cspell.jsonc b/cspell.jsonc index 2b238cbc..fc26a43a 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -982,7 +982,14 @@ "kravområdesförvaltningen", "sessionstoken", "spinbutton", - "unvalidated" + "unvalidated", + "keyrings", + "graphroot", + "rebuildable", + "deallocates", + "vnet", + "snet", + "computevm" ], "flagWords": [], "ignorePaths": [ diff --git a/docs/development/azure-vm-remote-ssh-development.md b/docs/development/azure-vm-remote-ssh-development.md new file mode 100644 index 00000000..9da8d96c --- /dev/null +++ b/docs/development/azure-vm-remote-ssh-development.md @@ -0,0 +1,688 @@ +# Azure VM Remote SSH Development + + + + + + + + +This guide describes the optional disposable Azure VM development environment. +The VM is for one developer, runs the app directly in `/workspace`, and runs +only SQL Server, Keycloak, Kong, and HSA support services in rootless Podman. + +Use the devcontainer for normal local work. Use this VM when you need a larger +remote Linux host opened from VS Code Remote SSH. + +## Get the tenant ID and subscription ID + +The simplest lookup path is Azure Portal Cloud Shell: + +1. Open the Azure Portal. +2. Open **Cloud Shell** from the `>_` toolbar icon. +3. Choose **Bash**. +4. List the subscriptions visible to your portal session: + +```sh +az account list \ + --query "[].{subscription:name,subscriptionId:id,tenantId:tenantId}" \ + -o table +``` + +Use the `tenantId` value from the row that contains the development +subscription. For one known subscription, query only that subscription: + +>[!NOTE] +> +> Can also use PowerShell in Cloud Shell to list subscriptions and their tenants: +> +>```powershell +>Connect-AzAccount +>Get-AzSubscription | Select-Object Name, Id, TenantId +>``` + +- From Azure PowerShell, list subscriptions and their tenants: + +```powershell +Connect-AzAccount +Get-AzSubscription | Select-Object Name, Id, TenantId +``` + +## Login to the Tenant + +Log Azure CLI into the tenant. The setup script uses the subscription ID from +`.env.azure.development`, so that subscription must be visible in the active +Azure CLI login. + +Make sure Azure CLI is using the public Azure cloud, then sign in directly to +the tenant: + +```sh +az cloud set --name AzureCloud +az login --tenant "" +``` + +Verify that the development subscription is visible after login: + +```sh +az account list --all \ + --query "[].{name:name,id:id,tenant:tenantId,state:state}" \ + -o table +``` + +## Step 1: Understand Cost + +Review the configured cost drivers after choosing or editing +`.env.azure.development`: + +```sh +pwsh ./scripts/azure-dev.ps1 estimate-cost +``` + +This command reads local configuration only. It does not require Azure login, +subscription visibility, resource-group permissions, or provider registration. +It also does not fetch live Azure prices. + +The environment can bill for compute, the OS disk, the 256 GiB data disk, +static public IP, network traffic, and taxes. The setup, stop, and remove +commands are covered in the setup and management sections below. The data disk +is used for the repository checkout, VM host state, and rootless Podman +storage. `/workspace`, `/var/lib/krav-azure-dev`, and +`/home/vscode/.local/share/containers/storage` are bind mounts into the data +disk. The bootstrap fails if the data disk is missing. + +Disk layout after setup: + +```text +OS disk +`-- / + |-- etc/ + | |-- apt/ + | | |-- keyrings/ + | | `-- sources.list.d/ + | |-- fstab + | |-- krav-dev/ -> optional Tailscale env read + | `-- sudoers.d/ + | `-- 90-krav-vscode + | + |-- home/ + | `-- vscode/ + | |-- .cache/ + | | `-- ms-playwright/ + | |-- .codex/ + | | |-- config.toml + | | |-- sqlite/ + | | `-- tmp/ + | |-- .config/ + | | |-- containers/ + | | | |-- containers.conf + | | | |-- storage.conf -> rootless storage points to data disk + | | | `-- systemd/ -> Quadlet unit files + | | `-- krav-dev/ -> support service env files + | |-- .dotnet/ + | |-- .local/ + | | `-- share/ + | | `-- containers/ + | | `-- storage/ -> bind mount to data disk Podman storage + | |-- .npm/ + | |-- .nuget/ + | |-- .oh-my-zsh/ + | | `-- custom/ + | | `-- plugins/ + | |-- .zshrc + | `-- ... + | + |-- mnt/ + | `-- krav-azure-dev-data/ -> data disk mount point + | + |-- opt/ + | `-- google/ + | `-- chrome/ + | `-- chrome -> symlink to Playwright/system Chrome + | + |-- run/ + | `-- user/ + | `-- / -> user systemd runtime + | + |-- tmp/ + | `-- krav-bootstrap-repo.* + | + |-- usr/ + | |-- local/ + | | `-- bin/ -> dotenv-linter + | `-- share/ + | `-- keyrings/ + | + |-- workspace/ -> bind mount to data disk workspace/ + | + `-- var/ + `-- lib/ + |-- krav-azure-dev/ -> bind mount to data disk host-state/ + `-- systemd/ + `-- linger/ + `-- vscode + +Data disk +`-- /mnt/krav-azure-dev-data/ + |-- workspace/ -> repository checkout + | |-- .env.development.local -> managed Azure VM block + | |-- containers/ + | | `-- kong/ + | | `-- kong.yml + | |-- node_modules/ + | `-- ... + | + |-- home/ + | `-- vscode/ + | `-- .local/ + | `-- share/ + | `-- containers/ + | `-- storage/ -> rootless Podman graphroot/images and volumes + | + `-- host-state/ -> managed host state +``` + +Package installation also writes normal Ubuntu, Node.js, Docker, GitHub CLI, +and .NET package-manager files under standard system locations such as `/usr`, +`/lib`, and `/var`. + +`/home/vscode` intentionally remains on the OS disk. Moving the full home +directory to the data disk is possible, for example by bind-mounting a data +disk directory to `/home/vscode`, but it couples SSH login, user systemd, shell +startup, VS Code Remote SSH, and user secrets to the data disk being present and +healthy. Keeping only `/workspace`, `/var/lib/krav-azure-dev`, and the +rootless Podman storage directory on the data disk gives the large and +rebuildable data a bigger disk while keeping the VM login path simpler to +recover. + +## Step 2: Prepare Azure + +Get or choose these values first: + +- subscription ID +- tenant ID for login +- Azure region, for example `eastus2`. +- resource group name, for example `rg-krav-dev-personal` +- stable environment ID, for example your username or initials + +The Azure region should be a region with the desired VM SKU and a low-latency +path to your location. The setup script does not validate latency. + +Make sure to use the same region for the resource group as for the resource +group configured in `.env.azure.development`. The resource group must be +in the same subscription as the VM. The resource group should preferably +be dedicated to this personal development environment. + +The resource group name and environment ID should also be specified in the +`.env.azure.development` file. + +The personal shortcut is subscription-scope `Contributor`. With that role, +`setup` can create and tag the resource group. + +The shared-subscription option is a pre-created resource group plus one of +these resource-group scoped roles: + +- a project-specific custom role with only the actions listed below +- the built-in `Contributor` role when a custom role is not available + +### Create resource group + +In a shared subscription, prefer an admin-created resource group and a custom +role scoped to that resource group. Ask the admin to create and tag the group: + +```sh +az group create \ + --subscription "" \ + --name "" \ + --location "eastus2" \ + --tags \ + "managed-by=kravhantering-azure-dev" \ + "environment-id=" \ + "repository=viscalyx/Kravhantering" \ + "purpose=personal-development" +``` + +The setup flow requires all four tags before it mutates an existing resource +group. If the group exists without them, setup fails closed and prints the +`az group update` command an owner can run. + +### Register providers + +The least-practical custom role needs resource-group deployment, compute, +network, disk, public IP, SSH public-key, and optional DevTestLab schedule +actions. The setup command does not create role assignments and does not use VM +extensions, VM runCommand, Azure SSH key-pair generation, or `DataActions`. + +If your tenant requires explicit provider registration, an owner can check or +register these providers: + +```sh +az provider show --subscription "" \ + --namespace "Microsoft.Compute" \ + --query registrationState \ + -o tsv + +az provider register --subscription "" \ + --namespace "Microsoft.Compute" + +az provider register --subscription "" \ + --namespace "Microsoft.Network" + +az provider register --subscription "" \ + --namespace "Microsoft.DevTestLab" +``` + +## Step 3: Install Workstation Prerequisites + +Install these tools on the workstation: + +- PowerShell 7+, run as `pwsh` +- Azure CLI +- OpenSSH client, `ssh-keygen`, and `scp` +- VS Code with Remote SSH +- Optional: Tailscale CLI for Tailscale cleanup checks + +Service-principal automation may use `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, +and `AZURE_CLIENT_SECRET` from the shell or `.env.azure.development.local`. +When all three values are set, real Azure commands log in with the service +principal before checking any existing Azure CLI user login. + +## Step 4: Configure `.env.azure.development` + +Copy the example and edit the required Azure values: + +```sh +cp .env.azure.development.example .env.azure.development +``` + +Required values: + +```env +AZURE_DEV_VM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +AZURE_DEV_VM_RESOURCE_GROUP=rg-krav-dev-personal +AZURE_DEV_VM_LOCATION=eastus2 +AZURE_DEV_VM_ENVIRONMENT_ID= +``` + +Set these common non-secret values deliberately: + +```env +AZURE_DEV_VM_NAME_PREFIX=krav-dev +AZURE_DEV_VM_NAME=krav-dev-vm +AZURE_DEV_VM_SIZE=Standard_D8s_v5 +AZURE_DEV_VM_FALLBACK_SIZE=Standard_D8as_v5 +AZURE_DEV_VM_CONNECTIVITY_MODE=public-ssh +AZURE_DEV_VM_ALLOWED_SSH_CIDR=auto +AZURE_DEV_VM_SSH_HOST_ALIAS=kravhantering-azure-dev +AZURE_DEV_VM_SSH_PRIVATE_KEY_PATH=~/.ssh/kravhantering_azure_dev_ed25519 +AZURE_DEV_VM_AUTO_STOP_ENABLED=true +AZURE_DEV_VM_AUTO_STOP_TIME=2200 +AZURE_DEV_VM_AUTO_STOP_TIME_ZONE=UTC +``` + +Use `AZURE_DEV_VM_ALLOWED_SSH_CIDR=auto` for the normal path. Setup detects +your current public IPv4 address and proposes it as a `/32`. Do not use +`0.0.0.0/0`; the tool refuses broad SSH ranges. + +The parser intentionally supports only `KEY=value`, optional quotes, blank +lines, and full-line comments. It does not evaluate shell expressions, +`export`, variable expansion, or command substitution. + +## Step 5: Configure `.env.azure.development.local` + +Create this file only when you need per-workstation overrides or secrets: + +```sh +touch .env.azure.development.local +``` + +The file is gitignored by the existing `.env.*.local` rule. Put secrets here +only when session environment variables are not practical. + +Optional service-principal login: + +Use this when setup should run as the service principal instead of your current +Azure CLI user. Set all three values together: + +```env +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +``` + +For real commands, including `setup`, a complete service-principal +configuration is used before any existing `az login` session. If any of the +three values are set, all three must be set. For `-WhatIf`, the script does not +perform `az login --service-principal` because that would mutate the local +Azure CLI session. + +### Determine permissions for service principal + +Use one of these values for ``: + +- the project-specific custom role name or role ID from the Azure admin +- `Contributor` for the built-in role at the resource-group scope + +The custom role is preferred in a shared subscription. `Contributor` is simpler +for a personal subscription or a dedicated development resource group, but it +can manage more resource types inside that group than this tool needs. + +### Option 1. Create service principal and role assignment + +If you are allowed to create app registrations and assign Azure RBAC at the +resource-group scope, create the credentials and role assignment in one command: + +```sh +az ad sp create-for-rbac \ + --name "krav-dev-" \ + --role "" \ + --scopes \ + "/subscriptions//resourceGroups/" \ + --query "{tenantId:tenant,clientId:appId,clientSecret:password}" \ + -o json +``` + +Copy the output values into `.env.azure.development.local`: + +```env +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +``` + +`clientId` is the service principal application ID, returned as `appId` by +Azure CLI. `clientSecret` is the generated password. Save it immediately; Azure +does not let you read the same secret value later. + +### Option 2. Admin-created service principal + +If the tenant admin creates the identity before assigning access, ask them to +run: + +```sh +az ad sp create-for-rbac \ + --name "krav-dev-" \ + --query "{tenantId:tenant,clientId:appId,clientSecret:password}" \ + -o json +``` + +Then assign the role at the resource-group scope: + +```sh +az role assignment create \ + --assignee "" \ + --role "" \ + --scope \ + "/subscriptions//resourceGroups/" +``` + +Copy the output values into `.env.azure.development.local`: + +```env +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +``` + +`clientId` is the service principal application ID, returned as `appId` by +Azure CLI. `clientSecret` is the generated password. Save it immediately; Azure +does not let you read the same secret value later. + +### Reset service principal secret + +If the secret is lost or expired, reset it and update +`.env.azure.development.local` with the new `clientSecret`: + +```sh +az ad sp credential reset \ + --id "" \ + --append \ + --query "{clientSecret:password}" \ + -o json +``` + +### (Optional) Use ephemeral Tailscale auth key + +Optional Tailscale mode: + +```env +AZURE_DEV_VM_CONNECTIVITY_MODE=tailscale +AZURE_DEV_TAILSCALE_AUTH_KEY= +AZURE_DEV_TAILSCALE_TAILNET= +``` + +Precedence is: + +1. session environment variables +2. `.env.azure.development.local` +3. `.env.azure.development` +4. built-in defaults + +That means a shell `AZURE_CLIENT_SECRET` overrides the value in +`.env.azure.development.local`. If the shell does not define it, the value from +`.env.azure.development.local` is used. + +## Step 6: Run Setup + +Run the read-only Azure readiness preflight before setup: + +```sh +pwsh ./scripts/azure-dev.ps1 setup -WhatIf +``` + +This checks tools, Azure login, subscription visibility, SKU availability, +resource-group ownership tags, SSH CIDR, and the deployment preview. It needs +the Azure permissions described in Step 2, but it must not create Azure +resources, SSH keys, local locks, state, or logs. + + +Azure what-if can report provider-owned or read-only defaults as changes, for +example NIC `kind`, dynamic private IP, public-IP DDoS settings, disk values +marked `Noeffect`, or standalone managed disks marked `Ignore`. Treat those as +noise when no create, delete, or meaningful update is listed for managed +resources. + +When preflight is clean, create or repair the environment: + +```sh +pwsh ./scripts/azure-dev.ps1 setup -Yes +``` + +`setup` prints a cost summary before it creates resources. It does not estimate +every charge. It does not run the Azure deployment preview; use +`setup -WhatIf` when you want that preview. + +Use `-Yes` for non-interactive confirmation. The command creates a dedicated +SSH key if missing, provisions Azure resources, installs the managed SSH config +block when approved, waits for SSH, uploads the local bootstrap and Quadlet +templates, reruns the VM bootstrap, and runs smoke validation. +If the VM already exists but was deallocated by `stop` or auto-shutdown, `setup` +starts it before waiting for SSH. + +The first setup can take a while. It installs host packages, mounts the data +disk at `/mnt/krav-azure-dev-data`, bind-mounts +`/mnt/krav-azure-dev-data/workspace` to `/workspace`, bind-mounts +`/mnt/krav-azure-dev-data/host-state` to `/var/lib/krav-azure-dev`, and +bind-mounts the data-disk-backed Podman storage directory to +`/home/vscode/.local/share/containers/storage`. It clones the repo to +`/workspace`, configures rootless Podman to use its normal home storage path, +runs `npm install`, restores .NET tools, installs Playwright browsers, verifies +the checked-out Kong config, builds HSA support images with Podman, recreates +the managed support containers from the current Quadlet templates and +checked-out Kong config while preserving named volumes, starts Quadlet services, +and runs smoke validation. + +The generated VS Code command is: + +```sh +code --remote ssh-remote+kravhantering-azure-dev /workspace +``` + +## Step 7: Open and Run the App + +Open the VM through VS Code Remote SSH: + +```sh +code --remote ssh-remote+kravhantering-azure-dev /workspace +``` + +On the VM: + +```sh +cd /workspace +npm run dev +``` + +The app runs directly on the VM host. Containers run only the support services. + +## Step 8: Manage the Environment + +Start the VM: + +```sh +pwsh ./scripts/azure-dev.ps1 start +``` + +Stop compute charges: + +```sh +pwsh ./scripts/azure-dev.ps1 stop +``` + +`stop` deallocates the VM and stops compute charges. Disks and public IP +resources can still bill. + +Show current state: + +```sh +pwsh ./scripts/azure-dev.ps1 status +``` + +Refresh only the SSH source CIDR after your public IP changes: + +```sh +pwsh ./scripts/azure-dev.ps1 update-cidr +``` + +Print or apply the managed SSH block: + +```sh +pwsh ./scripts/azure-dev.ps1 ssh-config +pwsh ./scripts/azure-dev.ps1 ssh-config -Apply +``` + +Forwarded ports are `3000`, `3001`, `4443`, `1433`, `8080`, `18000`, `9323`, +and `51204`. + +## Step 9: Manage Support Services + +On the VM, inspect the support stack as `vscode`: + +```sh +systemctl --user status krav-db.service +systemctl --user status krav-idp.service +systemctl --user status krav-kong.service +systemctl --user status krav-hsa-person-lookup-adapter.service +systemctl --user status krav-hsa-directory-mock.service +``` + +Restart a service: + +```sh +systemctl --user restart krav-kong.service +``` + +Inspect logs: + +```sh +journalctl --user -u krav-db.service -n 100 +journalctl --user -u krav-kong.service -n 100 +``` + +The VM bootstrap writes a managed block to `/workspace/.env.development.local` +so HSA lookup uses Kong on `127.0.0.1:18000`. + +## Tailscale + +Set `AZURE_DEV_VM_CONNECTIVITY_MODE=tailscale` only when the Tailscale account, +auth-key policy, and device cleanup process are ready. This mode uses ordinary +OpenSSH to the VM Tailscale address. It does not use Tailscale SSH. + +The bootstrap installs Tailscale. If `/etc/krav-dev/tailscale.env` exists on +the VM with `AZURE_DEV_TAILSCALE_AUTH_KEY`, bootstrap joins the tailnet with +`--ssh=false`. Treat auth keys as secrets and prefer ephemeral, pre-approved +keys. If teardown cannot remove the Tailscale device automatically, delete the +VM device from the Tailscale admin console. + +## Step 10: Validate + +Default smoke validation checks SSH, the data-disk bind mounts, `/workspace`, +major tool versions, rootless Podman units, loopback-only support ports, HSA +lookup through Kong, `npm run db:setup`, `npm run db:health`, and Playwright +browser availability. + +Optional heavier checks after the environment is accepted: + +```sh +npm run check +npm run test:integration +``` + +## Step 11: Tear Down + +Preview deletion: + +```sh +pwsh ./scripts/azure-dev.ps1 remove -WhatIf +``` + +Delete managed Azure resources and owned local state: + +```sh +pwsh ./scripts/azure-dev.ps1 remove +``` + +`remove` deletes the managed resources and is the full managed-resource cost +stop. + +SSH private and public key files are preserved by default. Use `-CleanupKeys` +only when you intentionally want to remove the generated key pair. + +## Troubleshooting + +If Azure recreates the VM and SSH reports a host-key mismatch, run the exact +command printed by the tool: + +```sh +ssh-keygen -R kravhantering-azure-dev +``` + +If setup reports that the existing VM was created with a different SSH public +key, the VM must be recreated. Azure does not allow changing +`osProfile.linuxConfiguration.ssh.publicKeys` on an existing VM. This can +happen if an earlier dry run created resources with the placeholder key. + +Preview and then remove the managed environment: + +```sh +pwsh ./scripts/azure-dev.ps1 remove -WhatIf +pwsh ./scripts/azure-dev.ps1 remove +``` + +Then rerun setup so the VM is created with the local SSH key. + +If SKU validation fails, set `AZURE_DEV_VM_SIZE` or +`AZURE_DEV_VM_FALLBACK_SIZE` to a size available in the selected region. Use a +4 vCPU and 16 GiB size only with the expectation that the workload may become +memory-bound. + +If SQL Server rootless volume validation fails, inspect: + +```sh +journalctl --user -u krav-db.service +podman volume inspect krav-sqlserver +``` + +The Ubuntu 24.04 bootstrap installs `dotnet-sdk-8.0` from the Ubuntu package +feeds. It does not add the Microsoft package feed or install +`packages-microsoft-prod.deb`. diff --git a/docs/development/azure-vm-remote-ssh-internals.md b/docs/development/azure-vm-remote-ssh-internals.md new file mode 100644 index 00000000..d7b2c100 --- /dev/null +++ b/docs/development/azure-vm-remote-ssh-internals.md @@ -0,0 +1,488 @@ +# Azure VM Remote SSH Internals + + + + + + + +This document is for contributors who maintain the Azure VM Remote SSH +implementation. The operator workflow, configuration examples, cost warning, +disk tree, and daily commands live in +[Azure VM Remote SSH Development](./azure-vm-remote-ssh-development.md). + +Do not duplicate operator instructions here. Add content here when it explains +how the implementation works, what invariants must be preserved, or how to +change the scripts safely. + +## System Shape + +The environment is a disposable, single-developer Azure VM opened with VS Code +Remote SSH. The app runs directly on the VM host in `/workspace`. Containers +run only support services: SQL Server, Keycloak, Kong, the HSA directory mock, +and the HSA person lookup adapter. + +The implementation has three layers: + +- PowerShell 7 command surface in `scripts/azure-dev.ps1` and + `scripts/azure-dev/*.psm1`. +- Azure resources declared in `scripts/azure-dev/templates/main.bicep`. +- Guest bootstrap and user-level Quadlet templates under + `scripts/azure-dev/templates/`. + +PowerShell owns local configuration, Azure CLI orchestration, SSH key and +OpenSSH config management, upload of guest templates, state, locks, logs, and +validation. Bicep owns the resource graph inside the selected resource group. +The guest bootstrap owns Ubuntu package setup, data-disk mounts, repository +checkout, rootless Podman configuration, Quadlet installation, and support +service startup. + +## Source Layout + +`scripts/azure-dev.ps1` is the only public entry point. It imports modules in +this order: + +```text +AzureDev.Config.psm1 +AzureDev.Logging.psm1 +AzureDev.Azure.psm1 +AzureDev.Ssh.psm1 +AzureDev.Bootstrap.psm1 +AzureDev.Validation.psm1 +AzureDev.Podman.psm1 +``` + +Module responsibilities: + + +| Module | Responsibility | +| --- | --- | +| `AzureDev.Config.psm1` | Strict dotenv parsing, defaults, precedence, config validation, and context creation. | +| `AzureDev.Logging.psm1` | Local state, locks, JSONL logs, redaction, and native command execution helpers. | +| `AzureDev.Azure.psm1` | Azure CLI calls, authentication checks, SKU and image lookup, resource-group ownership, deployment, power operations, CIDR updates, and tag-based deletion. | +| `AzureDev.Ssh.psm1` | Public IPv4 detection, CIDR validation, SSH key generation, managed OpenSSH config blocks, host-key mismatch handling, SSH wait loop, and VS Code command formatting. | +| `AzureDev.Bootstrap.psm1` | Uploads `bootstrap-host.sh` and Quadlet templates with `scp`, then invokes bootstrap over SSH with port forwarding disabled. | +| `AzureDev.Validation.psm1` | Runs post-setup smoke validation over SSH and reports remote diagnostics on failure. | +| `AzureDev.Podman.psm1` | Shared support-service unit and port metadata used by validation. | + + +Keep parsing and planning logic separate from Azure and filesystem mutations. +This is what makes `-WhatIf`, dry inspection, and future focused tests +practical. + +## Command Model + +The entry point validates the command name with PowerShell's `ValidateSet` and +then builds one context object. The context contains the resolved config, +operator switches, and derived local paths: + +```text +.azure/development.state.json +.azure/development.lock +.azure/logs/ +scripts/azure-dev/templates/main.bicep +scripts/azure-dev/templates/bootstrap-host.sh +``` + +Only `setup` requires `.env.azure.development` to exist. `estimate-cost` allows +missing Azure scope values so it can read local defaults and print cost drivers +without Azure access. Other commands still validate required Azure scope values +before calling Azure. + +The command flow is intentionally narrow: + +- `estimate-cost` prints local cost drivers only. +- `setup` validates prerequisites, resolves SSH CIDR and Ubuntu image, creates + or verifies the SSH key, checks existing VM SSH-key drift, converges the + resource group and Bicep deployment, starts the VM when needed, waits for SSH, + uploads templates, reruns bootstrap, runs smoke validation, writes state, and + prints SSH instructions. +- `start` starts the VM, refreshes SSH config, waits for SSH, and prints + connection instructions. +- `stop` deallocates the VM. +- `status` reads Azure state plus local state and prints a compact status. +- `update-cidr` updates only the SSH NSG rule and managed SSH config. +- `ssh-config` prints the managed OpenSSH block or applies it when requested. +- `remove` deletes only live resources selected by ownership tags, then removes + owned local state and the managed SSH config block. + +## Mutation Rules + +Every mutating function must be an advanced PowerShell function and must use +`SupportsShouldProcess`. `-WhatIf` relies on `$WhatIfPreference`; do not +special-case it by writing parallel dry-run code that diverges from the real +path. + +`setup -WhatIf` must remain read-only. It may inspect local tools, Azure login, +subscription visibility, SKU availability, resource-group tags, SSH CIDR, and +the Azure deployment preview. It must not create Azure resources, SSH keys, +OpenSSH config entries, local state, locks, or logs. + +The script uses a placeholder public key only during `setup -WhatIf` when the +real key does not exist. Real setup creates or reuses the configured local +`ed25519` key pair and installs only the public key on the VM. + +All native commands must go through `Invoke-AzureDevNativeCommand` unless there +is a specific reason not to. That helper writes the redacted formatted command +with `Write-Verbose` and writes the raw command output with `Write-Debug`. +Keep new secret-bearing arguments compatible with `Format-AzureDevCommand` +redaction. + +## Configuration And Authentication + +Configuration is a strict dotenv subset: + +- `KEY=value` +- optional single or double quotes +- blank lines +- full-line `#` comments + +The parser rejects `export`, shell evaluation, variable expansion, command +substitution, and unterminated quoted values. It expands `~` only for paths the +implementation explicitly resolves. + +Precedence is: + +1. Session environment variables. +2. `.env.azure.development.local`. +3. `.env.azure.development`. +4. Built-in defaults. + +The complete service-principal triple is: + +```text +AZURE_TENANT_ID +AZURE_CLIENT_ID +AZURE_CLIENT_SECRET +``` + +If any one value is set, all three must be set. For real Azure commands, a +complete triple is used before an existing Azure CLI user session. For +`-WhatIf`, the script must not run `az login --service-principal` because that +mutates local Azure CLI state. + +Never print or log secret values. State files must contain only non-secret +cache data that can be rebuilt from Azure or config. + +## Azure Provisioning + +PowerShell shells out to Azure CLI for: + +- cloud and account checks +- service-principal login when configured +- subscription selection +- provider and SKU/image inspection +- resource-group lookup and creation +- Bicep what-if and deployment +- output capture +- power operations +- tag-filtered resource deletion + +Bicep owns these resources: + +- VNet `namePrefix-vnet` +- subnet `snet-dev` +- NSG `namePrefix-nsg` +- optional public IP `namePrefix-pip` in `public-ssh` mode +- NIC `namePrefix-nic` +- SSH public-key resource `namePrefix-ssh-key` +- VM `vmName` +- managed OS disk `vmName-osdisk` +- managed data disk `vmName-data` +- optional DevTestLab schedule `shutdown-computevm-vmName` + +All resources receive the common tag set: + +```text +managed-by=kravhantering-azure-dev +environment-id= +repository=viscalyx/Kravhantering +purpose=personal-development +``` + +Existing resource groups are mutable only when ownership tags are present, or +when the explicit adoption path is used. Deletion uses live Azure tags, not +local state, as the source of truth. `remove` deletes matching resources inside +the resource group and does not delete the resource group itself. + +The VM admin user is always `vscode`. Azure password authentication is disabled +and the configured SSH public key is written to +`/home/vscode/.ssh/authorized_keys` through the VM OS profile. Azure does not +allow that OS-profile SSH key to be changed in place, so setup detects a VM +created with a different key and fails with a remove-and-recreate instruction. + +The data disk uses `deleteOption: Detach` in Bicep. Teardown therefore deletes +the managed data disk explicitly by tag, instead of relying on VM deletion to +cascade it. + +## SSH And Connectivity + +`public-ssh` is the default connectivity mode. The implementation detects the +operator's current public IPv4 address and converts it to a `/32` when +`AZURE_DEV_VM_ALLOWED_SSH_CIDR=auto`. Broad ranges such as `0.0.0.0/0` and +`::/0` must remain blocked. + +The managed OpenSSH block is bounded by markers and is the only part of +`~/.ssh/config` the tool may change. The block uses the configured host alias, +the `vscode` user, `IdentitiesOnly yes`, the configured private key, and the +local forwards documented in the development guide. + +Remote command probes use: + +```text +BatchMode=yes +ClearAllForwardings=yes +StrictHostKeyChecking=accept-new +``` + +`ClearAllForwardings=yes` is important for maintenance commands because an +existing VS Code Remote SSH session may already own the forwarded local ports. + +Tailscale mode is explicit. It uses ordinary OpenSSH over the VM's Tailscale +address and does not enable Tailscale SSH. + +## Guest Bootstrap + +`AzureDev.Bootstrap.psm1` uploads the current local bootstrap script and +Quadlet templates to `/tmp` on the VM, waits for cloud-init when available, and +runs: + +```text +sudo env AZURE_DEV_QUADLET_SOURCE=/tmp/krav-azure-dev/quadlet bash /tmp/krav-bootstrap-host.sh +``` + +Do not move bootstrap into Azure `customData`. Azure does not allow changing +`customData` on an existing VM, while this workflow must be able to rerun the +current local bootstrap against an existing development VM. + +Bootstrap is idempotent and is expected to repair safe drift. It installs host +packages, prepares the `vscode` user, mounts storage, clones or updates the +repository, configures rootless Podman, writes managed local app environment, +builds local HSA images, installs Quadlet units, and starts services. + +Package setup intentionally installs .NET SDK 8.0 from Ubuntu 24.04 package +feeds. Do not add the Microsoft package feed unless the devcontainer path is +changed at the same time and the reason is documented. + +Repository setup uses `npm install`, not `npm ci`, to match the current +devcontainer behavior. If deterministic install becomes a requirement, change +the devcontainer and Azure VM bootstrap together. + +## Storage Invariants + +The development guide contains the human-readable disk tree. The contributor +contract is: + +- `/mnt/krav-azure-dev-data` is the Azure data disk mount. +- `/workspace` is a bind mount to the data disk and contains the repository. +- `/var/lib/krav-azure-dev` is a bind mount to the data disk and contains host + state owned by this feature. +- `/home/vscode/.local/share/containers/storage` is a bind mount to the data + disk and is the rootless Podman graphroot. +- `/home/vscode` itself remains on the OS disk. +- There is no OS-disk fallback for `/workspace`, host state, or Podman storage. + +`bootstrap-host.sh` formats `/dev/disk/azure/scsi1/lun0` as ext4 only when the +device has no filesystem. It rewrites only the relevant `/etc/fstab` entries: +the data mount, `/workspace`, `/var/lib/krav-azure-dev`, and the rootless +Podman storage bind mount. It removes `lost+found` from the exposed bind-mount +roots and fixes ownership for `vscode`. + +Rootless Podman is configured with: + +```text +graphroot = "/home/vscode/.local/share/containers/storage" +rootless_storage_path = "/home/vscode/.local/share/containers/storage" +``` + +That path is deliberately the normal rootless location from Podman's point of +view. The data disk is introduced underneath it with a bind mount. This avoids +Netavark and rootless networking issues that can appear when graphroot is moved +to an unusual system path. + +When changing storage behavior, update bootstrap and smoke validation together. +Validation must prove that the data mount source is the Azure data disk and +that `/workspace`, host state, and Podman storage are on the same device as the +data-disk mount. + +## Podman Support Stack + +Support services run as rootless Podman containers owned by `vscode` and +managed by user-level systemd Quadlet units under: + +```text +/home/vscode/.config/containers/systemd/ +``` + +Bootstrap enables lingering for `vscode`, starts `user@.service`, sets +the user systemd environment, reloads user units, starts base network and +volume units, reruns the certificate generator, and then starts long-running +services. + +Managed container names are stable because the app and diagnostics assume +them: + +```text +db +idp +kong +hsa-directory-mock +hsa-person-lookup-adapter +``` + +The shared Podman network is `krav-support`. Published support ports must bind +only to loopback: + +```text +127.0.0.1:1433 SQL Server +127.0.0.1:8080 Keycloak +127.0.0.1:18000 Kong HSA proxy +``` + +Do not add Azure NSG rules for support ports. Workstation access goes through +OpenSSH or VS Code Remote SSH port forwarding. + +SQL Server data and generated HSA mTLS certificates live in named Podman +volumes. Bootstrap may remove and recreate containers while preserving those +volumes. The SQL Server volume uses Podman's `U` volume option and +`HOME=/var/opt/mssql` so the image user has a writable home and system +directory. Kong uses `KONG_PREFIX=/tmp/kong` so rootless runtime state is +writable. + +Kong uses the checked-out repository file +`/workspace/containers/kong/kong.yml`. Bootstrap verifies route +`hsa-directory-person-lookup-rest` and adds `protocols: [http, https]` only +when missing. This keeps older workspaces repairable without uploading a local +copy of `kong.yml` from the operator machine. + +## Managed App Environment + +Bootstrap preserves user content in `/workspace/.env.development.local` and +writes only a managed block for Azure VM support-service overrides. The required +HSA lookup value is: + +```env +HSA_PERSON_LOOKUP_URL=http://127.0.0.1:18000/hsa/person-records/lookup +``` + +Do not replace the whole file. Contributors adding VM-specific variables must +extend the managed block logic and keep non-managed local developer settings +intact. + +## State, Locks, And Logs + +Local state is a cache written to: + +```text +.azure/development.state.json +``` + +It records the setup version, subscription, resource group, VM name, current +public IP or Tailscale target, SSH alias and key paths, deployment outputs, +last known SSH CIDR, and last validation status. Destructive paths must verify +live Azure resources instead of trusting this file. + +Mutating commands create: + +```text +.azure/development.lock +``` + +The lock contains command name, process ID, host, user, environment ID, and +start time. `-ForceUnlock` may remove stale local locks only. It must not +bypass Azure ownership checks or destructive confirmations. + +JSONL logs are written under: + +```text +.azure/logs/ +``` + +Logs must remain redacted. Do not write secrets, SSH private keys, tokens, +passwords, auth keys, or full connection strings. + +## Smoke Validation + +Smoke validation is a usability check, not a full repository quality gate. It +runs over SSH after bootstrap and diagnoses remote failures in place. + +Validation must prove these implementation contracts: + +- SSH reaches the generated host alias as `vscode`. +- `/workspace` exists, is owned by `vscode`, and contains the repo. +- the data disk, `/workspace`, host state, and rootless Podman storage are + mounted as described in the storage invariants. +- rootless Podman graphroot is + `/home/vscode/.local/share/containers/storage`. +- expected major tools are installed: Node 24, npm, .NET 8.0, Git, GitHub CLI, + Docker CLI, Compose, Buildx, Podman, `podman-compose`, Python, + `dotenv-linter`, and Playwright. +- user lingering is enabled. +- managed Quadlet services are active. +- support ports are bound only to loopback. +- the Kong HSA route allows both HTTP and HTTPS. +- the HSA lookup route succeeds through Kong. +- `npm run db:setup`, `npm run db:health`, and the Playwright dry-run browser + install check complete. + +On failure, validation dumps listening sockets, user systemd environment, +failed units, service status and journals, Podman containers, Podman store, +networks, volumes, and managed container logs. Keep this diagnostic output +high-signal because it is the main feedback loop for remote bootstrap issues. + +Do not make `npm run check` or `npm run test:integration` part of default VM +smoke validation. Those are optional confidence checks after the environment is +usable. + +## Teardown Contract + +`remove` discovers resources from live Azure and filters by ownership tags. +Deletion is intentionally resource-level rather than resource-group deletion so +a shared subscription can pre-create and retain the group. + +The deletion order is computed from the resources selected for deletion. If a +resource cannot be deleted because another managed resource still references +it, fix the ordering or dependency handling instead of broadening the deletion +scope. + +After Azure deletion, the tool removes the managed SSH config block and local +state. SSH keys are preserved unless the explicit cleanup switch is used. Logs +are preserved unless explicit log cleanup is requested. + +Tailscale cleanup is best-effort and separate from Azure deletion. Azure +teardown should continue even when Tailscale device cleanup cannot be performed +automatically. + +## Contributor Change Checklist + +When changing Azure VM Remote SSH behavior: + +- Update the user-facing development guide only for operator-visible workflow, + configuration, command, cost, troubleshooting, or disk-layout changes. +- Update this internals document for module responsibilities, invariants, + lifecycle flow, bootstrap behavior, validation coverage, or teardown safety. +- Keep `-WhatIf` read-only. +- Route new mutations through `ShouldProcess`. +- Route native commands through `Invoke-AzureDevNativeCommand` or document why + not. +- Redact new secret-bearing values in command formatting, state, and logs. +- Update bootstrap and smoke validation together when changing host layout, + Podman storage, support ports, or service startup order. +- Avoid adding tests that target `.ps1`, `.psm1`, or docs unless that policy is + deliberately changed. + +## Decision Inputs + +The original design work is tracked in: + + +- [Compare Ubuntu 24.04 and Rocky Linux for Azure VM base OS](https://github.com/viscalyx/Kravhantering/issues/432) +- [Choose secure connectivity model for Azure Remote SSH](https://github.com/viscalyx/Kravhantering/issues/433) +- [Choose Azure provisioning substrate and permissions model](https://github.com/viscalyx/Kravhantering/issues/434) +- [Define VM cost, size, region, and lifecycle guardrails](https://github.com/viscalyx/Kravhantering/issues/435) +- [Define host bootstrap parity with the devcontainer](https://github.com/viscalyx/Kravhantering/issues/436) +- [Design Podman topology for development support services](https://github.com/viscalyx/Kravhantering/issues/437) +- [Define operator configuration, credentials, and SSH integration contract](https://github.com/viscalyx/Kravhantering/issues/438) +- [Define idempotency, teardown, and state-safety contract](https://github.com/viscalyx/Kravhantering/issues/439) +- [Define validation and acceptance checks for the Azure development environment](https://github.com/viscalyx/Kravhantering/issues/440) + diff --git a/scripts/azure-dev.ps1 b/scripts/azure-dev.ps1 new file mode 100644 index 00000000..bb45bad2 --- /dev/null +++ b/scripts/azure-dev.ps1 @@ -0,0 +1,638 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Position = 0)] + [ValidateSet('estimate-cost', 'setup', 'start', 'stop', 'status', 'update-cidr', 'ssh-config', 'remove')] + [string]$Command = 'status', + + [string]$EnvironmentFile = '.env.azure.development', + + [string]$AllowedSshCidr, + + [switch]$Yes, + + [switch]$AdoptResourceGroup, + + [switch]$ForceUnlock, + + [switch]$Apply, + + [switch]$CleanupLogs, + + [switch]$CleanupKeys, + + [switch]$SkipSshConfig, + + [switch]$SkipSmokeValidation +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$moduleRoot = Join-Path $scriptRoot 'azure-dev' +foreach ($module in @( + 'AzureDev.Config.psm1', + 'AzureDev.Logging.psm1', + 'AzureDev.Azure.psm1', + 'AzureDev.Ssh.psm1', + 'AzureDev.Bootstrap.psm1', + 'AzureDev.Validation.psm1', + 'AzureDev.Podman.psm1' +)) { + Import-Module (Join-Path $moduleRoot $module) -Force -Verbose:$false +} + +function Test-AzureDevApproval { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Action + ) + + if ($Context.Yes -or $WhatIfPreference) { + return $true + } + + $answer = Read-Host "$Action Type YES to continue" + if ($answer -ne 'YES') { + throw 'Operation cancelled.' + } + return $true +} + +function Write-AzureDevCostSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [AllowNull()] + [pscustomobject]$Image + ) + + $subscription = if ([string]::IsNullOrWhiteSpace($Context.Config.SubscriptionId)) { + '' + } else { + $Context.Config.SubscriptionId + } + $resourceGroup = if ([string]::IsNullOrWhiteSpace($Context.Config.ResourceGroup)) { + '' + } else { + $Context.Config.ResourceGroup + } + $imageUrn = if ($null -ne $Image -and -not [string]::IsNullOrWhiteSpace($Image.urn)) { + $Image.urn + } else { + 'Canonical Ubuntu 24.04 LTS Server, resolved during setup' + } + + Write-Host 'Azure VM development environment cost summary' + Write-Host " Subscription: $subscription" + Write-Host " Resource group: $resourceGroup" + Write-Host " Location: $($Context.Config.Location)" + Write-Host " VM size: $($Context.Config.VmSize)" + Write-Host " OS disk: managed Premium SSD" + Write-Host " Data disk: $($Context.Config.DataDiskGiB) GiB Premium SSD" + Write-Host " Static public IP: $($Context.Config.ConnectivityMode -eq 'public-ssh')" + Write-Host " Auto-shutdown: $($Context.Config.AutoStopEnabled) at $($Context.Config.AutoStopTime) $($Context.Config.AutoStopTimeZone)" + Write-Host " Image: $imageUrn" + Write-Host ' Not estimated here: bandwidth, snapshots, logs, taxes, and current regional pricing.' + Write-Host ' Deallocation stops compute charges, but disks and public IPs can still bill.' + Write-Host ' Running remove is the full managed-resource cost stop.' +} + +function Write-AzureDevCostEstimate { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + Write-AzureDevCostSummary -Context $Context -Image $null + Write-Host '' + Write-Host 'This command reads local configuration only. It does not call Azure CLI,' + Write-Host 'validate subscription access, check SKU availability, or create resources.' + Write-Host 'Use the Azure Pricing Calculator for current region-specific prices.' +} + +function Get-AzureDevHostName { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $state = Get-AzureDevState -Context $Context + if ( + $Context.Config.ConnectivityMode -eq 'tailscale' -and + $null -ne $state -and + -not [string]::IsNullOrWhiteSpace($state.tailscaleTarget) + ) { + return $state.tailscaleTarget + } + + if ( + $null -ne $state -and + -not [string]::IsNullOrWhiteSpace($state.publicIp) + ) { + return $state.publicIp + } + + $publicIp = Get-AzureDevPublicIpAddress -Config $Context.Config + if (-not [string]::IsNullOrWhiteSpace($publicIp)) { + return $publicIp + } + + return '' +} + +function Write-AzureDevSshInstructions { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + Write-Host "SSH target: $($Context.Config.SshHostAlias)" + $codeCommand = Get-AzureDevCodeCommand -Context $Context + if ($null -ne $codeCommand) { + Write-Host $codeCommand + } +} + +function Test-AzureDevVmSshPublicKeyDrift { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$SshPublicKey + ) + + $existingKeys = Get-AzureDevVmAdminSshPublicKeys -Config $Context.Config + if (@($existingKeys).Count -eq 0) { + return + } + + if ($SshPublicKey -in @($existingKeys)) { + return + } + + $existingPreview = (@($existingKeys) | Select-Object -First 1).Trim() + if ($existingPreview.Length -gt 96) { + $existingPreview = $existingPreview.Substring(0, 96) + '...' + } + + throw ( + "Existing VM $($Context.Config.VmName) was created with a different SSH public key, " + + "and Azure does not allow changing osProfile.linuxConfiguration.ssh.publicKeys on an existing VM. " + + "Existing VM key starts with: $existingPreview. " + + "This can happen if an earlier setup -WhatIf run created resources with the placeholder key. " + + "Run: pwsh ./scripts/azure-dev.ps1 remove -WhatIf. " + + "Then run: pwsh ./scripts/azure-dev.ps1 remove. " + + "After removal, rerun setup so the VM is created with the local key at $($Context.Config.SshPublicKeyPath)." + ) +} + +function Set-AzureDevSetupState { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$AllowedCidr, + + [AllowNull()] + [object]$DeploymentResult, + + [Parameter(Mandatory = $true)] + [string]$ValidationStatus + ) + + $outputs = Get-AzureDevDeploymentOutputs -DeploymentResult $DeploymentResult + $publicIp = Get-AzureDevPublicIpAddress -Config $Context.Config + $state = [ordered]@{ + setupVersion = $Context.Config.SetupVersion + subscriptionId = $Context.Config.SubscriptionId + resourceGroup = $Context.Config.ResourceGroup + vmName = $Context.Config.VmName + publicIp = $publicIp + tailscaleTarget = $null + sshHostAlias = $Context.Config.SshHostAlias + sshPrivateKeyPath = $Context.Config.SshPrivateKeyPath + sshPublicKeyPath = $Context.Config.SshPublicKeyPath + deploymentOutputs = $outputs + lastKnownAllowedCidr = $AllowedCidr + lastValidationStatus = $ValidationStatus + } + Set-AzureDevState -Context $Context -State $state +} + +function Invoke-AzureDevSetup { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [string]$CidrOverride + ) + + if (-not $WhatIfPreference) { + New-AzureDevLock -Context $Context -CommandName 'setup' + } + + try { + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + $allowedCidr = Get-AzureDevAllowedSshCidr ` + -Config $Context.Config ` + -OverrideCidr $CidrOverride + + if ($WhatIfPreference) { + $publicKey = if (Test-Path -LiteralPath $Context.Config.SshPublicKeyPath) { + Get-AzureDevSshPublicKey -Config $Context.Config + } else { + Get-AzureDevPlaceholderPublicKey + } + } else { + New-AzureDevSshKey -Config $Context.Config + $publicKey = Get-AzureDevSshPublicKey -Config $Context.Config + } + + $image = Get-AzureDevUbuntuImage -Config $Context.Config + Write-AzureDevCostSummary -Context $Context -Image $image + Test-AzureDevVmSshPublicKeyDrift ` + -Context $Context ` + -SshPublicKey $publicKey + Test-AzureDevApproval -Context $Context -Action 'Create or update Azure VM resources.' | Out-Null + + New-AzureDevResourceGroup ` + -Context $Context ` + -WhatIf:$WhatIfPreference + + if ($WhatIfPreference -and $null -eq (Get-AzureDevResourceGroup -Config $Context.Config)) { + Write-Warning ( + 'Skipping deployment preview because the resource group does not exist. ' + + 'Run setup without -WhatIf to create it, or ask an Azure admin to create it.' + ) + return + } + + if ($WhatIfPreference) { + New-AzureDevDeployment ` + -Context $Context ` + -AllowedSshCidr $allowedCidr ` + -SshPublicKey $publicKey ` + -Image $image ` + -Preview + + Write-Host 'setup -WhatIf completed. No Azure resources, SSH files, local state, locks, or logs were created or modified.' + return + } + + $deployment = New-AzureDevDeployment ` + -Context $Context ` + -AllowedSshCidr $allowedCidr ` + -SshPublicKey $publicKey ` + -Image $image ` + -WhatIf:$WhatIfPreference + + $hostName = Get-AzureDevHostName -Context $Context + if (-not $Context.SkipSshConfig) { + $sshConfigApplied = Set-AzureDevManagedSshConfig ` + -Context $Context ` + -HostName $hostName + if (-not $sshConfigApplied) { + throw 'Setup cannot continue until the managed SSH config is applied. Rerun setup with -Apply or -Yes.' + } + } else { + Write-Host (Get-AzureDevSshConfigBlock -Context $Context -HostName $hostName) + } + + if (-not $WhatIfPreference) { + Start-AzureDevAzureVm ` + -Context $Context + Wait-AzureDevSsh ` + -Context $Context ` + -HostName $hostName ` + -AllowedSshCidr $allowedCidr | Out-Null + Invoke-AzureDevBootstrap -Context $Context + + $validationStatus = 'skipped' + if (-not $Context.SkipSmokeValidation) { + Invoke-AzureDevSmokeValidation -Context $Context + $validationStatus = 'passed' + } + + Set-AzureDevSetupState ` + -Context $Context ` + -AllowedCidr $allowedCidr ` + -DeploymentResult $deployment ` + -ValidationStatus $validationStatus + Write-AzureDevLog ` + -Context $Context ` + -CommandName 'setup' ` + -ActionCategory 'setup' ` + -TargetName $Context.Config.VmName ` + -TargetType 'Microsoft.Compute/virtualMachines' ` + -Result 'success' + Write-AzureDevSshInstructions -Context $Context + } + } finally { + if (-not $WhatIfPreference) { + Remove-AzureDevLock -Context $Context -Force + } + } +} + +function Start-AzureDevEnvironment { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not $WhatIfPreference) { + New-AzureDevLock -Context $Context -CommandName 'start' + } + try { + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + Start-AzureDevAzureVm ` + -Context $Context ` + -WhatIf:$WhatIfPreference + if (-not $WhatIfPreference) { + $hostName = Get-AzureDevHostName -Context $Context + Set-AzureDevManagedSshConfig ` + -Context $Context ` + -HostName $hostName | Out-Null + Wait-AzureDevSsh ` + -Context $Context ` + -HostName $hostName | Out-Null + Write-AzureDevSshInstructions -Context $Context + Write-AzureDevLog ` + -Context $Context ` + -CommandName 'start' ` + -ActionCategory 'vm-lifecycle' ` + -TargetName $Context.Config.VmName ` + -TargetType 'Microsoft.Compute/virtualMachines' ` + -Result 'success' + } + } finally { + if (-not $WhatIfPreference) { + Remove-AzureDevLock -Context $Context -Force + } + } +} + +function Stop-AzureDevEnvironment { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not $WhatIfPreference) { + New-AzureDevLock -Context $Context -CommandName 'stop' + } + try { + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + Stop-AzureDevAzureVm ` + -Context $Context ` + -WhatIf:$WhatIfPreference + if (-not $WhatIfPreference) { + Write-AzureDevLog ` + -Context $Context ` + -CommandName 'stop' ` + -ActionCategory 'vm-lifecycle' ` + -TargetName $Context.Config.VmName ` + -TargetType 'Microsoft.Compute/virtualMachines' ` + -Result 'success' + } + } finally { + if (-not $WhatIfPreference) { + Remove-AzureDevLock -Context $Context -Force + } + } +} + +function Get-AzureDevStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + $state = Get-AzureDevState -Context $Context + $publicIp = Get-AzureDevPublicIpAddress -Config $Context.Config + $powerState = Get-AzureDevVmPowerState -Config $Context.Config + $allowedCidr = if ($null -ne $state) { + $state.lastKnownAllowedCidr + } else { + $Context.Config.AllowedSshCidr + } + $validation = Get-AzureDevValidationStatus -State $state + + Write-Host "Resource group: $($Context.Config.ResourceGroup)" + Write-Host "VM: $($Context.Config.VmName)" + Write-Host "Power state: $powerState" + Write-Host "Connectivity mode: $($Context.Config.ConnectivityMode)" + Write-Host "Public IP: $publicIp" + Write-Host "Allowed SSH CIDR: $allowedCidr" + Write-Host "SSH alias: $($Context.Config.SshHostAlias)" + Write-Host "Last validation: $validation" +} + +function Update-AzureDevCidr { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [string]$CidrOverride + ) + + if ($Context.Config.ConnectivityMode -ne 'public-ssh') { + throw 'update-cidr applies only to public-ssh connectivity mode.' + } + + if (-not $WhatIfPreference) { + New-AzureDevLock -Context $Context -CommandName 'update-cidr' + } + try { + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + $allowedCidr = Get-AzureDevAllowedSshCidr ` + -Config $Context.Config ` + -OverrideCidr $CidrOverride + Update-AzureDevNetworkSecurityGroupCidr ` + -Context $Context ` + -AllowedSshCidr $allowedCidr ` + -WhatIf:$WhatIfPreference + + $hostName = Get-AzureDevHostName -Context $Context + Set-AzureDevManagedSshConfig ` + -Context $Context ` + -HostName $hostName ` + -WhatIf:$WhatIfPreference | Out-Null + + if (-not $WhatIfPreference) { + $state = Get-AzureDevState -Context $Context + $stateHash = if ($null -ne $state) { + $state | ConvertTo-Json -Depth 20 | ConvertFrom-Json -AsHashtable + } else { + [ordered]@{} + } + $stateHash.lastKnownAllowedCidr = $allowedCidr + Set-AzureDevState -Context $Context -State $stateHash + Write-AzureDevLog ` + -Context $Context ` + -CommandName 'update-cidr' ` + -ActionCategory 'network' ` + -TargetName $Context.Config.NamePrefix ` + -TargetType 'Microsoft.Network/networkSecurityGroups' ` + -Result 'success' + } + } finally { + if (-not $WhatIfPreference) { + Remove-AzureDevLock -Context $Context -Force + } + } +} + +function Get-AzureDevSshConfig { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $hostName = Get-AzureDevHostName -Context $Context + if ($Context.Apply -or $Context.Yes) { + Set-AzureDevManagedSshConfig ` + -Context $Context ` + -HostName $hostName ` + -WhatIf:$WhatIfPreference | Out-Null + } else { + Write-Host (Get-AzureDevSshConfigBlock -Context $Context -HostName $hostName) + } +} + +function Remove-AzureDevEnvironment { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not $WhatIfPreference) { + New-AzureDevLock -Context $Context -CommandName 'remove' + } + try { + Test-AzureDevPrerequisites ` + -Context $Context ` + -WhatIf:$WhatIfPreference + $resources = Get-AzureDevManagedResourcesForDeletion ` + -Resources (Get-AzureDevManagedResources -Config $Context.Config) + Write-Host 'Managed Azure resources selected for deletion:' + foreach ($resource in @($resources)) { + Write-Host " $($resource.type) $($resource.name)" + } + Test-AzureDevApproval -Context $Context -Action 'Delete the managed Azure resources above.' | Out-Null + + $deleted = Remove-AzureDevManagedResources ` + -Context $Context ` + -WhatIf:$WhatIfPreference + if ($Context.Config.ConnectivityMode -eq 'tailscale') { + Write-Host 'Tailscale mode detected. Remove the VM device from the tailnet if teardown cannot do it automatically.' + } + + Remove-AzureDevManagedSshConfig -WhatIf:$WhatIfPreference + Remove-AzureDevLocalState ` + -Context $Context ` + -WhatIf:$WhatIfPreference + + if ($Context.CleanupKeys) { + foreach ($path in @($Context.Config.SshPrivateKeyPath, $Context.Config.SshPublicKeyPath)) { + if (Test-Path -LiteralPath $path -PathType Leaf) { + if ($PSCmdlet.ShouldProcess($path, 'Remove generated SSH key')) { + Write-Verbose "Removing generated SSH key at $path" + Remove-Item -LiteralPath $path -Force + } + } + } + } else { + Write-Host "SSH key files were preserved: $($Context.Config.SshPrivateKeyPath), $($Context.Config.SshPublicKeyPath)" + } + + if (-not $WhatIfPreference) { + Write-AzureDevLog ` + -Context $Context ` + -CommandName 'remove' ` + -ActionCategory 'teardown' ` + -TargetName $Context.Config.ResourceGroup ` + -TargetType 'resource-group-resources' ` + -Result "deleted $(@($deleted).Count) resources" + } + } finally { + if (-not $WhatIfPreference) { + Remove-AzureDevLock -Context $Context -Force + } + } +} + +function Invoke-AzureDevCommand { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$CommandName, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + Write-Verbose "Running Azure dev command '$CommandName'" + switch ($CommandName) { + 'estimate-cost' { Write-AzureDevCostEstimate -Context $Context } + 'setup' { Invoke-AzureDevSetup -Context $Context -CidrOverride $AllowedSshCidr } + 'start' { Start-AzureDevEnvironment -Context $Context } + 'stop' { Stop-AzureDevEnvironment -Context $Context } + 'status' { Get-AzureDevStatus -Context $Context } + 'update-cidr' { Update-AzureDevCidr -Context $Context -CidrOverride $AllowedSshCidr } + 'ssh-config' { Get-AzureDevSshConfig -Context $Context } + 'remove' { Remove-AzureDevEnvironment -Context $Context } + } +} + +$requireEnv = $Command -eq 'setup' +$allowMissingAzureScope = $Command -eq 'estimate-cost' +$config = Get-AzureDevConfig ` + -EnvironmentFile $EnvironmentFile ` + -RequireEnvironmentFile:$requireEnv ` + -AllowMissingAzureScope:$allowMissingAzureScope +$context = New-AzureDevContext ` + -Config $config ` + -Yes:$Yes ` + -AdoptResourceGroup:$AdoptResourceGroup ` + -ForceUnlock:$ForceUnlock ` + -Apply:$Apply ` + -CleanupLogs:$CleanupLogs ` + -CleanupKeys:$CleanupKeys ` + -SkipSshConfig:$SkipSshConfig ` + -SkipSmokeValidation:$SkipSmokeValidation + +Invoke-AzureDevCommand -CommandName $Command -Context $context diff --git a/scripts/azure-dev/AzureDev.Azure.psm1 b/scripts/azure-dev/AzureDev.Azure.psm1 new file mode 100644 index 00000000..b77ea8f1 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Azure.psm1 @@ -0,0 +1,817 @@ +Set-StrictMode -Version Latest + +function Invoke-AzCli { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [switch]$Json + ) + + $commandLine = Format-AzureDevCommand -FilePath 'az' -Arguments $Arguments + Write-Verbose "Running $commandLine" + + $stderrPath = [System.IO.Path]::GetTempFileName() + try { + $output = & az @Arguments 2> $stderrPath + $exitCode = $LASTEXITCODE + $stdoutText = $output | Out-String + $stderrText = if ((Get-Item -LiteralPath $stderrPath).Length -gt 0) { + Get-Content -LiteralPath $stderrPath -Raw + } else { + '' + } + + Write-Debug ( + "Output from $commandLine`:$([Environment]::NewLine)" + + "stdout:$([Environment]::NewLine)$stdoutText" + + "stderr:$([Environment]::NewLine)$stderrText" + ) + + $text = $stdoutText.Trim() + $errorText = "$stdoutText$stderrText".Trim() + if ($exitCode -ne 0) { + throw "$commandLine failed: $errorText" + } + if ($Json) { + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + try { + return $text | ConvertFrom-Json + } catch { + throw "$commandLine did not return valid JSON: $text" + } + } + return $text + } finally { + Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Test-AzureDevLocalTool { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [switch]$Optional + ) + + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $command -and -not $Optional) { + throw "Required local tool is missing: $Name" + } + return $null -ne $command +} + +function Test-AzureDevRuntime { + [CmdletBinding()] + param() + + if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion.Major -lt 7) { + throw 'Run scripts/azure-dev.ps1 with PowerShell 7+ under pwsh.' + } +} + +function Connect-AzureDevServicePrincipal { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + if ( + [string]::IsNullOrWhiteSpace($Config.TenantId) -or + [string]::IsNullOrWhiteSpace($Config.ClientId) -or + [string]::IsNullOrWhiteSpace($Config.ClientSecret) + ) { + return $false + } + + if (-not $PSCmdlet.ShouldProcess('Azure CLI', 'Log in with service principal')) { + return $false + } + + Invoke-AzCli -Arguments @( + 'login', + '--service-principal', + '--tenant', + $Config.TenantId, + '--username', + $Config.ClientId, + '--password', + $Config.ClientSecret, + '--output', + 'none' + ) | Out-Null + return $true +} + +function Get-AzureDevAccount { + [CmdletBinding()] + param() + + try { + return Invoke-AzCli -Arguments @('account', 'show', '--output', 'json') -Json + } catch { + return $null + } +} + +function Get-AzureDevVisibleSubscriptions { + [CmdletBinding()] + param() + + return Invoke-AzCli -Arguments @( + 'account', + 'list', + '--all', + '--output', + 'json' + ) -Json +} + +function Set-AzureDevSubscription { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + if ($PSCmdlet.ShouldProcess($Config.SubscriptionId, 'Select Azure subscription')) { + Invoke-AzCli -Arguments @( + 'account', + 'set', + '--subscription', + $Config.SubscriptionId + ) | Out-Null + } +} + +function Test-AzureDevSubscriptionVisible { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $subscriptions = Get-AzureDevVisibleSubscriptions + $matchingSubscription = @($subscriptions) | + Where-Object { $_.id -eq $Config.SubscriptionId } | + Select-Object -First 1 + + if ($null -ne $matchingSubscription) { + return $true + } + + $cloud = try { + Invoke-AzCli -Arguments @('cloud', 'show', '--query', 'name', '--output', 'tsv') + } catch { + 'unknown' + } + $visible = @($subscriptions) | + ForEach-Object { " $($_.name) $($_.id) tenant=$($_.tenantId) state=$($_.state)" } + + throw ( + "Azure subscription $($Config.SubscriptionId) is not visible to the current Azure CLI login in cloud $cloud. " + + "Run: az account list --all --output table. " + + "If the subscription belongs to another tenant, run: az login --tenant . " + + "If the Azure cloud is wrong, run: az cloud set --name AzureCloud. " + + "Visible subscriptions:`n$($visible -join [Environment]::NewLine)" + ) +} + +function Test-AzureDevPrerequisites { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + Test-AzureDevRuntime + foreach ($tool in @('az', 'ssh', 'ssh-keygen')) { + Test-AzureDevLocalTool -Name $tool | Out-Null + } + Test-AzureDevLocalTool -Name 'scp' | Out-Null + Test-AzureDevLocalTool -Name 'git' | Out-Null + Test-AzureDevLocalTool -Name 'code' -Optional | Out-Null + + $connected = Connect-AzureDevServicePrincipal ` + -Config $Context.Config ` + -WhatIf:$WhatIfPreference + if (-not $connected) { + $account = Get-AzureDevAccount + if ($null -eq $account) { + throw 'Azure CLI is not logged in. Run az login or set service-principal env vars.' + } + } + + Test-AzureDevSubscriptionVisible -Config $Context.Config | Out-Null + Test-AzureDevSkuAvailability -Config $Context.Config | Out-Null +} + +function Test-AzureDevSkuAvailability { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $sizes = @($Config.VmSize, $Config.FallbackVmSize) | Select-Object -Unique + foreach ($size in $sizes) { + $skus = Invoke-AzCli -Arguments @( + 'vm', + 'list-skus', + '--subscription', + $Config.SubscriptionId, + '--location', + $Config.Location, + '--size', + $size, + '--resource-type', + 'virtualMachines', + '--all', + '--output', + 'json' + ) -Json + if (@($skus).Count -eq 0) { + throw "VM SKU $size is not available in $($Config.Location)." + } + } + return $true +} + +function Get-AzureDevUbuntuImage { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $images = Invoke-AzCli -Arguments @( + 'vm', + 'image', + 'list', + '--subscription', + $Config.SubscriptionId, + '--publisher', + 'Canonical', + '--offer', + 'ubuntu-24_04-lts', + '--sku', + 'server', + '--location', + $Config.Location, + '--all', + '--output', + 'json' + ) -Json + + $latest = @($images) | + Where-Object { $_.version -and $_.version -ne 'latest' } | + Sort-Object -Property version | + Select-Object -Last 1 + + if ($null -eq $latest) { + return [pscustomobject]@{ + publisher = 'Canonical' + offer = 'ubuntu-24_04-lts' + sku = 'server' + version = 'latest' + urn = 'Canonical:ubuntu-24_04-lts:server:latest' + plan = $null + } + } + + $urn = "$($latest.publisher):$($latest.offer):$($latest.sku):$($latest.version)" + $details = Invoke-AzCli -Arguments @( + 'vm', + 'image', + 'show', + '--subscription', + $Config.SubscriptionId, + '--urn', + $urn, + '--location', + $Config.Location, + '--output', + 'json' + ) -Json + + $planProperty = $details.PSObject.Properties['plan'] + $plan = if ($null -ne $planProperty) { $planProperty.Value } else { $null } + + return [pscustomobject]@{ + publisher = $latest.publisher + offer = $latest.offer + sku = $latest.sku + version = $latest.version + urn = $urn + plan = $plan + } +} + +function Get-AzureDevResourceGroup { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + try { + return Invoke-AzCli -Arguments @( + 'group', + 'show', + '--subscription', + $Config.SubscriptionId, + '--name', + $Config.ResourceGroup, + '--output', + 'json' + ) -Json + } catch { + return $null + } +} + +function Test-AzureDevOwnershipTags { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$Resource, + + [Parameter(Mandatory = $true)] + [hashtable]$ExpectedTags + ) + + foreach ($key in @('managed-by', 'environment-id', 'repository', 'purpose')) { + $actual = if ($Resource.tags -is [System.Collections.IDictionary]) { + $Resource.tags[$key] + } else { + $property = $Resource.tags.PSObject.Properties[$key] + if ($null -ne $property) { $property.Value } else { $null } + } + if ($actual -ne $ExpectedTags[$key]) { + return $false + } + } + return $true +} + +function New-AzureDevResourceGroup { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $config = $Context.Config + $existing = Get-AzureDevResourceGroup -Config $config + $tagArgs = @() + foreach ($tag in $config.Tags.GetEnumerator()) { + $tagArgs += "$($tag.Key)=$($tag.Value)" + } + + if ($null -eq $existing) { + if ($PSCmdlet.ShouldProcess($config.ResourceGroup, 'Create Azure resource group')) { + $arguments = @( + 'group', + 'create', + '--subscription', + $config.SubscriptionId, + '--name', + $config.ResourceGroup, + '--location', + $config.Location, + '--tags' + ) + $tagArgs + @('--output', 'json') + Invoke-AzCli -Arguments $arguments -Json | Out-Null + } + return + } + + if (Test-AzureDevOwnershipTags -Resource $existing -ExpectedTags $config.Tags) { + return + } + + if (-not $Context.AdoptResourceGroup) { + $tagText = ($tagArgs -join ' ') + throw ( + "Resource group $($config.ResourceGroup) exists without expected tags. " + + "An owner can run: az group update --name $($config.ResourceGroup) --tags $tagText" + ) + } + + if ($PSCmdlet.ShouldProcess($config.ResourceGroup, 'Adopt resource group tags')) { + $arguments = @( + 'group', + 'update', + '--subscription', + $config.SubscriptionId, + '--name', + $config.ResourceGroup, + '--set' + ) + @( + "tags.managed-by=$($config.ManagedBy)", + "tags.environment-id=$($config.EnvironmentId)", + "tags.repository=$($config.Repository)", + "tags.purpose=$($config.Purpose)" + ) + @('--output', 'json') + Invoke-AzCli -Arguments $arguments -Json | Out-Null + } +} + +function Get-AzureDevExpectedResourceNames { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + return [ordered]@{ + virtualNetwork = "$($Config.NamePrefix)-vnet" + subnet = 'snet-dev' + networkSecurityGroup = "$($Config.NamePrefix)-nsg" + publicIpAddress = "$($Config.NamePrefix)-pip" + networkInterface = "$($Config.NamePrefix)-nic" + osDisk = "$($Config.VmName)-osdisk" + dataDisk = "$($Config.VmName)-data" + sshPublicKey = "$($Config.NamePrefix)-ssh-key" + autoShutdown = "shutdown-computevm-$($Config.VmName)" + } +} + +function Get-AzureDevDeploymentParameters { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$AllowedSshCidr, + + [Parameter(Mandatory = $true)] + [string]$SshPublicKey, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Image + ) + + $config = $Context.Config + return @( + "environmentId=$($config.EnvironmentId)", + "namePrefix=$($config.NamePrefix)", + "vmName=$($config.VmName)", + "vmSize=$($config.VmSize)", + "dataDiskGiB=$($config.DataDiskGiB)", + "adminUsername=vscode", + "sshPublicKey=$SshPublicKey", + "connectivityMode=$($config.ConnectivityMode)", + "allowedSshCidr=$AllowedSshCidr", + "autoStopEnabled=$($config.AutoStopEnabled.ToString().ToLowerInvariant())", + "autoStopTime=$($config.AutoStopTime)", + "autoStopTimeZone=$($config.AutoStopTimeZone)", + "imagePublisher=$($Image.publisher)", + "imageOffer=$($Image.offer)", + "imageSku=$($Image.sku)", + "imageVersion=$($Image.version)" + ) +} + +function New-AzureDevDeployment { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$AllowedSshCidr, + + [Parameter(Mandatory = $true)] + [string]$SshPublicKey, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Image, + + [switch]$Preview + ) + + $parameters = Get-AzureDevDeploymentParameters ` + -Context $Context ` + -AllowedSshCidr $AllowedSshCidr ` + -SshPublicKey $SshPublicKey ` + -Image $Image + + $baseArgs = @( + 'deployment', + 'group', + $(if ($Preview) { 'what-if' } else { 'create' }), + '--resource-group', + $Context.Config.ResourceGroup, + '--subscription', + $Context.Config.SubscriptionId, + '--template-file', + $Context.TemplatePath, + '--parameters' + ) + $parameters + + if ($Preview) { + return Invoke-AzCli -Arguments ($baseArgs + @('--output', 'table')) + } + + if ($PSCmdlet.ShouldProcess($Context.Config.ResourceGroup, 'Deploy Azure VM resources')) { + return Invoke-AzCli -Arguments ($baseArgs + @('--output', 'json')) -Json + } + return $null +} + +function Get-AzureDevPublicIpAddress { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $names = Get-AzureDevExpectedResourceNames -Config $Config + try { + return Invoke-AzCli -Arguments @( + 'network', + 'public-ip', + 'show', + '--subscription', + $Config.SubscriptionId, + '--resource-group', + $Config.ResourceGroup, + '--name', + $names.publicIpAddress, + '--query', + 'ipAddress', + '--output', + 'tsv' + ) + } catch { + return $null + } +} + +function Get-AzureDevVmPowerState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + try { + return Invoke-AzCli -Arguments @( + 'vm', + 'get-instance-view', + '--subscription', + $Config.SubscriptionId, + '--resource-group', + $Config.ResourceGroup, + '--name', + $Config.VmName, + '--query', + "instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus | [0]", + '--output', + 'tsv' + ) + } catch { + return 'Not found' + } +} + +function Get-AzureDevVmAdminSshPublicKeys { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + try { + $keys = Invoke-AzCli -Arguments @( + 'vm', + 'show', + '--subscription', + $Config.SubscriptionId, + '--resource-group', + $Config.ResourceGroup, + '--name', + $Config.VmName, + '--query', + 'osProfile.linuxConfiguration.ssh.publicKeys[].keyData', + '--output', + 'json' + ) -Json + } catch { + return @() + } + + return @($keys) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +} + +function Start-AzureDevAzureVm { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if ($PSCmdlet.ShouldProcess($Context.Config.VmName, 'Start Azure VM')) { + Invoke-AzCli -Arguments @( + 'vm', + 'start', + '--subscription', + $Context.Config.SubscriptionId, + '--resource-group', + $Context.Config.ResourceGroup, + '--name', + $Context.Config.VmName + ) | Out-Null + } +} + +function Stop-AzureDevAzureVm { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if ($PSCmdlet.ShouldProcess($Context.Config.VmName, 'Deallocate Azure VM')) { + Invoke-AzCli -Arguments @( + 'vm', + 'deallocate', + '--subscription', + $Context.Config.SubscriptionId, + '--resource-group', + $Context.Config.ResourceGroup, + '--name', + $Context.Config.VmName + ) | Out-Null + } +} + +function Update-AzureDevNetworkSecurityGroupCidr { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$AllowedSshCidr + ) + + $names = Get-AzureDevExpectedResourceNames -Config $Context.Config + if ($PSCmdlet.ShouldProcess($names.networkSecurityGroup, "Set SSH CIDR to $AllowedSshCidr")) { + Invoke-AzCli -Arguments @( + 'network', + 'nsg', + 'rule', + 'update', + '--subscription', + $Context.Config.SubscriptionId, + '--resource-group', + $Context.Config.ResourceGroup, + '--nsg-name', + $names.networkSecurityGroup, + '--name', + 'AllowSshFromOperator', + '--source-address-prefixes', + $AllowedSshCidr, + '--output', + 'json' + ) -Json | Out-Null + } +} + +function Get-AzureDevManagedResources { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomObject]$Config + ) + + try { + $resources = Invoke-AzCli -Arguments @( + 'resource', + 'list', + '--subscription', + $Config.SubscriptionId, + '--resource-group', + $Config.ResourceGroup, + '--output', + 'json' + ) -Json + } catch { + return @() + } + + return @($resources) | Where-Object { + $_.tags.'managed-by' -eq $Config.ManagedBy -and + $_.tags.'environment-id' -eq $Config.EnvironmentId -and + $_.tags.repository -eq $Config.Repository + } +} + +function Get-AzureDevManagedResourcesForDeletion { + [CmdletBinding()] + param( + [AllowNull()] + [object[]]$Resources + ) + + $order = @{ + 'Microsoft.DevTestLab/schedules' = 10 + 'Microsoft.Compute/virtualMachines' = 20 + 'Microsoft.Network/networkInterfaces' = 30 + 'Microsoft.Network/publicIPAddresses' = 40 + 'Microsoft.Network/virtualNetworks' = 50 + 'Microsoft.Network/networkSecurityGroups' = 60 + 'Microsoft.Compute/disks' = 70 + 'Microsoft.Compute/sshPublicKeys' = 80 + } + + return @($Resources) | Sort-Object { + if ($order.ContainsKey($_.type)) { $order[$_.type] } else { 90 } + }, name +} + +function Remove-AzureDevManagedResources { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $resources = Get-AzureDevManagedResources -Config $Context.Config + $sorted = Get-AzureDevManagedResourcesForDeletion -Resources $resources + + foreach ($resource in $sorted) { + if ($PSCmdlet.ShouldProcess($resource.id, 'Delete managed Azure resource')) { + try { + Invoke-AzCli -Arguments @( + 'resource', + 'delete', + '--subscription', + $Context.Config.SubscriptionId, + '--ids', + $resource.id + ) | Out-Null + } catch { + throw "Failed to delete $($resource.type) $($resource.name): $($_.Exception.Message)" + } + } + } + + return $sorted +} + +function Get-AzureDevDeploymentOutputs { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$DeploymentResult + ) + + if ($null -eq $DeploymentResult -or $null -eq $DeploymentResult.properties) { + return @{} + } + + $outputs = @{} + foreach ($property in $DeploymentResult.properties.outputs.PSObject.Properties) { + $outputs[$property.Name] = $property.Value.value + } + return $outputs +} + +Export-ModuleMember -Function ` + Connect-AzureDevServicePrincipal, ` + Get-AzureDevAccount, ` + Get-AzureDevDeploymentOutputs, ` + Get-AzureDevDeploymentParameters, ` + Get-AzureDevExpectedResourceNames, ` + Get-AzureDevManagedResources, ` + Get-AzureDevPublicIpAddress, ` + Get-AzureDevResourceGroup, ` + Get-AzureDevUbuntuImage, ` + Get-AzureDevVisibleSubscriptions, ` + Get-AzureDevVmAdminSshPublicKeys, ` + Get-AzureDevVmPowerState, ` + Invoke-AzCli, ` + New-AzureDevDeployment, ` + New-AzureDevResourceGroup, ` + Remove-AzureDevManagedResources, ` + Set-AzureDevSubscription, ` + Get-AzureDevManagedResourcesForDeletion, ` + Start-AzureDevAzureVm, ` + Stop-AzureDevAzureVm, ` + Test-AzureDevLocalTool, ` + Test-AzureDevOwnershipTags, ` + Test-AzureDevPrerequisites, ` + Test-AzureDevRuntime, ` + Test-AzureDevSubscriptionVisible, ` + Test-AzureDevSkuAvailability, ` + Update-AzureDevNetworkSecurityGroupCidr diff --git a/scripts/azure-dev/AzureDev.Bootstrap.psm1 b/scripts/azure-dev/AzureDev.Bootstrap.psm1 new file mode 100644 index 00000000..52e11376 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Bootstrap.psm1 @@ -0,0 +1,147 @@ +Set-StrictMode -Version Latest + +function Invoke-AzureDevRemoteCommand { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$Command, + + [string]$Description = 'Run remote command' + ) + + if ($PSCmdlet.ShouldProcess($Context.Config.SshHostAlias, $Description)) { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh' ` + -Arguments @( + '-o', + 'BatchMode=yes', + '-o', + 'ClearAllForwardings=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + $Context.Config.SshHostAlias, + $Command + ) + if ($result.ExitCode -ne 0) { + throw "Remote command failed: $Description`n$($result.Text.Trim())" + } + } +} + +function Copy-AzureDevBootstrapFile { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$RemotePath + ) + + if (-not (Test-Path -LiteralPath $Context.BootstrapPath -PathType Leaf)) { + throw "Bootstrap script is missing: $($Context.BootstrapPath)" + } + + if ($PSCmdlet.ShouldProcess($Context.Config.SshHostAlias, 'Upload host bootstrap')) { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'scp' ` + -Arguments @( + '-o', + 'BatchMode=yes', + '-o', + 'ClearAllForwardings=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + $Context.BootstrapPath, + "$($Context.Config.SshHostAlias):$RemotePath" + ) + if ($result.ExitCode -ne 0) { + throw "Bootstrap upload failed.`n$($result.Text.Trim())" + } + } +} + +function Copy-AzureDevQuadletFiles { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$RemotePath + ) + + $templatesPath = Split-Path -Parent $Context.BootstrapPath + $quadletPath = Join-Path $templatesPath 'quadlet' + if (-not (Test-Path -LiteralPath $quadletPath -PathType Container)) { + throw "Quadlet template directory is missing: $quadletPath" + } + + $quadletFiles = @(Get-ChildItem -LiteralPath $quadletPath -File | Sort-Object -Property Name) + if ($quadletFiles.Count -eq 0) { + throw "Quadlet template directory contains no files: $quadletPath" + } + + if ($PSCmdlet.ShouldProcess($Context.Config.SshHostAlias, 'Upload Quadlet templates')) { + Invoke-AzureDevRemoteCommand ` + -Context $Context ` + -Command "rm -rf $RemotePath && mkdir -p $RemotePath" ` + -Description 'Prepare Quadlet upload directory' + + $arguments = @( + '-o', + 'BatchMode=yes', + '-o', + 'ClearAllForwardings=yes', + '-o', + 'StrictHostKeyChecking=accept-new' + ) + $arguments += @($quadletFiles | ForEach-Object { $_.FullName }) + $arguments += "$($Context.Config.SshHostAlias):$RemotePath/" + + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'scp' ` + -Arguments $arguments + if ($result.ExitCode -ne 0) { + throw "Quadlet upload failed.`n$($result.Text.Trim())" + } + } +} + +function Invoke-AzureDevBootstrap { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $remoteBootstrapPath = '/tmp/krav-bootstrap-host.sh' + $remoteQuadletPath = '/tmp/krav-azure-dev/quadlet' + Copy-AzureDevBootstrapFile ` + -Context $Context ` + -RemotePath $remoteBootstrapPath + Copy-AzureDevQuadletFiles ` + -Context $Context ` + -RemotePath $remoteQuadletPath + + $command = @( + 'if command -v cloud-init >/dev/null 2>&1; then sudo cloud-init status --wait || true; fi' + "chmod 0755 $remoteBootstrapPath" + "sudo env AZURE_DEV_QUADLET_SOURCE=$remoteQuadletPath bash $remoteBootstrapPath" + ) -join ' && ' + if ($PSCmdlet.ShouldProcess($Context.Config.SshHostAlias, 'Run host bootstrap')) { + Invoke-AzureDevRemoteCommand ` + -Context $Context ` + -Command $command ` + -Description 'Run host bootstrap' + } +} + +Export-ModuleMember -Function ` + Copy-AzureDevBootstrapFile, ` + Copy-AzureDevQuadletFiles, ` + Invoke-AzureDevBootstrap, ` + Invoke-AzureDevRemoteCommand diff --git a/scripts/azure-dev/AzureDev.Config.psm1 b/scripts/azure-dev/AzureDev.Config.psm1 new file mode 100644 index 00000000..f33ffc08 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Config.psm1 @@ -0,0 +1,346 @@ +Set-StrictMode -Version Latest + +$script:AzureDevManagedBy = 'kravhantering-azure-dev' +$script:AzureDevRepository = 'viscalyx/Kravhantering' +$script:AzureDevPurpose = 'personal-development' +$script:AzureDevSetupVersion = 1 + +function Get-AzureDevDefaultConfig { + [CmdletBinding()] + param() + + return [ordered]@{ + AZURE_CLIENT_ID = '' + AZURE_CLIENT_SECRET = '' + AZURE_DEV_TAILSCALE_AUTH_KEY = '' + AZURE_DEV_TAILSCALE_TAILNET = '' + AZURE_DEV_VM_ENVIRONMENT_ID = 'personal' + AZURE_DEV_VM_NAME_PREFIX = 'krav-dev' + AZURE_DEV_VM_NAME = 'krav-dev-vm' + AZURE_DEV_VM_SIZE = 'Standard_D8s_v5' + AZURE_DEV_VM_FALLBACK_SIZE = 'Standard_D8as_v5' + AZURE_DEV_VM_DATA_DISK_GIB = '256' + AZURE_DEV_VM_CONNECTIVITY_MODE = 'public-ssh' + AZURE_DEV_VM_ALLOWED_SSH_CIDR = 'auto' + AZURE_DEV_VM_SSH_HOST_ALIAS = 'kravhantering-azure-dev' + AZURE_DEV_VM_SSH_PRIVATE_KEY_PATH = '~/.ssh/kravhantering_azure_dev_ed25519' + AZURE_DEV_VM_AUTO_STOP_ENABLED = 'true' + AZURE_DEV_VM_AUTO_STOP_TIME = '2200' + AZURE_DEV_VM_AUTO_STOP_TIME_ZONE = 'UTC' + AZURE_DEV_VM_LOCATION = 'eastus2' + AZURE_DEV_VM_RESOURCE_GROUP = '' + AZURE_DEV_VM_SUBSCRIPTION_ID = '' + AZURE_TENANT_ID = '' + } +} + +function Import-AzureDevEnvFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [switch]$Optional + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + if ($Optional) { + return @{} + } + throw "Required environment file is missing: $Path" + } + + $result = @{} + $lineNumber = 0 + foreach ($line in Get-Content -LiteralPath $Path) { + $lineNumber += 1 + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) { + continue + } + if ($trimmed.StartsWith('export ')) { + throw "$Path line $lineNumber uses export, which is not supported." + } + if ($trimmed -notmatch '^([A-Z][A-Z0-9_]*)=(.*)$') { + throw "$Path line $lineNumber is not valid KEY=value syntax." + } + + $key = $Matches[1] + $rawValue = $Matches[2].Trim() + if ($rawValue.Contains('$(') -or $rawValue.Contains('`')) { + throw "$Path line $lineNumber contains shell evaluation syntax." + } + if ( + ($rawValue.StartsWith('"') -and -not $rawValue.EndsWith('"')) -or + ($rawValue.StartsWith("'") -and -not $rawValue.EndsWith("'")) + ) { + throw "$Path line $lineNumber has an unterminated quoted value." + } + if ( + ($rawValue.StartsWith('"') -and $rawValue.EndsWith('"')) -or + ($rawValue.StartsWith("'") -and $rawValue.EndsWith("'")) + ) { + $rawValue = $rawValue.Substring(1, $rawValue.Length - 2) + } + if ($rawValue -match '\$\{?[A-Za-z_][A-Za-z0-9_]*\}?') { + throw "$Path line $lineNumber contains variable expansion syntax." + } + + $result[$key] = $rawValue + } + + return $result +} + +function ConvertTo-AzureDevBoolean { + [CmdletBinding()] + param( + [AllowNull()] + [string]$Value, + + [bool]$DefaultValue = $false + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $DefaultValue + } + + switch ($Value.Trim().ToLowerInvariant()) { + { $_ -in @('1', 'true', 'yes', 'y', 'on') } { return $true } + { $_ -in @('0', 'false', 'no', 'n', 'off') } { return $false } + default { throw "Invalid boolean value: $Value" } + } +} + +function Resolve-AzureDevPath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if ($Path -eq '~') { + return $HOME + } + if ($Path.StartsWith("~/") -or $Path.StartsWith("~\")) { + return (Join-Path $HOME $Path.Substring(2)) + } + return $Path +} + +function Get-AzureDevTags { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$EnvironmentId + ) + + return [ordered]@{ + 'managed-by' = $script:AzureDevManagedBy + 'environment-id' = $EnvironmentId + repository = $script:AzureDevRepository + purpose = $script:AzureDevPurpose + } +} + +function Get-AzureDevConfig { + [CmdletBinding()] + param( + [string]$EnvironmentFile = '.env.azure.development', + + [switch]$RequireEnvironmentFile, + + [switch]$AllowMissingAzureScope + ) + + $repoRoot = (Get-Location).Path + $primaryPath = Join-Path $repoRoot $EnvironmentFile + $localPath = Join-Path $repoRoot '.env.azure.development.local' + $values = Get-AzureDevDefaultConfig + + $primaryValues = Import-AzureDevEnvFile ` + -Path $primaryPath ` + -Optional:(!$RequireEnvironmentFile) + foreach ($key in $primaryValues.Keys) { + $values[$key] = $primaryValues[$key] + } + + $localValues = Import-AzureDevEnvFile -Path $localPath -Optional + foreach ($key in $localValues.Keys) { + $values[$key] = $localValues[$key] + } + + $trackedKeys = @( + @($values.Keys) + + @( + 'AZURE_DEV_VM_SUBSCRIPTION_ID', + 'AZURE_DEV_VM_RESOURCE_GROUP', + 'AZURE_TENANT_ID', + 'AZURE_CLIENT_ID', + 'AZURE_CLIENT_SECRET', + 'AZURE_DEV_TAILSCALE_AUTH_KEY', + 'AZURE_DEV_TAILSCALE_TAILNET' + ) + ) | Select-Object -Unique + + foreach ($key in $trackedKeys) { + $item = Get-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue + if ($null -ne $item) { + $values[$key] = $item.Value + } + } + + $privateKeyPath = Resolve-AzureDevPath ` + -Path $values.AZURE_DEV_VM_SSH_PRIVATE_KEY_PATH + $publicKeyPath = "$privateKeyPath.pub" + $environmentId = $values.AZURE_DEV_VM_ENVIRONMENT_ID + + $config = [pscustomobject]@{ + RepoRoot = $repoRoot + EnvironmentFilePath = $primaryPath + LocalEnvironmentFilePath = $localPath + SubscriptionId = $values.AZURE_DEV_VM_SUBSCRIPTION_ID + ResourceGroup = $values.AZURE_DEV_VM_RESOURCE_GROUP + Location = $values.AZURE_DEV_VM_LOCATION + EnvironmentId = $environmentId + NamePrefix = $values.AZURE_DEV_VM_NAME_PREFIX + VmName = $values.AZURE_DEV_VM_NAME + VmSize = $values.AZURE_DEV_VM_SIZE + FallbackVmSize = $values.AZURE_DEV_VM_FALLBACK_SIZE + DataDiskGiB = [int]$values.AZURE_DEV_VM_DATA_DISK_GIB + ConnectivityMode = $values.AZURE_DEV_VM_CONNECTIVITY_MODE + AllowedSshCidr = $values.AZURE_DEV_VM_ALLOWED_SSH_CIDR + SshHostAlias = $values.AZURE_DEV_VM_SSH_HOST_ALIAS + SshPrivateKeyPath = $privateKeyPath + SshPublicKeyPath = $publicKeyPath + AutoStopEnabled = ConvertTo-AzureDevBoolean ` + -Value $values.AZURE_DEV_VM_AUTO_STOP_ENABLED ` + -DefaultValue $true + AutoStopTime = $values.AZURE_DEV_VM_AUTO_STOP_TIME + AutoStopTimeZone = $values.AZURE_DEV_VM_AUTO_STOP_TIME_ZONE + Tags = Get-AzureDevTags -EnvironmentId $environmentId + ManagedBy = $script:AzureDevManagedBy + Repository = $script:AzureDevRepository + Purpose = $script:AzureDevPurpose + SetupVersion = $script:AzureDevSetupVersion + TenantId = $values.AZURE_TENANT_ID + ClientId = $values.AZURE_CLIENT_ID + ClientSecret = $values.AZURE_CLIENT_SECRET + TailscaleAuthKey = $values.AZURE_DEV_TAILSCALE_AUTH_KEY + TailscaleTailnet = $values.AZURE_DEV_TAILSCALE_TAILNET + } + + Test-AzureDevConfig ` + -Config $config ` + -AllowMissingAzureScope:$AllowMissingAzureScope + return $config +} + +function Test-AzureDevConfig { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + + [switch]$AllowMissingAzureScope + ) + + $required = if ($AllowMissingAzureScope) { + [ordered]@{ + AZURE_DEV_VM_LOCATION = $Config.Location + } + } else { + [ordered]@{ + AZURE_DEV_VM_SUBSCRIPTION_ID = $Config.SubscriptionId + AZURE_DEV_VM_RESOURCE_GROUP = $Config.ResourceGroup + AZURE_DEV_VM_LOCATION = $Config.Location + } + } + foreach ($item in $required.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace($item.Value)) { + throw "$($item.Key) is required." + } + } + + if ($Config.ConnectivityMode -notin @('public-ssh', 'tailscale')) { + throw 'AZURE_DEV_VM_CONNECTIVITY_MODE must be public-ssh or tailscale.' + } + if ($Config.DataDiskGiB -lt 64) { + throw 'AZURE_DEV_VM_DATA_DISK_GIB must be at least 64.' + } + if ($Config.VmSize -match 'D4|B[0-9]|Standard_DS?[0-9]_') { + Write-Warning ( + 'A smaller VM size may become memory-bound for the full development workload.' + ) + } + if ($Config.AutoStopTime -notmatch '^([01][0-9]|2[0-3])[0-5][0-9]$') { + throw 'AZURE_DEV_VM_AUTO_STOP_TIME must use HHmm format, for example 2200.' + } + + $servicePrincipalValues = @( + $Config.TenantId, + $Config.ClientId, + $Config.ClientSecret + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ( + $servicePrincipalValues.Count -gt 0 -and + $servicePrincipalValues.Count -lt 3 + ) { + throw ( + 'Set all service-principal values together: AZURE_TENANT_ID, ' + + 'AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET.' + ) + } +} + +function New-AzureDevContext { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + + [switch]$Yes, + + [switch]$AdoptResourceGroup, + + [switch]$ForceUnlock, + + [switch]$Apply, + + [switch]$CleanupLogs, + + [switch]$CleanupKeys, + + [switch]$SkipSshConfig, + + [switch]$SkipSmokeValidation + ) + + return [pscustomobject]@{ + Config = $Config + Yes = [bool]$Yes + AdoptResourceGroup = [bool]$AdoptResourceGroup + ForceUnlock = [bool]$ForceUnlock + Apply = [bool]$Apply + CleanupLogs = [bool]$CleanupLogs + CleanupKeys = [bool]$CleanupKeys + SkipSshConfig = [bool]$SkipSshConfig + SkipSmokeValidation = [bool]$SkipSmokeValidation + StateDirectory = Join-Path $Config.RepoRoot '.azure' + LogsDirectory = Join-Path $Config.RepoRoot '.azure/logs' + StatePath = Join-Path $Config.RepoRoot '.azure/development.state.json' + LockPath = Join-Path $Config.RepoRoot '.azure/development.lock' + TemplatePath = Join-Path $Config.RepoRoot 'scripts/azure-dev/templates/main.bicep' + BootstrapPath = Join-Path $Config.RepoRoot 'scripts/azure-dev/templates/bootstrap-host.sh' + } +} + +Export-ModuleMember -Function ` + ConvertTo-AzureDevBoolean, ` + Get-AzureDevConfig, ` + Get-AzureDevDefaultConfig, ` + Get-AzureDevTags, ` + Import-AzureDevEnvFile, ` + New-AzureDevContext, ` + Resolve-AzureDevPath, ` + Test-AzureDevConfig diff --git a/scripts/azure-dev/AzureDev.Logging.psm1 b/scripts/azure-dev/AzureDev.Logging.psm1 new file mode 100644 index 00000000..1273204c --- /dev/null +++ b/scripts/azure-dev/AzureDev.Logging.psm1 @@ -0,0 +1,330 @@ +Set-StrictMode -Version Latest + +function New-AzureDevDirectory { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path -LiteralPath $Path -PathType Container) { + return + } + if ($PSCmdlet.ShouldProcess($Path, 'Create directory')) { + Write-Verbose "Creating directory $Path" + New-Item -ItemType Directory -Path $Path -Force | Out-Null + } +} + +function Get-AzureDevState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not (Test-Path -LiteralPath $Context.StatePath -PathType Leaf)) { + return $null + } + + return Get-Content -LiteralPath $Context.StatePath -Raw | ConvertFrom-Json +} + +function Set-AzureDevState { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$State + ) + + if ($PSCmdlet.ShouldProcess($Context.StatePath, 'Write Azure dev state')) { + New-AzureDevDirectory -Path $Context.StateDirectory | Out-Null + $State.updatedAt = (Get-Date).ToUniversalTime().ToString('o') + $json = $State | ConvertTo-Json -Depth 20 + Write-Verbose "Writing Azure dev state to $($Context.StatePath)" + Set-Content -LiteralPath $Context.StatePath -Value $json -Encoding UTF8 + } +} + +function ConvertTo-AzureDevRedactedValue { + [CmdletBinding()] + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + $text = [string]$Value + if ( + $text -match '(?i)(secret|password|token|private|connection string)' -or + $text -match '(?i)(client_secret|auth_key)' -or + $text -match 'mssql://[^:]+:[^@]+@' + ) { + return '[redacted]' + } + return $Value +} + +function Format-AzureDevCommandArgument { + [CmdletBinding()] + param( + [AllowNull()] + [string]$Argument + ) + + if ($null -eq $Argument) { + return "''" + } + if ($Argument.Length -eq 0) { + return "''" + } + if ($Argument -notmatch '[\s"'']') { + return $Argument + } + + return '"' + ($Argument -replace '"', '\"') + '"' +} + +function Format-AzureDevCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [string[]]$Arguments = @() + ) + + $redactedArguments = New-Object System.Collections.Generic.List[string] + $redactNext = $false + foreach ($argument in $Arguments) { + if ($redactNext) { + $redactedArguments.Add('[redacted]') + $redactNext = $false + continue + } + + $displayArgument = if ( + $argument -match '(?i)^(password|clientSecret|client_secret|secret|token|authKey|auth_key)=' + ) { + ($argument -replace '=.*$', '=[redacted]') + } else { + $argument + } + + $redactedArguments.Add($displayArgument) + if ($argument -match '(?i)^--?(password|client-secret|secret|token|auth-key)$') { + $redactNext = $true + } + } + + return (@($FilePath) + @($redactedArguments | ForEach-Object { + Format-AzureDevCommandArgument -Argument $_ + })) -join ' ' +} + +function Invoke-AzureDevNativeCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [string[]]$Arguments = @() + ) + + $commandLine = Format-AzureDevCommand -FilePath $FilePath -Arguments $Arguments + Write-Verbose "Running $commandLine" + $result = & $FilePath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $text = $result | Out-String + Write-Debug "Output from $commandLine`:$([Environment]::NewLine)$text" + + return [pscustomobject]@{ + ExitCode = $exitCode + Output = $result + Text = $text + } +} + +function Write-AzureDevLog { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$CommandName, + + [Parameter(Mandatory = $true)] + [string]$ActionCategory, + + [string]$TargetResourceId, + + [string]$TargetName, + + [string]$TargetType, + + [Parameter(Mandatory = $true)] + [string]$Result, + + [string]$ErrorDetails + ) + + $path = Join-Path $Context.LogsDirectory ( + "$(Get-Date -Format 'yyyyMMdd').jsonl" + ) + if (-not $PSCmdlet.ShouldProcess($path, 'Append Azure dev log entry')) { + return + } + + New-AzureDevDirectory -Path $Context.LogsDirectory | Out-Null + $entry = [ordered]@{ + timestamp = (Get-Date).ToUniversalTime().ToString('o') + command = $CommandName + environmentId = $Context.Config.EnvironmentId + subscriptionId = $Context.Config.SubscriptionId + resourceGroup = $Context.Config.ResourceGroup + actionCategory = $ActionCategory + targetResourceId = ConvertTo-AzureDevRedactedValue $TargetResourceId + targetName = ConvertTo-AzureDevRedactedValue $TargetName + targetType = ConvertTo-AzureDevRedactedValue $TargetType + result = $Result + errorDetails = ConvertTo-AzureDevRedactedValue $ErrorDetails + } + Write-Verbose "Appending Azure dev log entry to $path" + Add-Content ` + -LiteralPath $path ` + -Value (($entry | ConvertTo-Json -Compress -Depth 10)) ` + -Encoding UTF8 +} + +function Test-AzureDevProcessActive { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [int]$ProcessId + ) + + return $null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) +} + +function Get-AzureDevLock { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not (Test-Path -LiteralPath $Context.LockPath -PathType Leaf)) { + return $null + } + + return Get-Content -LiteralPath $Context.LockPath -Raw | ConvertFrom-Json +} + +function New-AzureDevLock { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$CommandName + ) + + $existing = Get-AzureDevLock -Context $Context + if ($null -ne $existing) { + $processId = [int]$existing.processId + $isActive = Test-AzureDevProcessActive -ProcessId $processId + if ($Context.ForceUnlock -and -not $isActive) { + Remove-AzureDevLock -Context $Context -Force + } else { + $details = $existing | ConvertTo-Json -Compress + throw "Azure dev command is already locked: $details" + } + } + + if (-not $PSCmdlet.ShouldProcess($Context.LockPath, 'Create local lock')) { + return + } + + New-AzureDevDirectory -Path $Context.StateDirectory | Out-Null + $lock = [ordered]@{ + command = $CommandName + processId = $PID + host = [System.Net.Dns]::GetHostName() + user = [System.Environment]::UserName + environmentId = $Context.Config.EnvironmentId + startedAt = (Get-Date).ToUniversalTime().ToString('o') + } + Write-Verbose "Creating local lock at $($Context.LockPath)" + Set-Content ` + -LiteralPath $Context.LockPath ` + -Value ($lock | ConvertTo-Json -Depth 10) ` + -Encoding UTF8 +} + +function Remove-AzureDevLock { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [switch]$Force + ) + + if (-not (Test-Path -LiteralPath $Context.LockPath -PathType Leaf)) { + return + } + if ($PSCmdlet.ShouldProcess($Context.LockPath, 'Remove local lock')) { + Write-Verbose "Removing local lock at $($Context.LockPath)" + Remove-Item -LiteralPath $Context.LockPath -Force:$Force + } +} + +function Remove-AzureDevLocalState { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + foreach ($path in @($Context.StatePath, $Context.LockPath)) { + if (Test-Path -LiteralPath $path -PathType Leaf) { + if ($PSCmdlet.ShouldProcess($path, 'Remove local Azure dev artifact')) { + Write-Verbose "Removing local Azure dev artifact at $path" + Remove-Item -LiteralPath $path -Force + } + } + } + + if ( + $Context.CleanupLogs -and + (Test-Path -LiteralPath $Context.LogsDirectory -PathType Container) + ) { + if ($PSCmdlet.ShouldProcess($Context.LogsDirectory, 'Remove Azure dev logs')) { + Write-Verbose "Removing Azure dev logs at $($Context.LogsDirectory)" + Remove-Item -LiteralPath $Context.LogsDirectory -Recurse -Force + } + } +} + +Export-ModuleMember -Function ` + ConvertTo-AzureDevRedactedValue, ` + Format-AzureDevCommand, ` + Format-AzureDevCommandArgument, ` + Get-AzureDevLock, ` + Get-AzureDevState, ` + Invoke-AzureDevNativeCommand, ` + New-AzureDevDirectory, ` + New-AzureDevLock, ` + Remove-AzureDevLocalState, ` + Remove-AzureDevLock, ` + Set-AzureDevState, ` + Test-AzureDevProcessActive, ` + Write-AzureDevLog diff --git a/scripts/azure-dev/AzureDev.Podman.psm1 b/scripts/azure-dev/AzureDev.Podman.psm1 new file mode 100644 index 00000000..455fbb65 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Podman.psm1 @@ -0,0 +1,47 @@ +Set-StrictMode -Version Latest + +function Get-AzureDevQuadletUnitNames { + [CmdletBinding()] + param() + + return @( + 'krav-support.network', + 'krav-sqlserver.volume', + 'krav-hsa-mtls-certs.volume', + 'krav-db.container', + 'krav-idp.container', + 'krav-hsa-mtls-cert-generator.container', + 'krav-hsa-directory-mock.container', + 'krav-hsa-person-lookup-adapter.container', + 'krav-kong.container' + ) +} + +function Get-AzureDevSupportPorts { + [CmdletBinding()] + param() + + return [ordered]@{ + sqlServer = '127.0.0.1:1433' + keycloak = '127.0.0.1:8080' + kongProxy = '127.0.0.1:18000' + } +} + +function Test-AzureDevLoopbackBinding { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$PublishPort + ) + + if ($PublishPort -notmatch '^127\.0\.0\.1:\d+:\d+$') { + throw "Support-service ports must bind to loopback only: $PublishPort" + } + return $true +} + +Export-ModuleMember -Function ` + Get-AzureDevQuadletUnitNames, ` + Get-AzureDevSupportPorts, ` + Test-AzureDevLoopbackBinding diff --git a/scripts/azure-dev/AzureDev.Ssh.psm1 b/scripts/azure-dev/AzureDev.Ssh.psm1 new file mode 100644 index 00000000..7e847975 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Ssh.psm1 @@ -0,0 +1,435 @@ +Set-StrictMode -Version Latest + +$script:AzureDevWhatIfPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINHTPpE1LHuzHN/oirKpSYd7H/LfaLu0H1gp8VOcBt1y azure-dev-whatif-placeholder' +$script:AzureDevSshMarkerName = 'kravhantering-azure-dev' +$script:AzureDevForwardedPorts = @(3000, 3001, 4443, 1433, 8080, 18000, 9323, 51204) + +function Get-AzureDevCurrentIpv4 { + [CmdletBinding()] + param() + + try { + Write-Verbose 'Detecting current public IPv4 address' + $response = Invoke-RestMethod ` + -Uri 'https://api.ipify.org?format=json' ` + -TimeoutSec 15 + Write-Debug "Output from public IPv4 detection:$([Environment]::NewLine)$($response | Out-String)" + return $response.ip + } catch { + throw 'Could not auto-detect the current public IPv4 address.' + } +} + +function Test-AzureDevCidr { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Cidr + ) + + if ($Cidr -in @('0.0.0.0/0', '::/0')) { + throw 'Broad SSH source ranges are refused.' + } + if ($Cidr -notmatch '^(\d{1,3}\.){3}\d{1,3}/([1-9]|[12][0-9]|3[0-2])$') { + throw "SSH CIDR must be an IPv4 CIDR, for example 203.0.113.10/32: $Cidr" + } + + $ip = $Cidr.Split('/')[0] + foreach ($part in $ip.Split('.')) { + $value = [int]$part + if ($value -lt 0 -or $value -gt 255) { + throw "SSH CIDR contains an invalid IPv4 octet: $Cidr" + } + } + return $true +} + +function Get-AzureDevAllowedSshCidr { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config, + + [string]$OverrideCidr + ) + + $cidr = if (-not [string]::IsNullOrWhiteSpace($OverrideCidr)) { + $OverrideCidr + } else { + $Config.AllowedSshCidr + } + + if ($Config.ConnectivityMode -eq 'tailscale') { + return '10.0.0.0/32' + } + if ($cidr -eq 'auto') { + $cidr = "$(Get-AzureDevCurrentIpv4)/32" + } + if ($cidr -match '^(\d{1,3}\.){3}\d{1,3}$') { + $cidr = "$cidr/32" + } + + Test-AzureDevCidr -Cidr $cidr | Out-Null + return $cidr +} + +function Get-AzureDevPlaceholderPublicKey { + [CmdletBinding()] + param() + + return $script:AzureDevWhatIfPublicKey +} + +function New-AzureDevSshKey { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $privatePath = $Config.SshPrivateKeyPath + $publicPath = $Config.SshPublicKeyPath + if (Test-Path -LiteralPath $privatePath -PathType Leaf) { + if (-not (Test-Path -LiteralPath $publicPath -PathType Leaf)) { + if ($PSCmdlet.ShouldProcess($publicPath, 'Derive SSH public key')) { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh-keygen' ` + -Arguments @('-y', '-f', $privatePath) + if ($result.ExitCode -ne 0) { + throw "Could not derive public key from $privatePath`: $($result.Text.Trim())" + } + $publicKey = $result.Text.Trim() + Write-Verbose "Writing SSH public key to $publicPath" + Set-Content -LiteralPath $publicPath -Value $publicKey -Encoding ascii + } + } + return + } + + $directory = Split-Path -Parent $privatePath + if ($PSCmdlet.ShouldProcess($privatePath, 'Create dedicated ed25519 SSH key')) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + Write-Verbose "Creating SSH key directory $directory" + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh-keygen' ` + -Arguments @( + '-t', + 'ed25519', + '-f', + $privatePath, + '-N', + '', + '-C', + 'kravhantering azure dev' + ) + if ($result.ExitCode -ne 0) { + throw "Could not generate SSH key at $privatePath`: $($result.Text.Trim())" + } + } +} + +function Get-AzureDevSshPublicKey { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + if (-not (Test-Path -LiteralPath $Config.SshPublicKeyPath -PathType Leaf)) { + throw "SSH public key is missing: $($Config.SshPublicKeyPath)" + } + + return (Get-Content -LiteralPath $Config.SshPublicKeyPath -Raw).Trim() +} + +function ConvertTo-AzureDevSshPath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $homePath = [System.IO.Path]::GetFullPath($HOME) + $fullPath = [System.IO.Path]::GetFullPath($Path) + if ($fullPath.StartsWith($homePath)) { + $relative = $fullPath.Substring($homePath.Length).TrimStart('\', '/') + return "~/$($relative -replace '\\', '/')" + } + return $Path -replace '\\', '/' +} + +function Get-AzureDevSshConfigBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$HostName + ) + + $identityFile = ConvertTo-AzureDevSshPath -Path $Context.Config.SshPrivateKeyPath + $lines = @( + "# >>> $script:AzureDevSshMarkerName managed", + "Host $($Context.Config.SshHostAlias)", + " HostName $HostName", + ' User vscode', + " IdentityFile $identityFile", + ' IdentitiesOnly yes', + ' StrictHostKeyChecking accept-new' + ) + foreach ($port in $script:AzureDevForwardedPorts) { + $lines += " LocalForward 127.0.0.1:$port 127.0.0.1:$port" + } + $lines += "# <<< $script:AzureDevSshMarkerName managed" + return ($lines -join [Environment]::NewLine) +} + +function Get-AzureDevSshConfigPath { + [CmdletBinding()] + param() + + return Join-Path (Join-Path $HOME '.ssh') 'config' +} + +function Set-AzureDevManagedSshConfig { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$HostName + ) + + $block = Get-AzureDevSshConfigBlock -Context $Context -HostName $HostName + if (-not ($Context.Yes -or $Context.Apply)) { + Write-Host $block + Write-Host 'Pass -Yes or -Apply to update ~/.ssh/config.' + return $false + } + + $path = Get-AzureDevSshConfigPath + $directory = Split-Path -Parent $path + if ($PSCmdlet.ShouldProcess($path, 'Update managed OpenSSH config block')) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + Write-Verbose "Creating SSH config directory $directory" + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + $existing = if (Test-Path -LiteralPath $path -PathType Leaf) { + Get-Content -LiteralPath $path -Raw + } else { + '' + } + + $pattern = "(?ms)^# >>> $script:AzureDevSshMarkerName managed.*?^# <<< $script:AzureDevSshMarkerName managed\r?\n?" + if ($existing -match $pattern) { + $updated = [regex]::Replace($existing, $pattern, "$block`n") + } else { + $prefix = if ($existing.Trim().Length -gt 0) { + $existing.TrimEnd() + [Environment]::NewLine + [Environment]::NewLine + } else { + '' + } + $updated = $prefix + $block + [Environment]::NewLine + } + Write-Verbose "Writing managed OpenSSH config block to $path" + Set-Content -LiteralPath $path -Value $updated -Encoding UTF8 + } + return $true +} + +function Remove-AzureDevManagedSshConfig { + [CmdletBinding(SupportsShouldProcess = $true)] + param() + + $path = Get-AzureDevSshConfigPath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + return + } + + $existing = Get-Content -LiteralPath $path -Raw + $pattern = "(?ms)^# >>> $script:AzureDevSshMarkerName managed.*?^# <<< $script:AzureDevSshMarkerName managed\r?\n?" + if ($existing -notmatch $pattern) { + return + } + + if ($PSCmdlet.ShouldProcess($path, 'Remove managed OpenSSH config block')) { + $updated = [regex]::Replace($existing, $pattern, '').TrimEnd() + [Environment]::NewLine + Write-Verbose "Removing managed OpenSSH config block from $path" + Set-Content -LiteralPath $path -Value $updated -Encoding UTF8 + } +} + +function Test-AzureDevHostKeyMismatch { + [CmdletBinding()] + param( + [AllowNull()] + [string]$Output + ) + + if ([string]::IsNullOrWhiteSpace($Output)) { + return $false + } + + return $Output -match 'REMOTE HOST IDENTIFICATION HAS CHANGED' -or + $Output -match 'Host key verification failed' +} + +function Reset-AzureDevKnownHost { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$Host + ) + + if ([string]::IsNullOrWhiteSpace($Host)) { + return + } + + if ($PSCmdlet.ShouldProcess($Host, 'Remove stale OpenSSH known_hosts entry')) { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh-keygen' ` + -Arguments @('-R', $Host) + if ($result.ExitCode -ne 0) { + Write-Warning "Could not remove known_hosts entry for $Host`: $($result.Text.Trim())" + } + } +} + +function Wait-AzureDevSsh { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [string]$HostName, + + [string]$AllowedSshCidr, + + [int]$TimeoutSeconds = 300 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastOutput = '' + $knownHostReset = $false + do { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh' ` + -Arguments @( + '-o', + 'BatchMode=yes', + '-o', + 'ClearAllForwardings=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'StrictHostKeyChecking=accept-new', + $Context.Config.SshHostAlias, + 'true' + ) + if ($result.ExitCode -eq 0) { + return $true + } + $lastOutput = $result.Text + if (-not $knownHostReset -and (Test-AzureDevHostKeyMismatch -Output $lastOutput)) { + $hostEntries = @($Context.Config.SshHostAlias, $HostName) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + foreach ($hostEntry in $hostEntries) { + Reset-AzureDevKnownHost -Host $hostEntry + } + $knownHostReset = $true + continue + } + Start-Sleep -Seconds 10 + } while ((Get-Date) -lt $deadline) + + if (Test-AzureDevHostKeyMismatch -Output $lastOutput) { + $commands = @("ssh-keygen -R $($Context.Config.SshHostAlias)") + if (-not [string]::IsNullOrWhiteSpace($HostName)) { + $commands += "ssh-keygen -R $HostName" + } + throw "SSH host-key mismatch. Run: $($commands -join '; ')" + } + + $diagnostics = @( + "SSH did not become reachable for $($Context.Config.SshHostAlias)." + ) + if (-not [string]::IsNullOrWhiteSpace($HostName)) { + $diagnostics += "SSH host: $HostName" + } + + if (Get-Command Get-AzureDevVmPowerState -ErrorAction SilentlyContinue) { + $powerState = Get-AzureDevVmPowerState -Config $Context.Config + if (-not [string]::IsNullOrWhiteSpace($powerState)) { + $diagnostics += "VM power state: $powerState" + } + } + + if (Get-Command Get-AzureDevPublicIpAddress -ErrorAction SilentlyContinue) { + $publicIp = Get-AzureDevPublicIpAddress -Config $Context.Config + if (-not [string]::IsNullOrWhiteSpace($publicIp)) { + $diagnostics += "Azure public IP: $publicIp" + } + } + + $resolvedAllowedCidr = $AllowedSshCidr + if ([string]::IsNullOrWhiteSpace($resolvedAllowedCidr)) { + if (Get-Command Get-AzureDevState -ErrorAction SilentlyContinue) { + $state = Get-AzureDevState -Context $Context + $lastKnownAllowedCidr = if ($null -ne $state) { + $state.PSObject.Properties['lastKnownAllowedCidr'] + } else { + $null + } + if ( + $null -ne $lastKnownAllowedCidr -and + -not [string]::IsNullOrWhiteSpace([string]$lastKnownAllowedCidr.Value) + ) { + $resolvedAllowedCidr = [string]$lastKnownAllowedCidr.Value + } + } + } + if ([string]::IsNullOrWhiteSpace($resolvedAllowedCidr)) { + $resolvedAllowedCidr = $Context.Config.AllowedSshCidr + } + if (-not [string]::IsNullOrWhiteSpace($resolvedAllowedCidr)) { + $diagnostics += "Allowed SSH CIDR: $resolvedAllowedCidr" + } + + $diagnostics += "Last SSH output: $lastOutput" + throw ($diagnostics -join [Environment]::NewLine) +} + +function Get-AzureDevCodeCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (Get-Command code -ErrorAction SilentlyContinue) { + return "code --remote ssh-remote+$($Context.Config.SshHostAlias) /workspace" + } + return $null +} + +Export-ModuleMember -Function ` + ConvertTo-AzureDevSshPath, ` + Get-AzureDevAllowedSshCidr, ` + Get-AzureDevCodeCommand, ` + Get-AzureDevCurrentIpv4, ` + Get-AzureDevPlaceholderPublicKey, ` + Get-AzureDevSshConfigBlock, ` + Get-AzureDevSshConfigPath, ` + Get-AzureDevSshPublicKey, ` + New-AzureDevSshKey, ` + Remove-AzureDevManagedSshConfig, ` + Reset-AzureDevKnownHost, ` + Set-AzureDevManagedSshConfig, ` + Test-AzureDevHostKeyMismatch, ` + Test-AzureDevCidr, ` + Wait-AzureDevSsh diff --git a/scripts/azure-dev/AzureDev.Validation.psm1 b/scripts/azure-dev/AzureDev.Validation.psm1 new file mode 100644 index 00000000..6faba3c5 --- /dev/null +++ b/scripts/azure-dev/AzureDev.Validation.psm1 @@ -0,0 +1,291 @@ +Set-StrictMode -Version Latest + +function Invoke-AzureDevSmokeValidation { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $remoteScript = @' +set -euo pipefail +export HOME=/home/vscode +export XDG_CONFIG_HOME="${HOME}/.config" +export XDG_DATA_HOME="${HOME}/.local/share" +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" +export CONTAINERS_CONF="${HOME}/.config/containers/containers.conf" +export CONTAINERS_STORAGE_CONF="${HOME}/.config/containers/storage.conf" + +managed_units=( + krav-db.service + krav-idp.service + krav-kong.service + krav-hsa-directory-mock.service + krav-hsa-person-lookup-adapter.service +) +managed_containers=( + db + idp + kong + hsa-directory-mock + hsa-person-lookup-adapter +) + +dump_smoke_diagnostics() { + printf '\nAzure VM smoke validation diagnostics\n' + printf '\nListening TCP sockets:\n' + ss -ltn || true + printf '\nUser systemd environment:\n' + systemctl --user show-environment || true + printf '\nFailed user services:\n' + systemctl --user --failed --no-pager || true + + local unit + for unit in "${managed_units[@]}"; do + printf '\nService status for %s:\n' "${unit}" + systemctl --user status "${unit}" --no-pager || true + printf '\nService journal for %s:\n' "${unit}" + journalctl --user -u "${unit}" -n 80 --no-pager || true + done + + printf '\nPodman containers:\n' + podman ps -a || true + printf '\nPodman store:\n' + podman info --format 'GraphRoot={{.Store.GraphRoot}} RunRoot={{.Store.RunRoot}}' || true + printf '\nPodman networks:\n' + podman network ls || true + printf '\nPodman volumes:\n' + podman volume ls || true + + local container + for container in "${managed_containers[@]}"; do + printf '\nContainer logs for %s:\n' "${container}" + podman logs --tail 100 "${container}" || true + done +} + +require_loopback_port() { + local name="$1" + local port="$2" + local attempt + + for attempt in $(seq 1 24); do + if ss -ltn | grep -q "127.0.0.1:${port}"; then + return + fi + sleep 5 + done + + printf 'Missing loopback listener for %s on 127.0.0.1:%s\n' "${name}" "${port}" + dump_smoke_diagnostics + exit 1 +} + +wait_for_sql_server_login() { + local attempt + local wait_output + + wait_output="$(mktemp)" + for attempt in $(seq 1 8); do + printf 'Waiting for SQL Server login readiness, attempt %s/8...\n' "${attempt}" + if node scripts/db-sqlserver-admin.mjs wait >"${wait_output}" 2>&1; then + cat "${wait_output}" + rm -f "${wait_output}" + return + fi + + if [ "${attempt}" -lt 8 ]; then + sleep 10 + fi + done + + printf 'SQL Server did not become login-ready before database setup.\n' + printf '\nLast db:wait output:\n' + cat "${wait_output}" || true + rm -f "${wait_output}" + dump_smoke_diagnostics + exit 1 +} + +run_workspace_command_or_diagnose() { + local description="$1" + shift + + if "$@"; then + return + fi + + printf '%s failed.\n' "${description}" + dump_smoke_diagnostics + exit 1 +} + +python3 - <<'PY' +from pathlib import Path +import sys + +import yaml + +path = Path('/workspace/containers/kong/kong.yml') +data = yaml.safe_load(path.read_text(encoding='utf-8')) or {} +expected = {'http', 'https'} + +for service in data.get('services') or []: + for route in service.get('routes') or []: + if route.get('name') == 'hsa-directory-person-lookup-rest': + protocols = route.get('protocols') + if isinstance(protocols, list) and expected.issubset(set(protocols)): + sys.exit(0) + print('Kong HSA route must allow both http and https protocols') + sys.exit(1) + +print('Kong HSA route hsa-directory-person-lookup-rest not found') +sys.exit(1) +PY + +test -d /workspace +test "$(stat -c '%U' /workspace)" = "vscode" +test -d /workspace/.git +findmnt /mnt/krav-azure-dev-data >/dev/null +findmnt /workspace >/dev/null +findmnt /var/lib/krav-azure-dev >/dev/null +findmnt /home/vscode/.local/share/containers/storage >/dev/null +data_device_real="$(readlink -f /dev/disk/azure/scsi1/lun0)" +data_mount_source="$(findmnt -n -o SOURCE /mnt/krav-azure-dev-data)" +data_mount_real="$(readlink -f "${data_mount_source}")" +test "${data_mount_real}" = "${data_device_real}" +data_device_number="$(stat -c '%d' /mnt/krav-azure-dev-data)" +test "$(stat -c '%d' /workspace)" = "${data_device_number}" +test "$(stat -c '%d' /var/lib/krav-azure-dev)" = "${data_device_number}" +test "$(stat -c '%d' /home/vscode/.local/share/containers/storage)" = "${data_device_number}" +grep -Fq 'graphroot = "/home/vscode/.local/share/containers/storage"' /home/vscode/.config/containers/storage.conf +grep -Fq 'rootless_storage_path = "/home/vscode/.local/share/containers/storage"' /home/vscode/.config/containers/storage.conf +test "$(podman info --format '{{.Store.GraphRoot}}')" = "/home/vscode/.local/share/containers/storage" +podman network exists krav-support +node --version 2>/dev/null | grep -Eq '^v24\.' +npm --version >/dev/null 2>&1 +dotnet --version 2>/dev/null | grep -Eq '^8\.' +git --version >/dev/null 2>&1 +gh --version >/dev/null 2>&1 +docker --version >/dev/null 2>&1 +docker compose version >/dev/null 2>&1 +docker buildx version >/dev/null 2>&1 +podman --version >/dev/null 2>&1 +podman-compose --version >/dev/null 2>&1 +python3 --version >/dev/null 2>&1 +dotenv-linter --version >/dev/null 2>&1 +test -x /workspace/node_modules/.bin/playwright +/workspace/node_modules/.bin/playwright --version >/dev/null 2>&1 +loginctl show-user vscode -p Linger | grep -q 'Linger=yes' +check_user_service() { + local unit="$1" + local container="${2:-}" + if systemctl --user is-active --quiet "${unit}"; then + return + fi + + printf 'User service is not active: %s\n' "${unit}" + if [ -n "${container}" ]; then + printf '\nContainer logs for %s:\n' "${container}" + podman logs --tail 120 "${container}" || true + fi + dump_smoke_diagnostics + exit 1 +} +check_user_service krav-db.service db +check_user_service krav-idp.service idp +check_user_service krav-kong.service kong +check_user_service krav-hsa-directory-mock.service hsa-directory-mock +check_user_service krav-hsa-person-lookup-adapter.service hsa-person-lookup-adapter +require_loopback_port 'SQL Server' 1433 +require_loopback_port 'Keycloak' 8080 +require_loopback_port 'Kong HSA proxy' 18000 +if ! grep -Fq 'HSA_PERSON_LOOKUP_URL=http://127.0.0.1:18000/hsa/person-records/lookup' /workspace/.env.development.local; then + printf 'Missing managed HSA_PERSON_LOOKUP_URL in /workspace/.env.development.local\n' + dump_smoke_diagnostics + exit 1 +fi +hsa_response="$(mktemp)" +hsa_headers="$(mktemp)" +hsa_exit=1 +hsa_status=000 +hsa_succeeded=false +for attempt in $(seq 1 24); do + : > "${hsa_response}" + : > "${hsa_headers}" + set +e + hsa_status="$(curl -s -o "${hsa_response}" -D "${hsa_headers}" -w '%{http_code}' -X POST http://127.0.0.1:18000/hsa/person-records/lookup \ + -H 'content-type: application/json' \ + --data '{"hsaId":"SE5560000001-manualarea1"}')" + hsa_exit=$? + set -e + if [ "${hsa_exit}" -eq 0 ] && [ "${hsa_status}" = "200" ]; then + hsa_succeeded=true + break + fi + if [ "${attempt}" -lt 24 ]; then + sleep 5 + fi +done +if [ "${hsa_succeeded}" != "true" ]; then + printf 'HSA smoke request failed. curl exit: %s HTTP status: %s\n' "${hsa_exit}" "${hsa_status:-unknown}" + printf '\nResponse headers:\n' + cat "${hsa_headers}" || true + printf '\nResponse body:\n' + cat "${hsa_response}" || true + printf '\nKong health:\n' + podman exec kong kong health || true + printf '\nKong root HTTP probe:\n' + curl -sv http://127.0.0.1:18000/ >/dev/null || true + dump_smoke_diagnostics + rm -f "${hsa_response}" "${hsa_headers}" + exit 1 +fi +rm -f "${hsa_response}" "${hsa_headers}" +cd /workspace +wait_for_sql_server_login +run_workspace_command_or_diagnose 'SQL Server database setup' npm run db:setup +run_workspace_command_or_diagnose 'SQL Server health check' npm run db:health +run_workspace_command_or_diagnose 'Playwright dry-run install check' ./node_modules/.bin/playwright install --dry-run chromium +'@ + + $command = "bash -lc " + ( + "'" + $remoteScript.Replace("'", "'\''") + "'" + ) + + if ($PSCmdlet.ShouldProcess($Context.Config.SshHostAlias, 'Run smoke validation')) { + $result = Invoke-AzureDevNativeCommand ` + -FilePath 'ssh' ` + -Arguments @( + '-o', + 'BatchMode=yes', + '-o', + 'ClearAllForwardings=yes', + '-o', + 'StrictHostKeyChecking=accept-new', + $Context.Config.SshHostAlias, + $command + ) + if ($result.ExitCode -ne 0) { + throw "Azure VM smoke validation failed.`n$($result.Text.Trim())" + } + } +} + +function Get-AzureDevValidationStatus { + [CmdletBinding()] + param( + [AllowNull()] + [object]$State + ) + + if ($null -eq $State -or $null -eq $State.lastValidationStatus) { + return 'not-run' + } + return $State.lastValidationStatus +} + +Export-ModuleMember -Function ` + Get-AzureDevValidationStatus, ` + Invoke-AzureDevSmokeValidation diff --git a/scripts/azure-dev/templates/bootstrap-host.sh b/scripts/azure-dev/templates/bootstrap-host.sh new file mode 100644 index 00000000..b2356070 --- /dev/null +++ b/scripts/azure-dev/templates/bootstrap-host.sh @@ -0,0 +1,817 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${AZURE_DEV_REPO_URL:-https://github.com/viscalyx/Kravhantering.git}" +WORKSPACE_DIR="/workspace" +DATA_DEVICE="/dev/disk/azure/scsi1/lun0" +DATA_FSTYPE="ext4" +DATA_MOUNT_DIR="/mnt/krav-azure-dev-data" +DATA_WORKSPACE_DIR="${DATA_MOUNT_DIR}/workspace" +DATA_HOST_STATE_DIR="${DATA_MOUNT_DIR}/host-state" +VSCODE_USER="vscode" +VSCODE_HOME="/home/${VSCODE_USER}" +DATA_PODMAN_STORAGE_DIR="${DATA_MOUNT_DIR}/home/${VSCODE_USER}/.local/share/containers/storage" +PODMAN_STORAGE_DIR="${VSCODE_HOME}/.local/share/containers/storage" +SYSTEMD_USER_DIR="${VSCODE_HOME}/.config/containers/systemd" +KRAV_CONFIG_DIR="${VSCODE_HOME}/.config/krav-dev" +HOST_STATE_DIR="/var/lib/krav-azure-dev" +QUADLET_SOURCE_DIR="${AZURE_DEV_QUADLET_SOURCE:-${WORKSPACE_DIR}/scripts/azure-dev/templates/quadlet}" + +log() { + printf '[krav-azure-bootstrap] %s\n' "$*" +} + +run_as_vscode() { + runuser -u "${VSCODE_USER}" -- env \ + HOME="${VSCODE_HOME}" \ + XDG_CONFIG_HOME="${VSCODE_HOME}/.config" \ + XDG_DATA_HOME="${VSCODE_HOME}/.local/share" \ + CONTAINERS_CONF="${VSCODE_HOME}/.config/containers/containers.conf" \ + CONTAINERS_STORAGE_CONF="${VSCODE_HOME}/.config/containers/storage.conf" \ + bash -lc "$*" +} + +ensure_vscode_user() { + if ! id "${VSCODE_USER}" >/dev/null 2>&1; then + useradd --create-home --shell /bin/zsh "${VSCODE_USER}" + fi + usermod --shell /bin/zsh "${VSCODE_USER}" + usermod --append --groups sudo,docker "${VSCODE_USER}" || true + install -d -m 0755 /etc/sudoers.d + printf '%s ALL=(ALL) NOPASSWD:ALL\n' "${VSCODE_USER}" \ + > /etc/sudoers.d/90-krav-vscode + chmod 0440 /etc/sudoers.d/90-krav-vscode +} + +configure_repositories() { + install -d -m 0755 /etc/apt/keyrings + rm -f /tmp/packages-microsoft-prod.deb + + if [ ! -f /etc/apt/sources.list.d/nodesource.list ]; then + curl -fsSL https://deb.nodesource.com/setup_24.x | bash - + fi + + if [ ! -f /etc/apt/sources.list.d/docker.list ]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + printf \ + 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable\n' \ + "$(dpkg --print-architecture)" \ + > /etc/apt/sources.list.d/docker.list + fi + + if [ ! -f /etc/apt/sources.list.d/github-cli.list ]; then + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg + chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + printf \ + 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\n' \ + "$(dpkg --print-architecture)" \ + > /etc/apt/sources.list.d/github-cli.list + fi +} + +install_host_packages() { + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg + + configure_repositories + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + aardvark-dns \ + build-essential \ + dbus-user-session \ + docker-buildx-plugin \ + docker-ce \ + docker-ce-cli \ + docker-compose-plugin \ + dotnet-sdk-8.0 \ + fuse-overlayfs \ + gh \ + git \ + jq \ + libnss3-tools \ + lsb-release \ + mkcert \ + netavark \ + nodejs \ + podman \ + podman-compose \ + python-is-python3 \ + python3 \ + python3-pip \ + python3-venv \ + python3-yaml \ + ripgrep \ + slirp4netns \ + socat \ + sudo \ + uidmap \ + unzip \ + wget \ + zsh + + npm install --global npm@latest + + if ! command -v dotenv-linter >/dev/null 2>&1; then + curl -sSfL https://raw.githubusercontent.com/dotenv-linter/dotenv-linter/master/install.sh \ + | sh -s -- -b /usr/local/bin + fi +} + +mount_data_disk() { + install -d -m 0755 \ + "${DATA_MOUNT_DIR}" \ + "${WORKSPACE_DIR}" \ + "${HOST_STATE_DIR}" \ + "${PODMAN_STORAGE_DIR}" + if [ ! -e "${DATA_DEVICE}" ]; then + log "Azure data disk not found at ${DATA_DEVICE}; cannot place ${WORKSPACE_DIR} and Podman storage on the data disk" + return 1 + fi + + if ! blkid "${DATA_DEVICE}" >/dev/null 2>&1; then + mkfs.ext4 -F "${DATA_DEVICE}" + fi + + local uuid + uuid="$(blkid -s UUID -o value "${DATA_DEVICE}")" + + local fstab_tmp + fstab_tmp="$(mktemp)" + awk \ + -v uuid="${uuid}" \ + -v data_mount="${DATA_MOUNT_DIR}" \ + -v workspace="${WORKSPACE_DIR}" \ + -v host_state="${HOST_STATE_DIR}" \ + -v podman_storage="${PODMAN_STORAGE_DIR}" \ + '$1 == "UUID=" uuid { next } + $2 == data_mount { next } + $2 == workspace { next } + $2 == host_state { next } + $2 == podman_storage { next } + { print }' \ + /etc/fstab > "${fstab_tmp}" + cat "${fstab_tmp}" > /etc/fstab + rm -f "${fstab_tmp}" + printf 'UUID=%s %s %s defaults,nofail 0 2\n' \ + "${uuid}" \ + "${DATA_MOUNT_DIR}" \ + "${DATA_FSTYPE}" \ + >> /etc/fstab + printf '%s %s none bind,nofail,x-systemd.requires-mounts-for=%s 0 0\n' \ + "${DATA_WORKSPACE_DIR}" \ + "${WORKSPACE_DIR}" \ + "${DATA_MOUNT_DIR}" \ + >> /etc/fstab + printf '%s %s none bind,nofail,x-systemd.requires-mounts-for=%s 0 0\n' \ + "${DATA_HOST_STATE_DIR}" \ + "${HOST_STATE_DIR}" \ + "${DATA_MOUNT_DIR}" \ + >> /etc/fstab + printf '%s %s none bind,nofail,x-systemd.requires-mounts-for=%s 0 0\n' \ + "${DATA_PODMAN_STORAGE_DIR}" \ + "${PODMAN_STORAGE_DIR}" \ + "${DATA_MOUNT_DIR}" \ + >> /etc/fstab + + if ! findmnt "${DATA_MOUNT_DIR}" >/dev/null 2>&1; then + mount "${DATA_MOUNT_DIR}" + fi + + install -d -o "${VSCODE_USER}" -g "${VSCODE_USER}" \ + "${DATA_WORKSPACE_DIR}" \ + "${DATA_HOST_STATE_DIR}" \ + "${DATA_PODMAN_STORAGE_DIR}" \ + "${PODMAN_STORAGE_DIR}" + + if ! findmnt "${WORKSPACE_DIR}" >/dev/null 2>&1; then + mount "${WORKSPACE_DIR}" + fi + + if ! findmnt "${HOST_STATE_DIR}" >/dev/null 2>&1; then + mount "${HOST_STATE_DIR}" + fi + + if ! findmnt "${PODMAN_STORAGE_DIR}" >/dev/null 2>&1; then + mount "${PODMAN_STORAGE_DIR}" + fi + + rm -rf \ + "${DATA_MOUNT_DIR}/lost+found" \ + "${HOST_STATE_DIR}/lost+found" \ + "${WORKSPACE_DIR}/lost+found" \ + "${PODMAN_STORAGE_DIR}/lost+found" + chown "${VSCODE_USER}:${VSCODE_USER}" \ + "${DATA_WORKSPACE_DIR}" \ + "${DATA_HOST_STATE_DIR}" \ + "${DATA_PODMAN_STORAGE_DIR}" \ + "${WORKSPACE_DIR}" \ + "${HOST_STATE_DIR}" \ + "${PODMAN_STORAGE_DIR}" +} + +configure_podman_storage() { + install -d -o "${VSCODE_USER}" -g "${VSCODE_USER}" \ + "${VSCODE_HOME}/.config/containers" \ + "${VSCODE_HOME}/.local/share" \ + "${PODMAN_STORAGE_DIR}" + + cat > "${VSCODE_HOME}/.config/containers/storage.conf" < "${VSCODE_HOME}/.config/containers/containers.conf" <<'EOF' +[network] +network_backend = "netavark" +EOF + + chown -R "${VSCODE_USER}:${VSCODE_USER}" "${VSCODE_HOME}/.config/containers" + chown -R "${VSCODE_USER}:${VSCODE_USER}" "${PODMAN_STORAGE_DIR}" +} + +stop_user_quadlet_services_before_storage_change() { + local uid + uid="$(id -u "${VSCODE_USER}" 2>/dev/null || true)" + if [ -z "${uid}" ] || [ ! -d "/run/user/${uid}" ]; then + return + fi + + runuser -u "${VSCODE_USER}" -- env \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + systemctl --user stop \ + krav-kong.service \ + krav-hsa-person-lookup-adapter.service \ + krav-hsa-directory-mock.service \ + krav-db.service \ + krav-idp.service \ + krav-hsa-mtls-cert-generator.service \ + krav-support-network.service || true +} + +clone_or_update_repo() { + if [ -d "${WORKSPACE_DIR}/.git" ]; then + run_as_vscode "git -C '${WORKSPACE_DIR}' remote set-url origin '${REPO_URL}'" + else + rm -rf "${WORKSPACE_DIR}/lost+found" + if find "${WORKSPACE_DIR}" \ + -mindepth 1 \ + -maxdepth 1 \ + -print \ + -quit | grep -q .; then + log "${WORKSPACE_DIR} exists without .git and contains unmanaged files" + find "${WORKSPACE_DIR}" -mindepth 1 -maxdepth 1 -print + return 1 + fi + + local clone_dir + clone_dir="$(mktemp -d /tmp/krav-bootstrap-repo.XXXXXX)" + chown "${VSCODE_USER}:${VSCODE_USER}" "${clone_dir}" + run_as_vscode "git clone '${REPO_URL}' '${clone_dir}'" + run_as_vscode "cd '${clone_dir}' && tar cf - . | tar -C '${WORKSPACE_DIR}' -xf -" + rm -rf "${clone_dir}" + fi + git config --system --add safe.directory "${WORKSPACE_DIR}" || true + chown -R "${VSCODE_USER}:${VSCODE_USER}" "${WORKSPACE_DIR}" +} + +ensure_kong_route_protocols() { + local kong_config="${WORKSPACE_DIR}/containers/kong/kong.yml" + if [ ! -f "${kong_config}" ]; then + log "Kong config is missing: ${kong_config}" + return 1 + fi + + runuser -u "${VSCODE_USER}" -- env \ + HOME="${VSCODE_HOME}" \ + python3 - "${kong_config}" <<'PY' +from pathlib import Path +import sys + +import yaml + +path = Path(sys.argv[1]) +data = yaml.safe_load(path.read_text(encoding='utf-8')) or {} +expected = {'http', 'https'} +changed = False +found = False + +for service in data.get('services') or []: + for route in service.get('routes') or []: + if route.get('name') != 'hsa-directory-person-lookup-rest': + continue + found = True + protocols = route.get('protocols') + if not isinstance(protocols, list) or not expected.issubset(set(protocols)): + route['protocols'] = ['http', 'https'] + changed = True + +if not found: + raise SystemExit('Kong route hsa-directory-person-lookup-rest not found') + +if changed: + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding='utf-8') + print('updated Kong route protocols in /workspace/containers/kong/kong.yml') +else: + print('Kong route protocols already configured') +PY +} + +install_zsh_profile() { + if [ ! -d "${VSCODE_HOME}/.oh-my-zsh" ]; then + run_as_vscode \ + "RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\"" + fi + + run_as_vscode "mkdir -p '${VSCODE_HOME}/.oh-my-zsh/custom/plugins'" + if [ ! -d "${VSCODE_HOME}/.oh-my-zsh/custom/plugins/zsh-autosuggestions" ]; then + run_as_vscode \ + "git clone https://github.com/zsh-users/zsh-autosuggestions.git '${VSCODE_HOME}/.oh-my-zsh/custom/plugins/zsh-autosuggestions'" + fi + if [ ! -d "${VSCODE_HOME}/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting" ]; then + run_as_vscode \ + "git clone https://github.com/zsh-users/zsh-syntax-highlighting.git '${VSCODE_HOME}/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting'" + fi + + if ! grep -q 'zsh-autosuggestions' "${VSCODE_HOME}/.zshrc" 2>/dev/null; then + cat >> "${VSCODE_HOME}/.zshrc" <<'EOF' +plugins=(git zsh-autosuggestions zsh-syntax-highlighting) +EOF + fi + chown "${VSCODE_USER}:${VSCODE_USER}" "${VSCODE_HOME}/.zshrc" +} + +configure_codex_home() { + install -d -o "${VSCODE_USER}" -g "${VSCODE_USER}" \ + "${VSCODE_HOME}/.codex" \ + "${VSCODE_HOME}/.codex/sqlite" \ + "${VSCODE_HOME}/.codex/tmp" + + if [ ! -f "${VSCODE_HOME}/.codex/config.toml" ] && + [ -f "${WORKSPACE_DIR}/.devcontainer/codex-config.toml" ]; then + install -o "${VSCODE_USER}" -g "${VSCODE_USER}" -m 0644 \ + "${WORKSPACE_DIR}/.devcontainer/codex-config.toml" \ + "${VSCODE_HOME}/.codex/config.toml" + fi +} + +link_playwright_chrome() { + local browser_root="${PLAYWRIGHT_BROWSERS_PATH:-${VSCODE_HOME}/.cache/ms-playwright}" + local chrome_bin="" + if [ -d "${browser_root}" ]; then + chrome_bin="$( + find "${browser_root}" \ + \( -path '*/chrome-linux/chrome' -o -path '*/chrome-linux64/chrome' \) \ + -type f 2>/dev/null | + sort -V | + tail -n 1 + )" + fi + + if [ -z "${chrome_bin}" ]; then + local candidate candidate_path resolved_path + for candidate in chromium-browser chromium google-chrome chrome; do + if candidate_path="$(command -v "${candidate}" 2>/dev/null)"; then + resolved_path="$(readlink -f "${candidate_path}" 2>/dev/null || printf '%s' "${candidate_path}")" + if [ -x "${resolved_path}" ] && [ "${resolved_path}" != "/opt/google/chrome/chrome" ]; then + chrome_bin="${resolved_path}" + break + fi + fi + done + fi + + if [ -z "${chrome_bin}" ]; then + log "could not find a Playwright or system Chromium executable" + return 1 + fi + + install -d /opt/google/chrome + ln -sf "${chrome_bin}" /opt/google/chrome/chrome + log "linked /opt/google/chrome/chrome -> ${chrome_bin}" + /opt/google/chrome/chrome --version +} + +run_repository_setup() { + run_as_vscode "cd '${WORKSPACE_DIR}' && npm install" + run_as_vscode "cd '${WORKSPACE_DIR}' && dotnet tool restore" + run_as_vscode "cd '${WORKSPACE_DIR}' && npx playwright install --with-deps" + link_playwright_chrome + run_as_vscode "cd '${WORKSPACE_DIR}' && bash .devcontainer/trust-container-ca.sh" +} + +write_vm_env_override() { + local file="${WORKSPACE_DIR}/.env.development.local" + local start="# >>> kravhantering azure vm managed" + local end="# <<< kravhantering azure vm managed" + local tmp + tmp="$(mktemp)" + + if [ -f "${file}" ]; then + awk -v start="${start}" -v end="${end}" ' + $0 == start { skip = 1; next } + $0 == end { skip = 0; next } + skip != 1 { print } + ' "${file}" > "${tmp}" + fi + + { + printf '%s\n' "${start}" + printf 'HSA_PERSON_LOOKUP_URL=http://127.0.0.1:18000/hsa/person-records/lookup\n' + printf '%s\n' "${end}" + } >> "${tmp}" + + install -o "${VSCODE_USER}" -g "${VSCODE_USER}" -m 0600 "${tmp}" "${file}" + rm -f "${tmp}" +} + +install_quadlet_units() { + install -d -o "${VSCODE_USER}" -g "${VSCODE_USER}" \ + "${SYSTEMD_USER_DIR}" \ + "${KRAV_CONFIG_DIR}" + + if [ ! -d "${QUADLET_SOURCE_DIR}" ]; then + log "Quadlet template directory not found: ${QUADLET_SOURCE_DIR}" + return 1 + fi + + local quadlet_files=("${QUADLET_SOURCE_DIR}"/*) + if [ ! -e "${quadlet_files[0]}" ]; then + log "Quadlet template directory contains no files: ${QUADLET_SOURCE_DIR}" + return 1 + fi + + install -o "${VSCODE_USER}" -g "${VSCODE_USER}" -m 0644 \ + "${quadlet_files[@]}" \ + "${SYSTEMD_USER_DIR}/" + + if [ ! -f "${KRAV_CONFIG_DIR}/sqlserver.env" ]; then + cat > "${KRAV_CONFIG_DIR}/sqlserver.env" <<'EOF' +ACCEPT_EULA=Y +MSSQL_PID=Developer +MSSQL_SA_PASSWORD=YourStrong!Passw0rd +EOF + fi + if [ ! -f "${KRAV_CONFIG_DIR}/keycloak.env" ]; then + cat > "${KRAV_CONFIG_DIR}/keycloak.env" <<'EOF' +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin +EOF + fi + chown -R "${VSCODE_USER}:${VSCODE_USER}" "${KRAV_CONFIG_DIR}" + chmod 0600 "${KRAV_CONFIG_DIR}"/*.env +} + +build_hsa_images() { + run_as_vscode \ + "cd '${WORKSPACE_DIR}' && podman build --tag localhost/kravhantering/hsa-person-lookup-adapter:local --file containers/hsa-person-lookup-adapter/Dockerfile containers/hsa-person-lookup-adapter" + run_as_vscode \ + "cd '${WORKSPACE_DIR}' && podman build --tag localhost/kravhantering/hsa-directory-mock:local --file containers/hsa-directory-mock/Dockerfile containers/hsa-directory-mock" +} + +run_user_systemctl() { + local uid="$1" + shift + runuser -u "${VSCODE_USER}" -- env \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + systemctl --user "$@" +} + +run_user_podman() { + local uid="$1" + shift + runuser -u "${VSCODE_USER}" -- env \ + HOME="${VSCODE_HOME}" \ + XDG_CONFIG_HOME="${VSCODE_HOME}/.config" \ + XDG_DATA_HOME="${VSCODE_HOME}/.local/share" \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + CONTAINERS_CONF="${VSCODE_HOME}/.config/containers/containers.conf" \ + CONTAINERS_STORAGE_CONF="${VSCODE_HOME}/.config/containers/storage.conf" \ + podman "$@" +} + +configure_user_systemd_environment() { + local uid="$1" + + run_user_systemctl "${uid}" set-environment \ + "HOME=${VSCODE_HOME}" \ + "XDG_CONFIG_HOME=${VSCODE_HOME}/.config" \ + "XDG_DATA_HOME=${VSCODE_HOME}/.local/share" \ + "XDG_RUNTIME_DIR=/run/user/${uid}" \ + "CONTAINERS_CONF=${VSCODE_HOME}/.config/containers/containers.conf" \ + "CONTAINERS_STORAGE_CONF=${VSCODE_HOME}/.config/containers/storage.conf" +} + +prepare_user_podman_runtime() { + local uid="$1" + + install -d -o "${VSCODE_USER}" -g "${VSCODE_USER}" -m 0700 \ + "/tmp/containers-user-${uid}" \ + "/tmp/containers-user-${uid}/containers" \ + "/run/user/${uid}/containers" \ + "/run/user/${uid}/netns" \ + "/run/user/${uid}/libpod" \ + "/run/user/${uid}/libpod/tmp" + + if ! run_user_podman "${uid}" system migrate; then + log "failed to reset rootless Podman runtime state" + dump_support_stack_diagnostics + return 1 + fi +} + +run_user_systemctl_or_diagnose() { + local uid="$1" + local description="$2" + shift 2 + + local action="$1" + shift + local units=("$@") + + local command_failed=0 + if ! run_user_systemctl "${uid}" "${action}" "${units[@]}"; then + command_failed=1 + fi + + if [ "${action}" = "start" ] || [ "${action}" = "restart" ]; then + if wait_user_units_active "${uid}" "${description}" "${units[@]}"; then + return + fi + elif [ "${command_failed}" -eq 0 ]; then + return + fi + + log "failed to ${description}" + dump_support_stack_diagnostics + return 1 +} + +wait_user_units_active() { + local uid="$1" + local description="$2" + shift 2 + + local unit state substate attempt stalled_attempts + for unit in "$@"; do + stalled_attempts=0 + for attempt in $(seq 1 150); do + state="$( + run_user_systemctl "${uid}" show "${unit}" -p ActiveState --value 2>/dev/null || + true + )" + substate="$( + run_user_systemctl "${uid}" show "${unit}" -p SubState --value 2>/dev/null || + true + )" + + case "${state}" in + active) + break + ;; + failed | inactive | unknown | '') + stalled_attempts=$((stalled_attempts + 1)) + if [ "${stalled_attempts}" -ge 5 ]; then + log "${unit} stayed in ${state:-unknown}/${substate:-unknown} while trying to ${description}" + return 1 + fi + ;; + *) + stalled_attempts=0 + ;; + esac + + if [ "${attempt}" -eq 1 ] || [ $((attempt % 15)) -eq 0 ]; then + log "waiting for ${unit} to become active; current state is ${state:-unknown}/${substate:-unknown}" + fi + sleep 2 + done + + if [ "${state}" != "active" ]; then + log "timed out waiting for ${unit} to become active; current state is ${state:-unknown}/${substate:-unknown}" + return 1 + fi + done +} + +ensure_base_podman_resources() { + local uid="$1" + + if ! run_user_podman "${uid}" network exists krav-support; then + log "creating missing Podman network krav-support" + if ! run_user_podman "${uid}" network create krav-support; then + log "failed to create Podman network krav-support" + dump_support_stack_diagnostics + return 1 + fi + fi + + local volume + for volume in krav-sqlserver krav-hsa-mtls-certs; do + if ! run_user_podman "${uid}" volume exists "${volume}"; then + log "creating missing Podman volume ${volume}" + if ! run_user_podman "${uid}" volume create "${volume}"; then + log "failed to create Podman volume ${volume}" + dump_support_stack_diagnostics + return 1 + fi + fi + done +} + +stop_managed_containers() { + local uid="$1" + run_user_systemctl "${uid}" stop \ + krav-kong.service \ + krav-hsa-person-lookup-adapter.service \ + krav-hsa-directory-mock.service \ + krav-db.service \ + krav-idp.service \ + krav-hsa-mtls-cert-generator.service \ + krav-support-network.service || true + + run_user_podman "${uid}" rm --force \ + kong \ + hsa-person-lookup-adapter \ + hsa-directory-mock \ + db \ + idp \ + hsa-mtls-cert-generator >/dev/null 2>&1 || true + + run_user_podman "${uid}" network rm --force krav-support >/dev/null 2>&1 || true +} + +start_user_quadlets() { + local uid + uid="$(id -u "${VSCODE_USER}")" + loginctl enable-linger "${VSCODE_USER}" + systemctl start "user@${uid}.service" || true + + configure_user_systemd_environment "${uid}" + prepare_user_podman_runtime "${uid}" + run_user_systemctl "${uid}" daemon-reload + + stop_managed_containers "${uid}" + + run_user_systemctl "${uid}" reset-failed \ + krav-support-network.service \ + krav-sqlserver-volume.service \ + krav-hsa-mtls-certs-volume.service \ + krav-hsa-mtls-cert-generator.service \ + krav-hsa-directory-mock.service \ + krav-hsa-person-lookup-adapter.service \ + krav-db.service \ + krav-idp.service \ + krav-kong.service || true + + run_user_systemctl_or_diagnose "${uid}" "restart base Quadlet resources" restart \ + krav-support-network.service \ + krav-sqlserver-volume.service \ + krav-hsa-mtls-certs-volume.service + ensure_base_podman_resources "${uid}" + + run_user_systemctl_or_diagnose "${uid}" "generate HSA mTLS certificates" restart \ + krav-hsa-mtls-cert-generator.service + + run_user_systemctl_or_diagnose "${uid}" "start database service" start \ + krav-db.service + + run_user_systemctl_or_diagnose "${uid}" "start identity service" start \ + krav-idp.service + + run_user_systemctl_or_diagnose "${uid}" "start HSA directory mock service" restart \ + krav-hsa-directory-mock.service + + run_user_systemctl_or_diagnose "${uid}" "start HSA person lookup adapter service" restart \ + krav-hsa-person-lookup-adapter.service + + run_user_systemctl_or_diagnose "${uid}" "start Kong proxy service" restart \ + krav-kong.service +} + +install_optional_tailscale() { + if command -v tailscale >/dev/null 2>&1; then + return + fi + curl -fsSL https://tailscale.com/install.sh | sh + if [ -f /etc/krav-dev/tailscale.env ]; then + set -a + # shellcheck disable=SC1091 + . /etc/krav-dev/tailscale.env + set +a + if [ -n "${AZURE_DEV_TAILSCALE_AUTH_KEY:-}" ]; then + tailscale up \ + --auth-key="${AZURE_DEV_TAILSCALE_AUTH_KEY}" \ + --hostname="$(hostname)-krav-dev" \ + --ssh=false + fi + fi +} + +dump_support_stack_diagnostics() { + local uid + uid="$(id -u "${VSCODE_USER}")" + + log "support stack diagnostics" + ss -ltn || true + printf '\n[krav-azure-bootstrap] user systemd environment\n' + run_user_systemctl "${uid}" show-environment || true + run_user_systemctl "${uid}" --no-pager --failed || true + + local units=( + krav-support-network.service + krav-sqlserver-volume.service + krav-hsa-mtls-certs-volume.service + krav-hsa-mtls-cert-generator.service + krav-db.service + krav-idp.service + krav-hsa-directory-mock.service + krav-hsa-person-lookup-adapter.service + krav-kong.service + ) + for unit in "${units[@]}"; do + printf '\n[krav-azure-bootstrap] systemd status for %s\n' "${unit}" + run_user_systemctl "${uid}" status "${unit}" --no-pager || true + printf '\n[krav-azure-bootstrap] systemd environment for %s\n' "${unit}" + run_user_systemctl "${uid}" show "${unit}" -p Environment --no-pager || true + printf '\n[krav-azure-bootstrap] journal for %s\n' "${unit}" + runuser -u "${VSCODE_USER}" -- env \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + journalctl --user -u "${unit}" -n 80 --no-pager || true + done + + printf '\n[krav-azure-bootstrap] podman containers\n' + run_user_podman "${uid}" ps -a || true + printf '\n[krav-azure-bootstrap] podman store\n' + run_user_podman "${uid}" info --format 'GraphRoot={{.Store.GraphRoot}} RunRoot={{.Store.RunRoot}}' || true + printf '\n[krav-azure-bootstrap] podman networks\n' + run_user_podman "${uid}" network ls || true + printf '\n[krav-azure-bootstrap] podman volumes\n' + run_user_podman "${uid}" volume ls || true + + local containers=( + db + idp + hsa-mtls-cert-generator + hsa-directory-mock + hsa-person-lookup-adapter + kong + ) + for container in "${containers[@]}"; do + printf '\n[krav-azure-bootstrap] podman logs for %s\n' "${container}" + run_user_podman "${uid}" logs --tail 100 "${container}" || true + done +} + +validate_loopback_ports() { + for _ in $(seq 1 90); do + if ss -ltn | grep '127.0.0.1:1433' >/dev/null && + ss -ltn | grep '127.0.0.1:8080' >/dev/null && + ss -ltn | grep '127.0.0.1:18000' >/dev/null && + curl -s -o /dev/null http://127.0.0.1:18000/; then + return + fi + sleep 5 + done + log 'timed out waiting for support stack loopback ports' + dump_support_stack_diagnostics + return 1 +} + +main() { + log "starting host bootstrap" + install_host_packages + ensure_vscode_user + stop_user_quadlet_services_before_storage_change + mount_data_disk + configure_podman_storage + clone_or_update_repo + ensure_kong_route_protocols + install_zsh_profile + configure_codex_home + run_repository_setup + write_vm_env_override + install_quadlet_units + build_hsa_images + start_user_quadlets + install_optional_tailscale + validate_loopback_ports + log "host bootstrap completed" +} + +main "$@" diff --git a/scripts/azure-dev/templates/main.bicep b/scripts/azure-dev/templates/main.bicep new file mode 100644 index 00000000..54c0d369 --- /dev/null +++ b/scripts/azure-dev/templates/main.bicep @@ -0,0 +1,259 @@ +@description('Stable environment id used in ownership tags.') +param environmentId string + +@description('Name prefix for generated Azure resources.') +param namePrefix string + +@description('Virtual machine name.') +param vmName string + +@description('Azure region.') +param location string = resourceGroup().location + +@description('Azure VM size.') +param vmSize string + +@description('Data disk size in GiB.') +@minValue(64) +param dataDiskGiB int + +@description('Linux administrator and SSH user.') +param adminUsername string = 'vscode' + +@description('OpenSSH public key installed on the VM.') +param sshPublicKey string + +@description('Connectivity mode.') +@allowed([ + 'public-ssh' + 'tailscale' +]) +param connectivityMode string = 'public-ssh' + +@description('Allowed source CIDR for inbound SSH in public-ssh mode.') +param allowedSshCidr string + +@description('Enable Azure DevTestLab auto-shutdown.') +param autoStopEnabled bool = true + +@description('Auto-shutdown time in HHmm format.') +param autoStopTime string = '2200' + +@description('Auto-shutdown time zone id.') +param autoStopTimeZone string = 'UTC' + +@description('Ubuntu Marketplace image publisher.') +param imagePublisher string + +@description('Ubuntu Marketplace image offer.') +param imageOffer string + +@description('Ubuntu Marketplace image SKU.') +param imageSku string + +@description('Ubuntu Marketplace image version.') +param imageVersion string + +var commonTags = { + 'managed-by': 'kravhantering-azure-dev' + 'environment-id': environmentId + repository: 'viscalyx/Kravhantering' + purpose: 'personal-development' +} + +var vnetName = '${namePrefix}-vnet' +var subnetName = 'snet-dev' +var nsgName = '${namePrefix}-nsg' +var publicIpName = '${namePrefix}-pip' +var nicName = '${namePrefix}-nic' +var osDiskName = '${vmName}-osdisk' +var dataDiskName = '${vmName}-data' +var sshPublicKeyName = '${namePrefix}-ssh-key' +var autoShutdownName = 'shutdown-computevm-${vmName}' + +var sshRules = connectivityMode == 'public-ssh' ? [ + { + name: 'AllowSshFromOperator' + properties: { + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '22' + sourceAddressPrefix: allowedSshCidr + destinationAddressPrefix: '*' + } + } +] : [] + +var nicIpProperties = union({ + subnet: { + id: subnet.id + } + privateIPAllocationMethod: 'Dynamic' + privateIPAddressVersion: 'IPv4' +}, connectivityMode == 'public-ssh' ? { + publicIPAddress: { + id: publicIp!.id + } +} : {}) + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: vnetName + location: location + tags: commonTags + properties: { + addressSpace: { + addressPrefixes: [ + '10.41.0.0/16' + ] + } + privateEndpointVNetPolicies: 'Disabled' + } +} + +resource nsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { + name: nsgName + location: location + tags: commonTags + properties: { + securityRules: sshRules + } +} + +resource publicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = if (connectivityMode == 'public-ssh') { + name: publicIpName + location: location + sku: { + name: 'Standard' + tier: 'Regional' + } + tags: commonTags + properties: { + publicIPAllocationMethod: 'Static' + publicIPAddressVersion: 'IPv4' + } +} + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: vnet + name: subnetName + properties: { + addressPrefix: '10.41.1.0/24' + privateEndpointNetworkPolicies: 'Disabled' + networkSecurityGroup: { + id: nsg.id + } + } +} + +resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = { + name: nicName + location: location + tags: commonTags + properties: { + ipConfigurations: [ + { + name: 'ipconfig1' + properties: nicIpProperties + } + ] + } +} + +resource sshKey 'Microsoft.Compute/sshPublicKeys@2024-07-01' = { + name: sshPublicKeyName + location: location + tags: commonTags + properties: { + publicKey: sshPublicKey + } +} + +resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = { + name: vmName + location: location + tags: commonTags + properties: { + hardwareProfile: { + vmSize: vmSize + } + networkProfile: { + networkInterfaces: [ + { + id: nic.id + } + ] + } + osProfile: { + computerName: vmName + adminUsername: adminUsername + linuxConfiguration: { + disablePasswordAuthentication: true + ssh: { + publicKeys: [ + { + path: '/home/${adminUsername}/.ssh/authorized_keys' + keyData: sshPublicKey + } + ] + } + } + } + storageProfile: { + imageReference: { + publisher: imagePublisher + offer: imageOffer + sku: imageSku + version: imageVersion + } + osDisk: { + name: osDiskName + createOption: 'FromImage' + managedDisk: { + storageAccountType: 'Premium_LRS' + } + } + dataDisks: [ + { + lun: 0 + name: dataDiskName + createOption: 'Empty' + caching: 'None' + deleteOption: 'Detach' + diskSizeGB: dataDiskGiB + managedDisk: { + storageAccountType: 'Premium_LRS' + } + toBeDetached: false + } + ] + } + } +} + +resource autoShutdown 'Microsoft.DevTestLab/schedules@2018-09-15' = if (autoStopEnabled) { + name: autoShutdownName + location: location + tags: commonTags + properties: { + status: 'Enabled' + taskType: 'ComputeVmShutdownTask' + dailyRecurrence: { + time: autoStopTime + } + timeZoneId: autoStopTimeZone + notificationSettings: { + status: 'Disabled' + } + targetResourceId: vm.id + } +} + +output vmName string = vm.name +output adminUsername string = adminUsername +output publicIpAddress string = connectivityMode == 'public-ssh' ? publicIp!.properties.ipAddress : '' +output networkSecurityGroupName string = nsg.name +output sshPublicKeyResourceName string = sshKey.name +output imageUrn string = '${imagePublisher}:${imageOffer}:${imageSku}:${imageVersion}' diff --git a/scripts/azure-dev/templates/quadlet/krav-db.container b/scripts/azure-dev/templates/quadlet/krav-db.container new file mode 100644 index 00000000..01079b57 --- /dev/null +++ b/scripts/azure-dev/templates/quadlet/krav-db.container @@ -0,0 +1,31 @@ +[Unit] +Description=Kravhantering SQL Server developer database +Requires=krav-support-network.service krav-sqlserver-volume.service +After=krav-support-network.service krav-sqlserver-volume.service + +[Container] +ContainerName=db +Image=mcr.microsoft.com/mssql/server:2025-CU6-ubuntu-24.04@sha256:2cd0aec4a3bfc3cf9205bed3f7922f4c6208f7c767dc62edcee308d0fd7d56d0 +Network=krav-support.network +Volume=krav-sqlserver.volume:/var/opt/mssql:Z,U +PublishPort=127.0.0.1:1433:1433 +EnvironmentFile=%h/.config/krav-dev/sqlserver.env +Environment=HOME=/var/opt/mssql +HealthCmd=/bin/bash -ec 'if [ -x /opt/mssql-tools18/bin/sqlcmd ]; then /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -C -Q "SELECT 1"; elif [ -x /opt/mssql-tools/bin/sqlcmd ]; then /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -C -Q "SELECT 1"; else bash -c "&3; grep -m 1 " 200 " <&3 >/dev/null; } 3<>/dev/tcp/localhost/8080 && { printf "GET /realms/kravhantering-dev/protocol/openid-connect/certs HTTP/1.0\r\nHost: localhost\r\n\r\n" >&3; grep -m 1 " 200 " <&3 >/dev/null; } 3<>/dev/tcp/localhost/8080' +HealthInterval=5s +HealthRetries=30 +HealthStartPeriod=10s +HealthTimeout=5s +HealthOnFailure=kill + +[Service] +Environment=HOME=%h +Environment=XDG_CONFIG_HOME=%h/.config +Environment=XDG_DATA_HOME=%h/.local/share +Environment=XDG_RUNTIME_DIR=%t +Environment=CONTAINERS_CONF=%h/.config/containers/containers.conf +Environment=CONTAINERS_STORAGE_CONF=%h/.config/containers/storage.conf +Restart=on-failure +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/scripts/azure-dev/templates/quadlet/krav-kong.container b/scripts/azure-dev/templates/quadlet/krav-kong.container new file mode 100644 index 00000000..eab9365a --- /dev/null +++ b/scripts/azure-dev/templates/quadlet/krav-kong.container @@ -0,0 +1,39 @@ +[Unit] +Description=Kravhantering Kong HSA proxy +Requires=krav-support-network.service krav-hsa-person-lookup-adapter.service +After=krav-support-network.service krav-hsa-person-lookup-adapter.service + +[Container] +ContainerName=kong +Image=docker.io/kong/kong-gateway:3.15.0.0-20260702-ubuntu@sha256:f0516241dd354e66a9a8b4151c884bf40df8f8d850cec892adb4446cd210491f +Network=krav-support.network +PublishPort=127.0.0.1:18000:8000 +Volume=/workspace/containers/kong/kong.yml:/kong/declarative/kong.yml:ro +Environment=KONG_ADMIN_ACCESS_LOG=/dev/stdout +Environment=KONG_ADMIN_ERROR_LOG=/dev/stderr +Environment=KONG_ADMIN_LISTEN=0.0.0.0:8001 +Environment=KONG_DATABASE=off +Environment=KONG_DECLARATIVE_CONFIG=/kong/declarative/kong.yml +Environment=KONG_PREFIX=/tmp/kong +Environment=KONG_PROXY_ACCESS_LOG=/dev/stdout +Environment=KONG_PROXY_ERROR_LOG=/dev/stderr +Environment=KONG_PROXY_LISTEN=0.0.0.0:8000 +HealthCmd=kong health +HealthInterval=10s +HealthRetries=12 +HealthStartPeriod=10s +HealthTimeout=5s +HealthOnFailure=kill + +[Service] +Environment=HOME=%h +Environment=XDG_CONFIG_HOME=%h/.config +Environment=XDG_DATA_HOME=%h/.local/share +Environment=XDG_RUNTIME_DIR=%t +Environment=CONTAINERS_CONF=%h/.config/containers/containers.conf +Environment=CONTAINERS_STORAGE_CONF=%h/.config/containers/storage.conf +Restart=on-failure +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/scripts/azure-dev/templates/quadlet/krav-sqlserver.volume b/scripts/azure-dev/templates/quadlet/krav-sqlserver.volume new file mode 100644 index 00000000..5a40d66a --- /dev/null +++ b/scripts/azure-dev/templates/quadlet/krav-sqlserver.volume @@ -0,0 +1,10 @@ +[Volume] +VolumeName=krav-sqlserver + +[Service] +Environment=HOME=%h +Environment=XDG_CONFIG_HOME=%h/.config +Environment=XDG_DATA_HOME=%h/.local/share +Environment=XDG_RUNTIME_DIR=%t +Environment=CONTAINERS_CONF=%h/.config/containers/containers.conf +Environment=CONTAINERS_STORAGE_CONF=%h/.config/containers/storage.conf diff --git a/scripts/azure-dev/templates/quadlet/krav-support.network b/scripts/azure-dev/templates/quadlet/krav-support.network new file mode 100644 index 00000000..8f2cf1ab --- /dev/null +++ b/scripts/azure-dev/templates/quadlet/krav-support.network @@ -0,0 +1,11 @@ +[Network] +NetworkName=krav-support +Driver=bridge + +[Service] +Environment=HOME=%h +Environment=XDG_CONFIG_HOME=%h/.config +Environment=XDG_DATA_HOME=%h/.local/share +Environment=XDG_RUNTIME_DIR=%t +Environment=CONTAINERS_CONF=%h/.config/containers/containers.conf +Environment=CONTAINERS_STORAGE_CONF=%h/.config/containers/storage.conf