diff --git a/docs/azure-labs-plan.md b/docs/azure-labs-plan.md new file mode 100644 index 0000000..5b1546f --- /dev/null +++ b/docs/azure-labs-plan.md @@ -0,0 +1,190 @@ +# Azure Labs pack: design plan (v2) + +Status: draft for review. No code written yet. +Updated: 2026-11-01. Replaces the first draft (pack name `azure-classroom`, Python SDK, cost section). + +## Purpose + +Azure Lab Services (ALS) retires on June 28, 2027. Customers lose their lab accounts, lab plans and labs, and keep their Azure Compute Gallery and its images. +Source: [ALS retirement guide](https://learn.microsoft.com/en-us/azure/lab-services/retirement-guide). + +The `azure-labs` pack rebuilds the ALS workflow on standard Azure VMs: template, gallery image, one VM per student, schedules, idle shutdown, reset, browser access, teardown. +It is an alternative to Azure Lab Services. It is not Microsoft's service and is not affiliated with Microsoft. Every doc page says so in the first lines. + +Goals now: working functionality, content for a YouTube series, and a test path a junior engineer can run. Cost features are out of scope. + +## Scope changes from the first draft + +| Change | Why | +|---|---| +| Pack id `azure-labs` (was `azure-classroom`) | Search term. Docs: `docs/azure-labs.md`, `docs/azure-labs/`. | +| Azure calls go through the `az` CLI, not the Python SDK | You are signed in with `az`. Removes the Python 3.9 SDK problem. Viewers can copy the commands. No `azure` extra. | +| No cost command, no price table, no cost column | Out of scope for now. | +| Simulation uses one subscription, two resource groups | No ALS account is available. See below. | +| Sizes follow the quota of the test subscription | 10 vCPUs per region, Dsv5 family quota 0. | + +## How the simulation works + +You have no ALS lab, so the "old lab" is a stand-in. Everything after the gallery image is real. + +1. **Legacy lab (stand-in).** The template step builds a template VM, runs the recipe, and saves it as a gallery image. For the ALS case it is captured as a specialized image, because ALS exports only specialized images. +2. **Inventory.** A sample `Get-Labs.ps1` CSV, labelled "sample output, not from a real customer", describes that lab. `labagent als plan` reads it. +3. **New lab.** `labagent launch azure-labs --param template=gallery --param gallery_image_id= --param image_state=specialized` rebuilds the class in a new resource group. +4. **Moving between accounts.** Run for real: replicate the image to a second region (eastus2). Documented but not run: share the gallery to another subscription by role assignment, since only one subscription exists. + +On camera: say what is simulated (the ALS lab) and what is real (gallery, VMs, access, reset, teardown). + +## What I verified, and what I did not + +| Fact | Source | Confidence | +|---|---|---| +| ALS retires June 28, 2027. Gallery and images stay. | MS retirement guide | high | +| ALS exports only specialized images. | MS retirement guide | high | +| `Get-Labs.ps1` writes a CSV with the columns you listed plus SubscriptionId, SubscriptionName, LabPlanId, ResourceGroupName, Id. | script source | high | +| `Get-AzLabServicesUsage.ps1` writes no CSV. It prints tables by `-DetailLevel` 1 to 4 and returns objects with `-PassThru`. | script source | high | +| Specialized images: no `osProfile`, same accounts, passwords and computer name as the source VM. | [Compute Gallery docs](https://learn.microsoft.com/en-us/azure/virtual-machines/shared-image-galleries) | high | +| Image definition needs publisher, offer, sku, OS type and OS state. Versions are Major.Minor.Patch. A VM can be the source of a version. | same | high | +| `Microsoft.DevTestLab/schedules`, API `2018-09-15`, task type `ComputeVmShutdownTask`, daily time and time zone ID. | [ARM reference](https://learn.microsoft.com/en-us/azure/templates/microsoft.devtestlab/schedules) | high | +| The schedule must be named `shutdown-computevm-` to show as the VM's auto-shutdown. | my memory | low. Spike. | +| Dv3, Dv4, Dv5, Ev3 to Ev5 and Fsv2 support nested virtualization. B-series does not. | search summary | moderate. Spike. | +| Windows 10/11 client images need an eligible license. Windows Server is in the VM price. | [MS Q&A](https://learn.microsoft.com/en-us/answers/questions/2141337/am-i-eligible-for-windows-10-11-license-with-multi) | moderate | +| New VNets created after Sept 30, 2025 have no default outbound internet. | [Azure update](https://azure.microsoft.com/updates/default-outbound-access-for-vms-in-azure-will-be-retired-transition-to-a-new-method-of-internet-access) | high | +| Guacamole `user-mapping.xml` auth works from one file and applies changes without restart. md5 hashing is documented, SHA-256 not confirmed. | [Guacamole manual](https://guacamole.apache.org/doc/gug/configuring-guacamole.html) | moderate | +| Test subscription: pay-as-you-go, 10 vCPUs per region, Dsv5 quota 0, Dv3 and B-series 10, you are Owner, providers registered. | `az` read-only checks, 2026-11-01 | high | + +## Decisions (defaults I will use unless you say otherwise) + +1. **`az` CLI, not the SDK.** Done. Each student VM deploys through an ARM template with `az deployment group create`. The password goes in a secure parameter read from a 0600 temp file that is deleted after. It never appears in process arguments or logs. +2. **Gallery in a second resource group** `rg-labagent-azure-labs--images`, deleted only with `delete_images=true`. The bring-your-own-image path never touches an existing gallery. +3. **Idle shutdown and hours cap run on the gateway** (it has one managed identity with VM Contributor on the lab group, reads Guacamole sessions, deallocates idle or over-cap VMs). Without a gateway (`access=rdp-ssh-direct`), only the fixed-time auto-shutdown applies. An in-guest shutdown does not stop the compute bill, only a deallocate does. +4. **Student start** is not in v1. The schedule starts VMs. `labagent start azure-labs --name X --student S` starts one. +5. **Passwords** are sealed with `secrets_box`. `launch` gets `--pubkey ` (or `LABAGENT_PUBKEY`). No key means the run stops at preflight. A `labagent secrets decrypt --key private.pem` helper reads them. Guacamole stores the VM password in a 0600 file inside the gateway. The student never sees it. +6. **Outbound internet.** Param `outbound`: `none` (default) or `nat`. The template VM gets a temporary public IP for the build and loses it before capture. +7. **Schedules** run through `labagent schedule run`, a stateless command meant for a GitHub Actions cron. +8. **Specialized path.** After create, a Run Command sets a per-student password and a new computer name. Windows clones share a machine SID. The planner flags it for AD labs. +9. **Windows client OS is out of scope.** `os` is `linux` or `windows-server`. +10. **New top-level commands** `reset`, `status`, `start`, `stop`, `schedule run`, `sweep-azure`, `als plan`, `secrets decrypt` go into `KNOWN` in `cli.py`, or the plain-English translator would catch them. + +## Design + +### Layout + +``` +labagent/packs/azure_labs/ + pack.py Pack subclass: params, steps, outputs + azcli.py AzCli: every az call, JSON in and out, UTF-8, secure temp files + safety.py tags, names, allow-lists, size and count caps, expiry + recipes/ linux-admin (cloud-init), windows-ad (Custom Script Extension) + arm/ vm.json, schedule.json (ARM templates) + guac.py gateway cloud-init, user-mapping.xml writer, controller script +labagent/als/ planner: CSV in, config and risk list out +``` + +### Auth and safety + +- Uses the signed-in `az` session (`az login`, or OIDC in CI). No secrets in config. +- Refuse unless the subscription ID is in `LABAGENT_ALLOWED_AZURE_SUBSCRIPTIONS` (comma list). Empty means refuse. +- Lab group `rg-labagent-azure-labs-`. Name: lower case, 3 to 40 characters. +- Tags on every resource: `labagent=1`, `pack`, `name`, `cohort`, `created_at`, `expires_at`. Student VMs add `student`. +- `destroy` lists the lab group, deletes only resources with this lab's tags, and deletes the group only if it has `created_by_labagent=1` and is then empty. An untagged resource blocks the group delete and is reported. +- Caps: `max_students` default 30, ceiling 100. Size allow-list: `Standard_B1s`, `B2s`, `B2ms`, `D2s_v3`, `D4s_v3`, `D2s_v5`, `D4s_v5`. For `nested=true`: `D2s_v3`, `D4s_v3`, `D4s_v5`, `D8s_v5`, `E4s_v5`. Anything else is refused. +- Defaults: linux `Standard_B2s`, windows-server `Standard_D2s_v3`. The preflight error names the family and the portal path when quota is short. +- `labagent sweep-azure plan` lists labs past `expires_at` and writes a plan with a hash (same idea as `labagent/plan.py`). `apply --plan x.json --hash H` deletes exactly that plan, if it is under 24 hours old. + +### Steps (each has a read-only `check`) + +| # | Step | Creates | Check | +|---|---|---|---| +| 1 | `preflight` | nothing | `az account show`, subscription allowed, quota, image exists, public key present | +| 2 | `network` | RG, VNet, subnets, NSG, optional NAT Gateway | NSG and VNet exist with tags | +| 3 | `template` | temp VM, gallery RG, gallery, image definition, image version 1.0.0 | version exists, state Succeeded | +| 4 | `publish` | student VMs | VM with the student tag exists | +| 5 | `auto_shutdown` | one `DevTestLab/schedules` per VM | schedule exists | +| 6 | `schedule` | tags that `schedule run` reads | tags present | +| 7 | `gateway` | gateway VM, static IP with DNS label, Guacamole | login page returns 200 | +| 8 | `verify` | nothing | each VM reachable from the gateway, one test command per VM | +| 9 | `output` | nothing | n/a | + +The gateway's user list is written at the end of step 7, after step 4 has made the VMs. + +**Quota preflight.** `az vm list-usage` gives cores by VM family and region. Count the peak at any moment: the template phase (template VM) and the publish phase (students plus gateway) never overlap, so use the larger. A reset needs one extra slot only if create runs before delete, so reset deletes first. + +**Template step.** +1. Create the template VM from a marketplace image (Ubuntu 24.04 LTS, Windows Server 2022 Datacenter). +2. Run the recipe: cloud-init on Linux, Custom Script Extension on Windows. +3. Generalized path: Linux `waagent -deprovision+user`, Windows `sysprep /generalize /oobe /shutdown`, then deallocate and `az vm generalize`. + Specialized path: deallocate only. +4. `az sig image-version create` from the VM. +5. Delete the template VM, disk, NIC and temporary IP. Keep the image. Save `gallery_image_id` in state. + +Image definition: Hyper-V generation V2, security type Standard, publisher `labagent`, offer = recipe name, sku = os. Generalized and specialized images need separate definitions. + +**Publish.** Concurrency 5. VM name `vm--`. Windows computer name `s-` (15-character limit). Passwords exist only in memory until sealed. + +**Reset.** Delete the student's VM and disk, create a new one from the image, issue a new password, update `user-mapping.xml`. Student work on that disk is lost, as in ALS reimage. + +**Status.** Table: student, power state, hours run today, last session seen. "Last session" comes from the Guacamole API and is blank without a gateway. + +### Gateway + +One Linux VM (B2s). Docker Compose: `guacd`, `guacamole/guacamole`, Caddy. TLS self-signed by default, a param for a real domain, and `tls=letsencrypt` using the public IP's Azure DNS label. NSG inbound 443 only. Students accept 22 and 3389 only from the gateway subnet. Auth by `user-mapping.xml`: one user per student, one instructor with every connection. If SHA-256 is not supported, use md5 and say so in the docs. + +### Planner: `labagent als plan` + +Input: the `Get-Labs.ps1` CSV. Optional usage CSV, made by running `Get-AzLabServicesUsage.ps1 -PassThru -DetailLevel 2 | Export-Csv`. Column names used from it: `LabName`, `TotalVirtualMachines`, `AssignedVMs`, `TotalStudents`, `TotalStudentsWithUsage`, `VM-Utilization (%)`. I will check these against a real run before the docs promise them. + +| CSV column | Config | +|---|---| +| `OsType` | `os` | +| `SkuName` | `vm_size`, by a lookup table. A name not in the table adds a warning. | +| `SkuCapacity` or `AssignedVMs` | `students` | +| `IsCustomImage` true, `ImageSource` | `template=gallery`, `gallery_image_id`, `image_state=specialized` | +| `IsCustomImage` false | nearest recipe, risk "marketplace image" | +| `Location` | `region` | + +Risks flagged: Windows client OS, nested virtualization size, `IsAdvancedNetworking=true`, marketplace image, specialized Windows (SID). + +Sample file `examples/als/sample-get-labs.csv`: four labs (Linux admin, Windows Server AD, cybersecurity with nested virtualization, advanced networking), labelled "sample output, not from a real customer". + +## Testing and junior validation + +Unit tests use a fake `AzCli` (same pattern as `FakeEc2` in `test_rag_ide_ec2.py`): idempotent steps, resume after each failing step, tag-only destroy, subscription refusal, quota failure, no password in logs or envelope, sweep plan and apply, planner mapping and risk flags. They run without Azure. + +`docs/e2e-azure-labs.md` is written for a junior engineer. Each test has: the exact command, the expected output, an `az` command that proves it, and pass or fail. Tests: + +| # | Test | Proves | +|---|---|---| +| 1 | Refusal: run with the subscription not in the allow-list | guard works, nothing created | +| 2 | `launch --dry-run` | steps listed, nothing created | +| 3 | Linux template to gallery image (specialized and generalized) | image version exists | +| 4 | Launch 3 students from that image | 3 VMs, tagged, no public IP | +| 5 | Open the gateway URL, log in as a student, reach the VM | browser access | +| 6 | Auto-shutdown schedule shows on a VM | schedule resource | +| 7 | Reset one student | that VM recreated, others untouched | +| 8 | `als plan` on the sample CSV | 4 configs and the expected risk flags | +| 9 | Windows Server AD with 2 students | AD tools present | +| 10 | Destroy | lab group gone, image kept | +| 11 | Destroy with `delete_images=true` | image group gone | +| 12 | Put an untagged resource in the lab group, destroy | it is reported, not deleted | + +Each test ends with a cleanup check: `az resource list --tag labagent=1` returns nothing for that lab. + +## Pull requests + +| PR | Content | +|---|---| +| 1 | `AzCli`, allow-list, tags, names, caps, quota preflight, `destroy` by tag, `sweep-azure`, `--pubkey`, `secrets decrypt` | +| 2 | Template step, recipes, gallery, image version, bring-your-own image check | +| 3 | `publish`, auto-shutdown, `reset`, `status`, `start`, `stop`, `schedule run` | +| 4 | Guacamole gateway and controller | +| 5 | `als plan` and the sample CSV | +| 6 | Docs: `azure-labs.md`, six tutorial pages, README, `e2e-azure-labs.md` | + +After each PR: tests pass, and I list the Azure resources that PR creates. + +## Risks + +1. I cannot reach your Azure subscription from the tests. Every Azure call is tested against a fake. The pack counts as working only after the junior engineer completes the e2e table. +2. Items marked low or moderate above need a spike on a real subscription: schedule resource name, Guacamole SHA-256, nested-size list. +3. The planner's SKU table is a guess until you share a real `Get-Labs.ps1` file. +4. Guacamole, Caddy and Docker images get pinned by digest in PR 4. diff --git a/labagent/azure_sweep.py b/labagent/azure_sweep.py new file mode 100644 index 0000000..3ad3fb0 --- /dev/null +++ b/labagent/azure_sweep.py @@ -0,0 +1,71 @@ +"""`labagent sweep-azure`: delete labs past their expires_at. Plan first (read-only), then apply exactly that plan.""" +from __future__ import annotations + +import time +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + +from .envelope import Envelope +from .packs.azure_labs import lifecycle, safety +from .packs.base import PackError +from .plan import PlanError, build_plan, verify_plan + +PLAN_MAX_AGE_S = 24 * 3600 + + +def _expired(tags: Dict[str, str], now: datetime) -> Optional[str]: + """The expiry stamp if this lab is a labagent lab, is not an images group, and is past its expiry. Else None.""" + if not safety.is_labagent_group(tags) or tags.get("role") == "images": + return None + when = safety.parse_stamp(tags.get("expires_at")) + return tags["expires_at"] if when and when <= now else None + + +def sweep_plan(az: Any, subscription_id: str, now: Optional[datetime] = None) -> Envelope: + safety.check_subscription(subscription_id) + now = now or safety.utc_now() + env = Envelope(command="sweep-azure", mode="plan", cohort="azure") + items: List[Dict[str, Any]] = [] + for g in az.groups_with_tag("labagent", "1"): + tags = g.get("tags") or {} + if not safety.is_labagent_group(tags): + continue + stamp = _expired(tags, now) + items.append({"arn": g["id"], "service": "resourcegroup", "type": "group", "id": g["name"], "region": g.get("location"), + "name": tags.get("name"), "action": "delete" if stamp else "skip", + "reason": f"expired {stamp}" if stamp else ("images group" if tags.get("role") == "images" else "not expired"), + "state": "planned"}) + env.items = items + env.plan = build_plan("azure", subscription_id, items, now.timestamp()) + n = sum(1 for i in items if i["action"] == "delete") + env.summary = {"to_delete": n, "skipped": len(items) - n} + return env + + +def sweep_apply(az: Any, subscription_id: str, plan: Dict[str, Any], approved_hash: Optional[str], now: Optional[datetime] = None, + log: Callable[[str], None] = lambda m: None) -> Envelope: + safety.check_subscription(subscription_id) + now = now or safety.utc_now() + env = Envelope(command="sweep-azure", mode="apply", cohort="azure") + try: + verify_plan(plan, subscription_id, now.timestamp(), PLAN_MAX_AGE_S, approved_hash) + except PlanError as e: + return env.fail(str(e)) + env.plan = {"id": plan.get("id"), "hash": plan["hash"], "created_at": plan["created_at"]} + for item in plan["items"]: + row = dict(item) + env.items.append(row) + if item["action"] != "delete": + row["state"] = "skipped" + continue + info = az.group_show(item["id"]) # re-read: a lab whose tags or expiry changed since the plan is left alone + if info is None or _expired(info.get("tags") or {}, now) is None or not safety.is_labagent_group(info.get("tags")): + row.update(state="skipped", reason="changed since the plan") + continue + try: + lifecycle.destroy_owned(az, item["id"], info["tags"]["name"], log) + row["state"] = "done" + except PackError as e: + row.update(state="failed", error=str(e)[:400]) + env.summary = {s: sum(1 for i in env.items if i["state"] == s) for s in ("done", "skipped", "failed")} + return env.finalize() diff --git a/labagent/cli.py b/labagent/cli.py index a5883e7..fde4fce 100644 --- a/labagent/cli.py +++ b/labagent/cli.py @@ -24,6 +24,59 @@ def _emit(env: Envelope, output: str) -> None: Path(output).chmod(0o600) +def _read_pubkey(path): + """PEM text of the orchestrator's public key from --pubkey, or the file named by LABAGENT_PUBKEY. None when neither is set.""" + import os + path = path or os.environ.get("LABAGENT_PUBKEY") + return Path(path).expanduser().read_text(encoding="utf-8") if path else None + + +def _secrets_main(args) -> int: + from . import secrets_box + if args.secrets_cmd == "keygen": + out = Path(args.dir).expanduser() + out.mkdir(parents=True, exist_ok=True) + private, public = secrets_box.generate_keypair() + priv_path, pub_path = out / "labagent-private.pem", out / "labagent-public.pem" + for path, text, mode in ((priv_path, private, 0o600), (pub_path, public, 0o644)): + if path.exists(): + print(f"error: {path} already exists: refusing to overwrite a key", file=sys.stderr) + return 2 + path.write_text(text, encoding="utf-8") + path.chmod(mode) + print(f"private key: {priv_path} (keep it secret)\npublic key: {pub_path} (pass to launch --pubkey)") + return 0 + try: + key = secrets_box.load_private_key(Path(args.key).expanduser().read_text(encoding="utf-8")) + raw = sys.stdin.read() if args.infile == "-" else Path(args.infile).expanduser().read_text(encoding="utf-8") + doc = json.loads(raw) + rows = [(s["user"], s.get("kind", ""), secrets_box.decrypt(key, s["ciphertext"])) for s in doc.get("secrets", [])] + except (OSError, ValueError, KeyError) as e: + print(f"error: could not decrypt: {type(e).__name__}", file=sys.stderr) + return 2 + for user, kind, pw in rows: + print(f"{user}\t{kind}\t{pw}") + return 0 + + +def _sweep_azure_main(args) -> int: + from .packs.azure_labs.azcli import AzCli + from .packs.base import PackError + from . import azure_sweep + try: + az = AzCli(args.subscription) + sub = az.account()["id"] + if args.sweep_cmd == "plan": + env = azure_sweep.sweep_plan(az, sub) + else: + doc = json.loads(Path(args.plan).expanduser().read_text(encoding="utf-8")) + env = azure_sweep.sweep_apply(az, sub, doc.get("plan") or doc, args.hash, log=lambda m: print(m, file=sys.stderr)) + except (PackError, OSError, ValueError) as e: + env = Envelope(command="sweep-azure").fail(str(e)[:400]) + _emit(env, args.output) + return env.exit_code + + def _packs_main(args) -> int: from .packs import registry from .packs.base import run_pack @@ -44,12 +97,17 @@ def _packs_main(args) -> int: if not name: print("error: --name is required for this pack", file=sys.stderr) return 2 - env = run_pack(pack, args.cmd, name, given, dry_run=args.dry_run) + try: + pubkey = _read_pubkey(getattr(args, "pubkey", None)) + except OSError as e: + print(f"error: cannot read the public key: {e}", file=sys.stderr) + return 2 + env = run_pack(pack, args.cmd, name, given, dry_run=args.dry_run, pubkey_pem=pubkey) _emit(env, args.output) return env.exit_code -KNOWN = {"run", "packs", "rag", "dataset", "launch", "destroy", "bootstrap-path", "commands"} +KNOWN = {"run", "packs", "rag", "dataset", "launch", "destroy", "bootstrap-path", "commands", "sweep-azure", "secrets"} def _nl_main(argv) -> int: @@ -121,6 +179,24 @@ def main(argv=None) -> int: lp.add_argument("--param", action="append", default=[], metavar="KEY=VALUE", help="Pack parameter, repeatable") lp.add_argument("--dry-run", action="store_true", help="Show the steps without running them") lp.add_argument("--output", default="-", help="Result envelope path, or - for stdout") + if verb == "launch": + lp.add_argument("--pubkey", help="PEM public key: passwords a pack creates are returned encrypted to it (or set LABAGENT_PUBKEY to the file path)") + sw = sub.add_parser("sweep-azure", help="Delete azure-labs labs past their expiry: plan first, then apply that plan") + sw.add_argument("--subscription", help="Subscription ID or name (default: the one az login selected)") + sws = sw.add_subparsers(dest="sweep_cmd", required=True) + swp = sws.add_parser("plan", help="List expired labs and write a plan (changes nothing)") + swp.add_argument("--output", default="-", help="Plan envelope path, or - for stdout") + swa = sws.add_parser("apply", help="Delete exactly the labs in a reviewed plan") + swa.add_argument("--plan", required=True, help="Plan file from `sweep-azure plan`") + swa.add_argument("--hash", required=True, help="The plan hash you approved") + swa.add_argument("--output", default="-", help="Result envelope path, or - for stdout") + sc = sub.add_parser("secrets", help="Create a key pair, or decrypt the passwords in a result envelope") + scs = sc.add_subparsers(dest="secrets_cmd", required=True) + kg = scs.add_parser("keygen", help="Write labagent-private.pem and labagent-public.pem") + kg.add_argument("--dir", default=".", help="Folder for the key files") + dc = scs.add_parser("decrypt", help="Print user, kind and password for every secret in a result envelope") + dc.add_argument("--key", required=True, help="Private key PEM") + dc.add_argument("--in", dest="infile", default="-", help="Result envelope JSON (default: stdin)") sub.add_parser("bootstrap-path", help="Print the path of the packaged Terraform state-backend module") sub.add_parser("commands", help="List supported job commands") args = p.parse_args(argv) @@ -147,6 +223,10 @@ def main(argv=None) -> int: return rag_cli.run(args) if args.cmd in ("packs", "launch", "destroy"): return _packs_main(args) + if args.cmd == "sweep-azure": + return _sweep_azure_main(args) + if args.cmd == "secrets": + return _secrets_main(args) if args.cmd == "commands": print("\n".join(sorted(COMMANDS))) return 0 diff --git a/labagent/packs/azure_labs/__init__.py b/labagent/packs/azure_labs/__init__.py new file mode 100644 index 0000000..72a9775 --- /dev/null +++ b/labagent/packs/azure_labs/__init__.py @@ -0,0 +1 @@ +"""azure-labs: simulate the Azure Lab Services classroom workflow on standard Azure VMs. An alternative, not Microsoft's service.""" diff --git a/labagent/packs/azure_labs/arm/network.json b/labagent/packs/azure_labs/arm/network.json new file mode 100644 index 0000000..bb84322 --- /dev/null +++ b/labagent/packs/azure_labs/arm/network.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "lab": {"type": "string"}, + "location": {"type": "string"}, + "tags": {"type": "object"} + }, + "variables": { + "gatewayPrefix": "10.20.0.0/24", + "studentPrefix": "10.20.1.0/24" + }, + "resources": [ + { + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2023-09-01", + "name": "[concat('nsg-gateway-', parameters('lab'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "securityRules": [ + { + "name": "allow-https", + "properties": { + "priority": 100, "direction": "Inbound", "access": "Allow", "protocol": "Tcp", + "sourceAddressPrefix": "Internet", "sourcePortRange": "*", + "destinationAddressPrefix": "*", "destinationPortRange": "443" + } + } + ] + } + }, + { + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2023-09-01", + "name": "[concat('nsg-students-', parameters('lab'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "securityRules": [ + { + "name": "allow-gateway-ssh-rdp", + "properties": { + "priority": 100, "direction": "Inbound", "access": "Allow", "protocol": "Tcp", + "sourceAddressPrefix": "[variables('gatewayPrefix')]", "sourcePortRange": "*", + "destinationAddressPrefix": "*", "destinationPortRanges": ["22", "3389"] + } + }, + { + "name": "deny-other-vnet", + "properties": { + "priority": 4000, "direction": "Inbound", "access": "Deny", "protocol": "*", + "sourceAddressPrefix": "VirtualNetwork", "sourcePortRange": "*", + "destinationAddressPrefix": "*", "destinationPortRange": "*" + } + } + ] + } + }, + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2023-09-01", + "name": "[concat('vnet-', parameters('lab'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/networkSecurityGroups', concat('nsg-gateway-', parameters('lab')))]", + "[resourceId('Microsoft.Network/networkSecurityGroups', concat('nsg-students-', parameters('lab')))]" + ], + "properties": { + "addressSpace": {"addressPrefixes": ["10.20.0.0/16"]}, + "subnets": [ + { + "name": "gateway", + "properties": { + "addressPrefix": "[variables('gatewayPrefix')]", + "networkSecurityGroup": {"id": "[resourceId('Microsoft.Network/networkSecurityGroups', concat('nsg-gateway-', parameters('lab')))]"} + } + }, + { + "name": "students", + "properties": { + "addressPrefix": "[variables('studentPrefix')]", + "networkSecurityGroup": {"id": "[resourceId('Microsoft.Network/networkSecurityGroups', concat('nsg-students-', parameters('lab')))]"} + } + } + ] + } + } + ], + "outputs": { + "vnet": {"type": "string", "value": "[concat('vnet-', parameters('lab'))]"} + } +} diff --git a/labagent/packs/azure_labs/azcli.py b/labagent/packs/azure_labs/azcli.py new file mode 100644 index 0000000..6e47c95 --- /dev/null +++ b/labagent/packs/azure_labs/azcli.py @@ -0,0 +1,115 @@ +"""Thin wrapper over the `az` CLI. The runner is injectable so tests never touch Azure. + +Secrets never go on a command line: values that must stay secret are written to a 0600 temp file for the deployment +parameters and deleted straight after. Error text is redacted before it reaches a log or the result envelope. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +from typing import Any, Dict, List, Optional + +from ..base import PackError + +SECRET = re.compile(r"(eyJ[\w-]{20,}\.[\w-]{10,}\.[\w-]{10,}|(?i:password|secret|key|token)\S*\s*[=:]\s*\S+)") +SCHEMA = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" + + +def redact(text: str, tail: int = 6) -> str: + return SECRET.sub("", "\n".join(str(text).strip().splitlines()[-tail:]))[:600] + + +class AzCli: + def __init__(self, subscription: Optional[str] = None, run=subprocess.run, binary: Optional[str] = None): + self.subscription = subscription + self._run = run + self._bin = binary + + def binary(self) -> str: + b = self._bin or shutil.which("az") + if not b: + raise PackError("Azure CLI not found. Install it (https://learn.microsoft.com/cli/azure/install-azure-cli) and run: az login") + return b + + def _exec(self, args: List[str]): + cmd = [self.binary(), *args, "--only-show-errors", "-o", "json"] + if self.subscription: + cmd += ["--subscription", self.subscription] + return self._run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") + + def json(self, *args: str) -> Any: + p = self._exec(list(args)) + if p.returncode != 0: + raise PackError(f"az {' '.join(args[:2])} failed: {redact(p.stderr or p.stdout)}") + return json.loads(p.stdout) if (p.stdout or "").strip() else {} + + # ---- account and capacity ------------------------------------------------------------------------------------------- + def account(self) -> Dict[str, Any]: + """The signed-in subscription: id, name. Raises a clear message when `az login` has not been run.""" + try: + return self.json("account", "show") + except PackError as e: + raise PackError(f"could not read the Azure login. Run: az login ({e})") from None + + def usage(self, region: str) -> Dict[str, Dict[str, int]]: + """{usage name: {used, limit}} for the region. vCPU totals are 'cores'; each VM family has its own key.""" + rows = self.json("vm", "list-usage", "-l", region) + return {r["name"]["value"]: {"used": int(r["currentValue"]), "limit": int(r["limit"])} for r in rows} + + def _skus(self, region: str) -> Dict[str, Dict[str, Any]]: + """Every VM size in the region, fetched once per run. `az vm list-skus --size` filters on the client, so one call is cheaper than several.""" + cache = self.__dict__.setdefault("_sku_cache", {}) + if region not in cache: + rows = self.json("vm", "list-skus", "-l", region, "--resource-type", "virtualMachines", + "--query", "[].{name:name,family:family,restricted:length(restrictions)}") + cache[region] = {r["name"]: r for r in rows} + return cache[region] + + def sku_family(self, size: str, region: str) -> str: + row = self._skus(region).get(size) + if row is None: + raise PackError(f"{size} is not available in {region}") + if row.get("restricted"): + raise PackError(f"{size} is restricted in {region} for this subscription. Pick another size or region.") + return row["family"] + + # ---- groups and resources ----------------------------------------------------------------------------------------- + def group_exists(self, name: str) -> bool: + return bool(self.json("group", "exists", "-n", name)) + + def group_show(self, name: str) -> Optional[Dict[str, Any]]: + return self.json("group", "show", "-n", name) if self.group_exists(name) else None + + def group_create(self, name: str, region: str, tags: Dict[str, str]) -> None: + self.json("group", "create", "-n", name, "-l", region, "--tags", *[f"{k}={v}" for k, v in tags.items()]) + + def group_delete(self, name: str) -> None: + self.json("group", "delete", "-n", name, "--yes") + + def groups_with_tag(self, key: str, value: str) -> List[Dict[str, Any]]: + return self.json("group", "list", "--tag", f"{key}={value}") or [] + + def resources(self, group: str) -> List[Dict[str, Any]]: + return self.json("resource", "list", "-g", group) or [] + + def resource_delete(self, rid: str) -> None: + self.json("resource", "delete", "--ids", rid) + + # ---- deployments (ARM templates) ------------------------------------------------------------------------------------ + def deploy(self, group: str, template: str, parameters: Dict[str, Any], name: str) -> Dict[str, Any]: + """Deploy an ARM template. Parameters go in a 0600 temp file, never on the command line, and the file is removed.""" + body = {"$schema": SCHEMA, "contentVersion": "1.0.0.0", "parameters": {k: {"value": v} for k, v in parameters.items()}} + fd, path = tempfile.mkstemp(prefix="labagent-az-", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(json.dumps(body)) + return self.json("deployment", "group", "create", "-g", group, "-n", name, "--template-file", template, "--parameters", f"@{path}") + finally: + try: + os.unlink(path) + except OSError: + pass diff --git a/labagent/packs/azure_labs/lifecycle.py b/labagent/packs/azure_labs/lifecycle.py new file mode 100644 index 0000000..a6a9228 --- /dev/null +++ b/labagent/packs/azure_labs/lifecycle.py @@ -0,0 +1,87 @@ +"""Quota arithmetic and tag-only teardown, shared by the pack and by `labagent sweep-azure`.""" +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Tuple + +from ..base import PackError +from . import safety + +# Delete in this order so dependants go first (a NIC cannot outlive its VM, a subnet needs its NSG until the VNet is gone). +ORDER = ["microsoft.compute/virtualmachines", "microsoft.compute/virtualmachinescalesets", "microsoft.devtestlab/schedules", + "microsoft.network/networkinterfaces", "microsoft.compute/disks", "microsoft.network/publicipaddresses", + "microsoft.network/natgateways", "microsoft.network/virtualnetworks", "microsoft.network/networksecuritygroups"] +ROUNDS = 4 + + +def peak_demand(size_family: str, vcpus: int, students: int, gateway_family: str, gateway_vcpus: int) -> Tuple[int, Dict[str, int]]: + """vCPUs needed at the busiest moment: (regional total, per family). + + The template build (one VM) and the class (students plus gateway) never run together, so take the larger of the two.""" + template = {size_family: vcpus} + klass: Dict[str, int] = {size_family: vcpus * students} + klass[gateway_family] = klass.get(gateway_family, 0) + gateway_vcpus + fams = {f: max(template.get(f, 0), klass.get(f, 0)) for f in set(template) | set(klass)} + return max(sum(template.values()), sum(klass.values())), fams + + +def check_quota(usage: Dict[str, Dict[str, int]], region_total: int, families: Dict[str, int], region: str) -> None: + def free(key: str) -> int: + u = usage.get(key) + return (u["limit"] - u["used"]) if u else 0 + short = [] + if free("cores") < region_total: + short.append(f"total regional vCPUs: need {region_total}, free {free('cores')}") + for fam, need in sorted(families.items()): + if free(fam) < need: + short.append(f"{fam}: need {need}, free {free(fam)}") + if short: + raise PackError(f"not enough vCPU quota in {region} ({'; '.join(short)}). Use fewer students or a smaller size, or ask for more: " + "Azure portal > Subscriptions > Usage + quotas.") + + +def _rank(rtype: str) -> int: + t = rtype.lower() + return ORDER.index(t) if t in ORDER else len(ORDER) + + +def _is_child(rtype: str) -> bool: + """microsoft.compute/virtualmachines/extensions: deleted with its parent.""" + return rtype.count("/") > 1 + + +def destroy_owned(az: Any, group: str, name: str, log: Callable[[str], None] = lambda m: None) -> Dict[str, Any]: + """Delete only the resources in `group` that carry lab `name`'s tags, then the group if labagent created it and it is empty. + + Raises PackError, deleting nothing more, when something without this lab's tags is in the group.""" + info = az.group_show(group) + if info is None: + return {"group_deleted": False, "resources_deleted": 0, "group_found": False} + if not safety.owned(info.get("tags"), name): + raise PackError(f"resource group {group} does not carry this lab's tags: refusing to touch it") + deleted = 0 + for _ in range(ROUNDS): + mine = [r for r in az.resources(group) if safety.owned(r.get("tags"), name) and not _is_child(r["type"])] + if not mine: + break + failed = 0 + for r in sorted(mine, key=lambda x: _rank(x["type"])): + try: + az.resource_delete(r["id"]) + deleted += 1 + log(f" deleted {r['type'].split('/')[-1]} {r['name']}") + except PackError: + failed += 1 # often a dependency not gone yet: the next round retries + if not failed: + break + left = az.resources(group) + mine_left = [r for r in left if safety.owned(r.get("tags"), name) and not _is_child(r["type"])] + foreign = [r for r in left if not safety.owned(r.get("tags"), name) and not _is_child(r["type"])] + if mine_left: + raise PackError(f"could not delete {len(mine_left)} resource(s) in {group}: {', '.join(sorted(r['name'] for r in mine_left))}. Run destroy again.") + if foreign: + raise PackError(f"{len(foreign)} resource(s) in {group} do not carry this lab's tags and were not deleted: " + f"{', '.join(sorted(r['name'] for r in foreign))}. Remove them yourself, then run destroy again.") + created = (info.get("tags") or {}).get("created_by_labagent") == "1" + if created: + az.group_delete(group) + return {"group_deleted": created, "resources_deleted": deleted, "group_found": True} diff --git a/labagent/packs/azure_labs/pack.py b/labagent/packs/azure_labs/pack.py new file mode 100644 index 0000000..a74a3fd --- /dev/null +++ b/labagent/packs/azure_labs/pack.py @@ -0,0 +1,126 @@ +"""azure-labs: rebuild the Azure Lab Services classroom workflow on standard Azure VMs. + +This is an alternative to Azure Lab Services, simulated with ordinary VMs. It is not Microsoft's service. +This first version creates the lab's resource group and network and tears them down by tag. Later steps add the template +image, student VMs, shutdown schedules, reset and the browser gateway. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List + +from ..base import Pack, PackContext, PackError, Param, Step +from . import lifecycle, safety +from .azcli import AzCli + +ARM_DIR = Path(__file__).parent / "arm" + + +class _LazyAz: + """Creates the real `az` wrapper on first use, inside a step, so `--dry-run` works without Azure installed.""" + + def __init__(self) -> None: + self._az = None + self.subscription = None + + def __getattr__(self, name: str): + if self._az is None: + self._az = AzCli(self.subscription) + return getattr(self._az, name) + + +class AzureLabs(Pack): + id = safety.PACK_ID + title = "Azure Labs: classroom VMs, an Azure Lab Services alternative" + summary = "Rebuild an Azure Lab Services class on standard Azure VMs, one per student" + aliases = ("azure-lab", "als-migration") + platforms = ("azure",) + cost_tier = "metered" + default_name = "lab" + params = [ + Param("os", "linux", "linux | windows-server"), + Param("vm_size", None, "Student VM size from the allow-list. Default: Standard_B2s (linux) or Standard_D2s_v3 (windows-server)"), + Param("nested", False, "Use a nested-virtualization size (for labs that run their own VMs)"), + Param("students", 3, "Number of student VMs"), + Param("max_students", safety.DEFAULT_MAX_STUDENTS, f"Hard cap on students (ceiling {safety.STUDENT_CEILING})"), + Param("region", "canadacentral", "Azure region"), + Param("expires_in_days", 14, "Tag the lab to expire after this many days (0 = never). `labagent sweep-azure` deletes expired labs"), + Param("cohort", "", "Cohort tag (default: the lab name)"), + Param("subscription", None, "Subscription ID or name (default: the one `az login` selected)"), + ] + output_keys = ("group", "region", "vm_size", "students") + + def services(self, profile): + return {"az": _LazyAz()} + + # ---- helpers ------------------------------------------------------------------------------------------------------------ + def _az(self, ctx: PackContext): + az = ctx.services["az"] + if getattr(az, "subscription", None) is None and ctx.params.get("subscription"): + az.subscription = ctx.params["subscription"] + return az + + def _login(self, ctx: PackContext) -> str: + az = self._az(ctx) + sub = az.account() + safety.check_subscription(sub["id"]) + return sub["id"] + + # ---- deploy ------------------------------------------------------------------------------------------------------------- + def _preflight(self, ctx: PackContext): + p = safety.validate(ctx.params) + sub = self._login(ctx) + az = self._az(ctx) + ctx.log(" checking VM sizes and quota in the region (the size list can take a minute) ...") + family = az.sku_family(p["vm_size"], p["region"]) + gw_family = az.sku_family(safety.GATEWAY_SIZE, p["region"]) + total, families = lifecycle.peak_demand(family, p["vcpus"], p["students"], gw_family, safety.SIZES[safety.GATEWAY_SIZE]) + lifecycle.check_quota(az.usage(p["region"]), total, families, p["region"]) + group = safety.group_name(ctx.name) + ctx.log(f" subscription allowed | group: {group} | {p['students']} x {p['vm_size']} in {p['region']} | peak vCPUs: {total}") + return {"subscription_id": sub, "group": group, "region": p["region"], "vm_size": p["vm_size"], "students": p["students"], + "vcpu_peak": total} + + def _network_done(self, ctx: PackContext) -> bool: + az = self._az(ctx) + group = safety.group_name(ctx.name) + if not az.group_exists(group): + return False + return any(r["type"].lower() == "microsoft.network/virtualnetworks" and safety.owned(r.get("tags"), ctx.name) + for r in az.resources(group)) + + def _network(self, ctx: PackContext): + az = self._az(ctx) + group, region = ctx.state["group"], ctx.state["region"] + existing = az.group_show(group) + created = False + if existing is None: + tags = safety.lab_tags(ctx.name, ctx.params.get("cohort"), int(ctx.params["expires_in_days"]), created_group=True) + az.group_create(group, region, tags) + created = True + elif not safety.owned(existing.get("tags"), ctx.name): + raise PackError(f"resource group {group} already exists and is not this lab's: refusing to use it. Pick another --name.") + tags = safety.lab_tags(ctx.name, ctx.params.get("cohort"), int(ctx.params["expires_in_days"])) + if existing is not None: # keep the original created_at and expires_at on a re-run + for k in ("created_at", "expires_at"): + tags[k] = existing["tags"].get(k, tags[k]) + az.deploy(group, str(ARM_DIR / "network.json"), {"lab": safety.lab_name(ctx.name), "location": region, "tags": tags}, "labagent-network") + ctx.log(f" network ready in {group}") + return {"network": True, "group_created": created or ctx.state.get("group_created", False)} + + def deploy_steps(self, ctx: PackContext) -> List[Step]: + return [ + Step("preflight", "Check the Azure login, subscription allow list, size and vCPU quota", self._preflight), + Step("network", "Create the lab resource group, virtual network and security groups", self._network, check=self._network_done), + ] + + # ---- destroy ------------------------------------------------------------------------------------------------------------ + def _destroy(self, ctx: PackContext): + self._login(ctx) + group = safety.group_name(ctx.name) + out = lifecycle.destroy_owned(self._az(ctx), group, ctx.name, ctx.log) + ctx.log(f" {'resource group deleted' if out['group_deleted'] else 'resource group kept'}; resources deleted: {out['resources_deleted']}") + return {"group_deleted": out["group_deleted"], "network": False} + + def destroy_steps(self, ctx: PackContext) -> List[Step]: + return [Step("destroy", "Delete this lab's tagged resources, then the group if labagent created it", self._destroy)] diff --git a/labagent/packs/azure_labs/safety.py b/labagent/packs/azure_labs/safety.py new file mode 100644 index 0000000..91a8986 --- /dev/null +++ b/labagent/packs/azure_labs/safety.py @@ -0,0 +1,131 @@ +"""Guards for azure-labs: subscription allow-list, names, tags, size and count caps. + +Same standard as the AWS engine: nothing runs unless the subscription is allowed, every resource is tagged, and +destroy only touches resources that carry this lab's tags. +""" +from __future__ import annotations + +import os +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from ..base import PackError + +PACK_ID = "azure-labs" +ALLOWED_ENV = "LABAGENT_ALLOWED_AZURE_SUBSCRIPTIONS" +DEFAULT_MAX_STUDENTS = 30 +STUDENT_CEILING = 100 +NEVER = "never" + +# size -> vCPUs. Only these may be used. The first group is the general list; NESTED is the subset for nested=true. +SIZES: Dict[str, int] = { + "Standard_B1s": 1, "Standard_B2s": 2, "Standard_B2ms": 2, + "Standard_D2s_v3": 2, "Standard_D4s_v3": 4, "Standard_D2s_v5": 2, "Standard_D4s_v5": 4, + "Standard_D8s_v5": 8, "Standard_E4s_v5": 4, +} +NESTED = {"Standard_D2s_v3", "Standard_D4s_v3", "Standard_D4s_v5", "Standard_D8s_v5", "Standard_E4s_v5"} +DEFAULT_SIZE = {"linux": "Standard_B2s", "windows-server": "Standard_D2s_v3"} +GATEWAY_SIZE = "Standard_B2s" +OS_CHOICES = tuple(DEFAULT_SIZE) + + +def slug(text: str, n: int = 40) -> str: + return re.sub(r"[^a-z0-9-]+", "-", str(text).lower()).strip("-")[:n].strip("-") + + +def lab_name(name: str) -> str: + s = slug(name) + if len(s) < 3: + raise PackError("the lab name must have at least 3 letters or digits after cleaning (lower case, a-z, 0-9 and -)") + return s + + +def group_name(name: str) -> str: + return f"rg-labagent-{PACK_ID}-{lab_name(name)}" + + +def images_group_name(name: str) -> str: + return group_name(name) + "-images" + + +def allowed_subscriptions(environ: Optional[Dict[str, str]] = None) -> List[str]: + raw = (environ if environ is not None else os.environ).get(ALLOWED_ENV, "") + return [s.strip().lower() for s in raw.split(",") if s.strip()] + + +def check_subscription(subscription_id: str, environ: Optional[Dict[str, str]] = None) -> None: + """Refuse to run unless the subscription is explicitly allowed. An empty list refuses everything.""" + allowed = allowed_subscriptions(environ) + if not allowed: + raise PackError(f"{ALLOWED_ENV} is empty: refusing to run. Set it to the subscription ID you may use for labs.") + if str(subscription_id).lower() not in allowed: + raise PackError(f"the current Azure subscription is not in {ALLOWED_ENV}: refusing to run.") + + +def validate(params: Dict[str, Any]) -> Dict[str, Any]: + """Check and normalise the parameters. Returns the values the steps use.""" + os_name = str(params.get("os") or "linux") + if os_name not in OS_CHOICES: + raise PackError(f"os must be one of: {', '.join(OS_CHOICES)}") + nested = bool(params.get("nested")) + size = params.get("vm_size") or ("Standard_D4s_v3" if nested else DEFAULT_SIZE[os_name]) + if size not in SIZES: + raise PackError(f"vm_size {size} is not allowed. Allowed: {', '.join(sorted(SIZES))}") + if nested and size not in NESTED: + raise PackError(f"nested=true needs a nested-virtualization size: {', '.join(sorted(NESTED))}") + try: + students = int(params.get("students")) + cap = int(params.get("max_students") or DEFAULT_MAX_STUDENTS) + except (TypeError, ValueError): + raise PackError("students and max_students must be whole numbers") + if not 1 <= cap <= STUDENT_CEILING: + raise PackError(f"max_students must be between 1 and {STUDENT_CEILING}") + if not 1 <= students <= cap: + raise PackError(f"students must be between 1 and max_students ({cap})") + region = str(params.get("region") or "") + if not re.fullmatch(r"[a-z0-9]{3,30}", region): + raise PackError("region must look like canadacentral") + try: + days = int(params.get("expires_in_days")) + except (TypeError, ValueError): + raise PackError("expires_in_days must be a whole number (0 means never)") + if not 0 <= days <= 365: + raise PackError("expires_in_days must be between 0 and 365") + return {"os": os_name, "vm_size": size, "vcpus": SIZES[size], "nested": nested, "students": students, + "max_students": cap, "region": region, "expires_in_days": days} + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def stamp(dt: datetime) -> str: + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_stamp(text: Any) -> Optional[datetime]: + try: + return datetime.strptime(str(text), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def lab_tags(name: str, cohort: str, days: int, now: Optional[datetime] = None, created_group: bool = False) -> Dict[str, str]: + now = now or utc_now() + tags = {"labagent": "1", "pack": PACK_ID, "name": lab_name(name), "cohort": slug(cohort or name) or lab_name(name), + "created_at": stamp(now), "expires_at": stamp(now + timedelta(days=days)) if days else NEVER} + if created_group: + tags["created_by_labagent"] = "1" + return tags + + +def owned(tags: Optional[Dict[str, str]], name: str) -> bool: + """True only for a resource that carries this lab's tags. Untagged or differently tagged resources are never ours.""" + t = tags or {} + return t.get("labagent") == "1" and t.get("pack") == PACK_ID and t.get("name") == lab_name(name) + + +def is_labagent_group(tags: Optional[Dict[str, str]]) -> bool: + t = tags or {} + return t.get("labagent") == "1" and t.get("pack") == PACK_ID diff --git a/labagent/packs/base.py b/labagent/packs/base.py index b321e69..41444cb 100644 --- a/labagent/packs/base.py +++ b/labagent/packs/base.py @@ -38,6 +38,7 @@ class PackContext: mem: Dict[str, Any] = field(default_factory=dict) # in-memory only (tokens, connections) services: Dict[str, Any] = field(default_factory=dict) # injected: dbx, pg_connect, ... (tests swap these) log: Callable[[str], None] = lambda m: print(m, file=sys.stderr) + pubkey_pem: Optional[str] = None # set when `launch --pubkey` is given; packs that mint passwords need it @dataclass @@ -121,7 +122,7 @@ def _seal_passwords(env: Envelope, ctx: PackContext, pubkey_pem: Optional[str]) return from ..secrets_box import encrypt, load_public_key key = load_public_key(pubkey_pem) - env.secrets = [{"user": user, "kind": "ide_password", "ciphertext": encrypt(key, pw)} for user, pw in sorted(secrets_in.items()) + env.secrets = [{"user": user, "kind": ctx.mem.get("password_kind", "ide_password"), "ciphertext": encrypt(key, pw)} for user, pw in sorted(secrets_in.items()) if not str(pw).startswith("(")] @@ -134,6 +135,7 @@ def run_pack(pack: Pack, action: str, name: str, given: Dict[str, Any], services return env.fail(str(e)) ctx = PackContext(pack.id, name, params, state=load_state(pack.id, name), services=services if services is not None else pack.services(params.get("profile"))) + ctx.pubkey_pem = pubkey_pem if log: ctx.log = log steps = pack.deploy_steps(ctx) if action == "launch" else pack.destroy_steps(ctx) diff --git a/labagent/packs/registry.py b/labagent/packs/registry.py index eb39689..f96bbac 100644 --- a/labagent/packs/registry.py +++ b/labagent/packs/registry.py @@ -13,7 +13,8 @@ def builtin() -> Dict[str, Pack]: from .aml.pack import AmlLab from .dbx_workspace.pack import DatabricksWorkspace from .dbx_lakehouse.pack import DatabricksLakehouse - return {p.id: p for p in (RagSecTutorial(), DbAgent(), RagIde(), AmlLab(), DatabricksWorkspace(), DatabricksLakehouse())} + from .azure_labs.pack import AzureLabs + return {p.id: p for p in (RagSecTutorial(), DbAgent(), RagIde(), AmlLab(), DatabricksWorkspace(), DatabricksLakehouse(), AzureLabs())} def all_packs() -> Dict[str, Pack]: diff --git a/labagent/secrets_box.py b/labagent/secrets_box.py index 24f940e..e50ed8a 100644 --- a/labagent/secrets_box.py +++ b/labagent/secrets_box.py @@ -34,3 +34,24 @@ def encrypt(public_key, plaintext: str) -> str: padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), ) return base64.b64encode(ct).decode() + + +def load_private_key(pem: str): + return serialization.load_pem_private_key(pem.encode(), password=None) + + +def decrypt(private_key, ciphertext_b64: str) -> str: + pt = private_key.decrypt( + base64.b64decode(ciphertext_b64), + padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + return pt.decode() + + +def generate_keypair(): + """A new RSA-3072 key pair as (private_pem, public_pem). The private key is unencrypted: keep it off shared disks.""" + from cryptography.hazmat.primitives.asymmetric import rsa + key = rsa.generate_private_key(public_exponent=65537, key_size=3072) + private = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode() + public = key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() + return private, public diff --git a/pyproject.toml b/pyproject.toml index 049727b..80df5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ labagent = [ "packs/*/app/static/*", "packs/*/tutorial/notebooks/*.py", "packs/*/sql/*/*.sql", + "packs/*/arm/*.json", "packs/*/guides/*.md", "ai/rag/lab/docs/*.md", ] diff --git a/tests/test_azure_labs.py b/tests/test_azure_labs.py new file mode 100644 index 0000000..44d85d0 --- /dev/null +++ b/tests/test_azure_labs.py @@ -0,0 +1,470 @@ +"""azure-labs, first slice: safety guards, az wrapper, network step, tag-only destroy, expiry sweep, key handling.""" +import json +import os +import stat +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from labagent import azure_sweep, secrets_box +from labagent.cli import main +from labagent.packs.azure_labs import lifecycle, safety +from labagent.packs.azure_labs.azcli import AzCli, redact +from labagent.packs.azure_labs.pack import AzureLabs +from labagent.packs.base import PackError, run_pack, state_path + +SUB = "00000000-0000-0000-0000-000000000001" +NOW = datetime(2026, 11, 1, 12, 0, 0, tzinfo=timezone.utc) +GROUP = "rg-labagent-azure-labs-demo" + + +class FakeAz: + """In-memory stand-in for AzCli. Records every call so tests can prove what was and was not touched.""" + + def __init__(self, sub=SUB, usage=None, restricted=False): + self.subscription = None + self.sub = sub + self.groups = {} # name -> {"location", "tags", "resources": [..]} + self.calls = [] + self.deploys = [] + self.restricted = restricted + self.undeletable = set() + self._usage = usage or {"cores": {"used": 0, "limit": 10}, "standardBSFamily": {"used": 0, "limit": 10}, + "standardDv3Family": {"used": 0, "limit": 10}} + + def account(self): + self.calls.append("account") + return {"id": self.sub, "name": "test"} + + def usage(self, region): + return self._usage + + def sku_family(self, size, region): + if self.restricted: + raise PackError(f"{size} is restricted in {region} for this subscription. Pick another size or region.") + return "standardBSFamily" if size.startswith("Standard_B") else "standardDv3Family" + + def group_exists(self, name): + return name in self.groups + + def group_show(self, name): + g = self.groups.get(name) + return {"name": name, "id": f"/subscriptions/{self.sub}/resourceGroups/{name}", "location": g["location"], "tags": dict(g["tags"])} if g else None + + def group_create(self, name, region, tags): + self.calls.append(("group_create", name)) + self.groups[name] = {"location": region, "tags": dict(tags), "resources": []} + + def group_delete(self, name): + self.calls.append(("group_delete", name)) + del self.groups[name] + + def groups_with_tag(self, key, value): + return [self.group_show(n) for n, g in self.groups.items() if g["tags"].get(key) == value] + + def resources(self, group): + return [dict(r) for r in self.groups[group]["resources"]] + + def resource_delete(self, rid): + self.calls.append(("resource_delete", rid)) + if rid in self.undeletable: + raise PackError("az resource delete failed: in use") + for g in self.groups.values(): + g["resources"] = [r for r in g["resources"] if r["id"] != rid] + + def deploy(self, group, template, parameters, name): + self.calls.append(("deploy", group)) + self.deploys.append((group, template, parameters)) + tags = parameters["tags"] + for rtype, rname in (("Microsoft.Network/virtualNetworks", "vnet-x"), ("Microsoft.Network/networkSecurityGroups", "nsg-x")): + self.groups[group]["resources"].append({"id": f"{group}/{rname}", "name": rname, "type": rtype, "tags": dict(tags)}) + return {} + + def add(self, group, name, rtype, tags): + self.groups[group]["resources"].append({"id": f"{group}/{name}", "name": name, "type": rtype, "tags": tags}) + + +@pytest.fixture(autouse=True) +def env(tmp_path, monkeypatch): + monkeypatch.setenv("LABAGENT_HOME", str(tmp_path)) + monkeypatch.setenv(safety.ALLOWED_ENV, SUB) + + +def launch(az, **params): + return run_pack(AzureLabs(), "launch", "demo", params, services={"az": az}, log=lambda m: None) + + +def destroy(az, name="demo"): + return run_pack(AzureLabs(), "destroy", name, {}, services={"az": az}, log=lambda m: None) + + +# ---- allow-list and validation ------------------------------------------------------------------------------------------ +def test_refuses_when_the_allow_list_is_empty_or_does_not_list_the_subscription(monkeypatch): + for value in ("", "11111111-1111-1111-1111-111111111111"): + monkeypatch.setenv(safety.ALLOWED_ENV, value) + az = FakeAz() + env = launch(az) + assert env.status == "failed" and "refusing to run" in env.error + assert not az.groups and ("group_create", GROUP) not in az.calls + + +def test_the_allow_list_is_case_insensitive_and_takes_a_comma_list(monkeypatch): + monkeypatch.setenv(safety.ALLOWED_ENV, f"other, {SUB.upper()}") + assert launch(FakeAz()).status == "ok" + + +@pytest.mark.parametrize("params,msg", [ + ({"vm_size": "Standard_M128s"}, "not allowed"), + ({"nested": True, "vm_size": "Standard_B2s"}, "nested-virtualization"), + ({"students": 31}, "max_students"), + ({"students": 0}, "between 1"), + ({"max_students": 101}, "between 1 and 100"), + ({"region": "Canada Central"}, "region"), + ({"os": "windows-10"}, "os must be"), + ({"expires_in_days": 400}, "between 0 and 365"), +]) +def test_bad_parameters_are_refused_before_anything_is_created(params, msg): + az = FakeAz() + env = launch(az, **params) + assert env.status == "failed" and msg in env.error and not az.groups + + +def test_defaults_follow_the_os_and_nested_uses_a_nested_size(): + base = {"students": 3, "region": "canadacentral", "expires_in_days": 1} + assert safety.validate({"os": "linux", **base})["vm_size"] == "Standard_B2s" + assert safety.validate({"os": "windows-server", **base})["vm_size"] == "Standard_D2s_v3" + assert safety.validate({"os": "linux", "nested": True, **base})["vm_size"] in safety.NESTED + + +def test_names_are_cleaned_and_must_be_long_enough(): + assert safety.group_name("Demo Lab_1") == "rg-labagent-azure-labs-demo-lab-1" + with pytest.raises(PackError): + safety.lab_name("a!") + + +# ---- quota ------------------------------------------------------------------------------------------------------------ +def test_quota_counts_the_busiest_phase_not_the_sum(): + total, fams = lifecycle.peak_demand("standardBSFamily", 2, 3, "standardBSFamily", 2) + assert total == 8 and fams == {"standardBSFamily": 8} # 3 students x 2 + gateway 2; the template phase is smaller + total, fams = lifecycle.peak_demand("standardDv3Family", 2, 2, "standardBSFamily", 2) + assert total == 6 and fams == {"standardDv3Family": 4, "standardBSFamily": 2} + + +def test_not_enough_quota_names_the_family_and_creates_nothing(): + az = FakeAz(usage={"cores": {"used": 0, "limit": 10}, "standardBSFamily": {"used": 0, "limit": 10}, "standardDv3Family": {"used": 0, "limit": 0}}) + env = launch(az, os="windows-server", students=2) + assert env.status == "failed" and "standardDv3Family: need 4, free 0" in env.error and not az.groups + + +def test_regional_quota_is_checked_too(): + env = launch(FakeAz(), students=5) # 5 x 2 + 2 = 12 > 10 + assert env.status == "failed" and "total regional vCPUs: need 12, free 10" in env.error + + +def test_a_restricted_size_is_reported(): + env = launch(FakeAz(restricted=True)) + assert env.status == "failed" and "restricted" in env.error + + +# ---- network step ----------------------------------------------------------------------------------------------------- +def test_launch_creates_a_tagged_group_and_network_and_rerun_skips_it(): + az = FakeAz() + env = launch(az, students=3, expires_in_days=7) + assert env.status == "ok" and [i["state"] for i in env.items] == ["done", "done"] + g = az.groups[GROUP] + assert g["tags"]["labagent"] == "1" and g["tags"]["pack"] == "azure-labs" and g["tags"]["created_by_labagent"] == "1" + assert g["tags"]["expires_at"] != safety.NEVER and {"created_at", "name", "cohort"} <= set(g["tags"]) + assert all(r["tags"]["labagent"] == "1" for r in g["resources"]) + assert "created_by_labagent" not in g["resources"][0]["tags"] + again = launch(az, students=3, expires_in_days=7) + assert [i["state"] for i in again.items] == ["done", "skipped"] # preflight always runs; the network is already there + assert az.calls.count(("group_create", GROUP)) == 1 + assert env.summary["outputs"]["group"] == GROUP + + +def test_expires_in_days_zero_means_never(): + az = FakeAz() + launch(az, expires_in_days=0) + assert az.groups[GROUP]["tags"]["expires_at"] == safety.NEVER + + +def test_a_group_with_the_same_name_that_is_not_ours_is_refused(): + az = FakeAz() + az.groups[GROUP] = {"location": "canadacentral", "tags": {"team": "finance"}, "resources": []} + env = launch(az) + assert env.status == "failed" and "not this lab's" in env.error and not az.deploys + + +def test_the_network_template_ships_and_has_no_public_inbound_except_443(): + t = json.loads((Path(__file__).parent.parent / "labagent/packs/azure_labs/arm/network.json").read_text(encoding="utf-8")) + rules = {r["name"]: r["properties"] for res in t["resources"] if "securityRules" in res["properties"] for r in res["properties"]["securityRules"]} + assert rules["allow-https"]["destinationPortRange"] == "443" and rules["allow-https"]["sourceAddressPrefix"] == "Internet" + assert rules["allow-gateway-ssh-rdp"]["sourceAddressPrefix"] == "[variables('gatewayPrefix')]" + assert rules["deny-other-vnet"]["access"] == "Deny" + assert all(r["tags"] == "[parameters('tags')]" for r in t["resources"]) + + +# ---- destroy ----------------------------------------------------------------------------------------------------------- +def test_destroy_deletes_tagged_resources_then_the_group_and_the_state(): + az = FakeAz() + launch(az) + assert state_path("azure-labs", "demo").exists() + env = destroy(az) + assert env.status == "ok" and GROUP not in az.groups + assert not state_path("azure-labs", "demo").exists() + + +def test_destroy_never_deletes_an_untagged_resource_and_keeps_the_group(): + az = FakeAz() + launch(az) + az.add(GROUP, "precious-disk", "Microsoft.Compute/disks", {}) + az.add(GROUP, "other-lab-vm", "Microsoft.Compute/virtualMachines", {"labagent": "1", "pack": "azure-labs", "name": "someone-else"}) + env = destroy(az) + assert env.status == "failed" and "precious-disk" in env.error and "other-lab-vm" in env.error + deleted = [c[1] for c in az.calls if isinstance(c, tuple) and c[0] == "resource_delete"] + assert f"{GROUP}/precious-disk" not in deleted and f"{GROUP}/other-lab-vm" not in deleted + assert GROUP in az.groups and ("group_delete", GROUP) not in az.calls + assert {r["name"] for r in az.groups[GROUP]["resources"]} == {"precious-disk", "other-lab-vm"} # the lab's own network is gone + + +def test_destroy_deletes_dependants_first(): + az = FakeAz() + launch(az) + tags = dict(az.groups[GROUP]["resources"][0]["tags"]) + az.add(GROUP, "nic-1", "Microsoft.Network/networkInterfaces", tags) + az.add(GROUP, "vm-1", "Microsoft.Compute/virtualMachines", tags) + destroy(az) + order = [c[1].split("/")[-1] for c in az.calls if isinstance(c, tuple) and c[0] == "resource_delete"] + assert order.index("vm-1") < order.index("nic-1") < order.index("vnet-x") < order.index("nsg-x") + + +def test_destroy_reports_a_resource_it_cannot_delete(): + az = FakeAz() + launch(az) + az.undeletable.add(f"{GROUP}/vnet-x") + env = destroy(az) + assert env.status == "failed" and "vnet-x" in env.error and GROUP in az.groups + + +def test_destroy_keeps_a_group_labagent_did_not_create_and_refuses_a_foreign_one(): + az = FakeAz() + tags = safety.lab_tags("demo", "", 7, NOW) # tagged for this lab, but without created_by_labagent + az.groups[GROUP] = {"location": "canadacentral", "tags": tags, "resources": []} + az.add(GROUP, "vnet-1", "Microsoft.Network/virtualNetworks", tags) + env = destroy(az) + assert env.status == "ok" and GROUP in az.groups and az.groups[GROUP]["resources"] == [] + az.groups["rg-labagent-azure-labs-other"] = {"location": "canadacentral", "tags": {"team": "x"}, "resources": []} + env = destroy(az, "other") + assert env.status == "failed" and "refusing to touch" in env.error + + +def test_destroy_with_no_group_is_a_clean_no_op(): + assert destroy(FakeAz()).status == "ok" + + +def test_destroy_also_checks_the_allow_list(monkeypatch): + monkeypatch.setenv(safety.ALLOWED_ENV, "") + az = FakeAz() + az.groups[GROUP] = {"location": "x", "tags": safety.lab_tags("demo", "", 7), "resources": []} + assert destroy(az).status == "failed" and GROUP in az.groups + + +def test_dry_run_makes_no_azure_call(): + az = FakeAz() + env = run_pack(AzureLabs(), "launch", "demo", {}, services={"az": az}, dry_run=True) + assert env.status == "ok" and az.calls == [] + + +# ---- sweep ---------------------------------------------------------------------------------------------------------------- +def lab_group(az, name, expires, role=None, pack="azure-labs"): + tags = {"labagent": "1", "pack": pack, "name": name, "cohort": name, "created_at": safety.stamp(NOW - timedelta(days=9)), "expires_at": expires} + if role: + tags["role"] = role + g = f"rg-labagent-azure-labs-{name}" + az.groups[g] = {"location": "canadacentral", "tags": {**tags, "created_by_labagent": "1"}, "resources": []} + az.add(g, "vnet-1", "Microsoft.Network/virtualNetworks", tags) + return g + + +def approved(az, now=NOW): + env = azure_sweep.sweep_plan(az, SUB, now) + return {**env.plan, "items": env.items} + + +def test_sweep_plan_lists_only_expired_labagent_labs(): + az = FakeAz() + lab_group(az, "old", safety.stamp(NOW - timedelta(days=1))) + lab_group(az, "fresh", safety.stamp(NOW + timedelta(days=3))) + lab_group(az, "forever", safety.NEVER) + lab_group(az, "oldimages", safety.stamp(NOW - timedelta(days=1)), role="images") + lab_group(az, "otherpack", safety.stamp(NOW - timedelta(days=1)), pack="not-ours") + env = azure_sweep.sweep_plan(az, SUB, NOW) + by = {i["id"]: i["action"] for i in env.items} + assert by["rg-labagent-azure-labs-old"] == "delete" + assert [by[f"rg-labagent-azure-labs-{n}"] for n in ("fresh", "forever", "oldimages")] == ["skip", "skip", "skip"] + assert "rg-labagent-azure-labs-otherpack" not in by + assert env.summary == {"to_delete": 1, "skipped": 3} and env.plan["hash"] + + +def test_sweep_apply_needs_the_approved_hash_and_deletes_only_the_plan(): + az = FakeAz() + g = lab_group(az, "old", safety.stamp(NOW - timedelta(days=1))) + plan = approved(az) + bad = azure_sweep.sweep_apply(az, SUB, plan, "0" * 64, NOW) + assert bad.status == "failed" and "approved hash" in bad.error and g in az.groups + lab_group(az, "later", safety.stamp(NOW - timedelta(days=1))) # expired after the plan was made: must not be deleted + env = azure_sweep.sweep_apply(az, SUB, plan, plan["hash"], NOW) + assert env.status == "ok" and g not in az.groups and "rg-labagent-azure-labs-later" in az.groups + + +def test_sweep_apply_skips_a_renewed_lab_and_rejects_an_old_plan_and_a_wrong_subscription(monkeypatch): + az = FakeAz() + g = lab_group(az, "old", safety.stamp(NOW - timedelta(days=1))) + plan = approved(az) + az.groups[g]["tags"]["expires_at"] = safety.stamp(NOW + timedelta(days=5)) + env = azure_sweep.sweep_apply(az, SUB, plan, plan["hash"], NOW) + assert env.items[0]["state"] == "skipped" and g in az.groups + late = azure_sweep.sweep_apply(az, SUB, plan, plan["hash"], NOW + timedelta(hours=25)) + assert late.status == "failed" and "expired" in late.error + monkeypatch.setenv(safety.ALLOWED_ENV, "11111111-1111-1111-1111-111111111111") + with pytest.raises(PackError): + azure_sweep.sweep_plan(az, SUB, NOW) + + +def test_sweep_apply_never_deletes_an_untagged_resource_in_an_expired_lab(): + az = FakeAz() + g = lab_group(az, "old", safety.stamp(NOW - timedelta(days=1))) + az.add(g, "precious", "Microsoft.Compute/disks", {}) + plan = approved(az) + env = azure_sweep.sweep_apply(az, SUB, plan, plan["hash"], NOW) + assert env.status == "partial" and env.items[0]["state"] == "failed" and "precious" in env.items[0]["error"] + assert g in az.groups and [r["name"] for r in az.groups[g]["resources"]] == ["precious"] + + +# ---- the az wrapper ----------------------------------------------------------------------------------------------------- +class Proc: + def __init__(self, out="", err="", rc=0): + self.stdout, self.stderr, self.returncode = out, err, rc + + +def test_azcli_builds_commands_with_json_output_and_the_subscription(): + seen = [] + + def run(cmd, **kw): + seen.append((cmd, kw)) + return Proc("[]") + AzCli("sub-1", run=run, binary="/usr/bin/az").resources("rg-x") + cmd, kw = seen[0] + assert cmd == ["/usr/bin/az", "resource", "list", "-g", "rg-x", "--only-show-errors", "-o", "json", "--subscription", "sub-1"] + assert kw["encoding"] == "utf-8" and kw["text"] is True + + +def test_azcli_errors_are_redacted_and_never_include_the_arguments(): + az = AzCli(run=lambda cmd, **kw: Proc(err="ERROR: bad request adminPassword=Sup3rSecret! and token: abcdef123456", rc=1), binary="az") + with pytest.raises(PackError) as e: + az.json("vm", "create", "--admin-password", "Sup3rSecret!") + msg = str(e.value) + assert "Sup3rSecret" not in msg and "abcdef123456" not in msg and "" in msg + assert "Sup3rSecret" not in redact("password: Sup3rSecret!") + + +def test_azcli_group_exists_and_a_missing_az_binary(monkeypatch): + assert AzCli(run=lambda c, **k: Proc("true"), binary="az").group_exists("g") is True + assert AzCli(run=lambda c, **k: Proc("false"), binary="az").group_exists("g") is False + monkeypatch.setattr("shutil.which", lambda n: None) + with pytest.raises(PackError, match="Azure CLI not found"): + AzCli().json("account", "show") + + +def test_azcli_deploy_keeps_secrets_off_the_command_line_and_removes_the_file(): + seen = {} + + def run(cmd, **kw): + path = cmd[cmd.index("--parameters") + 1].lstrip("@") + seen.update(cmd=cmd, path=path, mode=stat.S_IMODE(os.stat(path).st_mode), body=json.loads(open(path, encoding="utf-8").read())) + return Proc("{}") + AzCli(run=run, binary="az").deploy("rg", "t.json", {"adminPassword": "Sup3rSecret!", "lab": "x"}, "d1") + assert "Sup3rSecret!" not in " ".join(seen["cmd"]) and seen["mode"] == 0o600 + assert seen["body"]["parameters"]["adminPassword"] == {"value": "Sup3rSecret!"} + assert not os.path.exists(seen["path"]) + + +def test_azcli_usage_and_sku_family_parse_az_output(): + usage = [{"name": {"value": "cores"}, "currentValue": "2", "limit": "10"}] + assert AzCli(run=lambda c, **k: Proc(json.dumps(usage)), binary="az").usage("canadacentral") == {"cores": {"used": 2, "limit": 10}} + calls = [] + + def run(c, **k): + calls.append(c) + return Proc(json.dumps([{"name": "Standard_B2s", "family": "standardBSFamily", "restricted": 0}, + {"name": "Standard_D2s_v5", "family": "standardDSv5Family", "restricted": 1}])) + az = AzCli(run=run, binary="az") + assert az.sku_family("Standard_B2s", "canadacentral") == "standardBSFamily" + with pytest.raises(PackError, match="restricted"): + az.sku_family("Standard_D2s_v5", "canadacentral") + with pytest.raises(PackError, match="not available"): + az.sku_family("Standard_Nope", "canadacentral") + assert len(calls) == 1 # one az call for every lookup in the region + + +# ---- keys and secrets ----------------------------------------------------------------------------------------------------- +def test_keypair_round_trip_and_the_cli_decrypts_an_envelope(tmp_path, capsys): + assert main(["secrets", "keygen", "--dir", str(tmp_path)]) == 0 + priv, pub = tmp_path / "labagent-private.pem", tmp_path / "labagent-public.pem" + assert stat.S_IMODE(priv.stat().st_mode) == 0o600 + assert main(["secrets", "keygen", "--dir", str(tmp_path)]) == 2 # never overwrite a key + ct = secrets_box.encrypt(secrets_box.load_public_key(pub.read_text()), "Pa55-word") + result = tmp_path / "result.json" + result.write_text(json.dumps({"secrets": [{"user": "student-01", "kind": "vm_password", "ciphertext": ct}]})) + capsys.readouterr() + assert main(["secrets", "decrypt", "--key", str(priv), "--in", str(result)]) == 0 + assert capsys.readouterr().out.strip() == "student-01\tvm_password\tPa55-word" + assert main(["secrets", "decrypt", "--key", str(tmp_path / "nope.pem"), "--in", str(result)]) == 2 + + +def test_passwords_a_pack_creates_are_sealed_with_the_pubkey_and_use_the_pack_kind(): + from labagent.packs.base import Pack, Step + priv, pub = secrets_box.generate_keypair() + + class Mint(Pack): + id = "mint" + params = [] + + def deploy_steps(self, ctx): + def run(c): + c.mem["passwords"] = {"s1": "Secret-1"} + c.mem["password_kind"] = "vm_password" + return {} + return [Step("mint", "Make a password", run)] + + def destroy_steps(self, ctx): + return [] + env = run_pack(Mint(), "launch", "x", {}, services={}, pubkey_pem=pub) + assert env.secrets[0]["kind"] == "vm_password" and "Secret-1" not in json.dumps(env.to_dict()) + assert secrets_box.decrypt(secrets_box.load_private_key(priv), env.secrets[0]["ciphertext"]) == "Secret-1" + assert run_pack(Mint(), "launch", "y", {}, services={}).secrets == [] # no key: nothing leaves + + +def test_launch_reads_the_pubkey_from_the_flag_or_the_env(tmp_path, monkeypatch): + from labagent import cli + _, pub = secrets_box.generate_keypair() + f = tmp_path / "k.pem" + f.write_text(pub) + assert cli._read_pubkey(str(f)) == pub + monkeypatch.setenv("LABAGENT_PUBKEY", str(f)) + assert cli._read_pubkey(None) == pub + monkeypatch.delenv("LABAGENT_PUBKEY") + assert cli._read_pubkey(None) is None + + +def test_the_new_commands_are_not_sent_to_the_plain_english_translator(): + from labagent.cli import KNOWN + assert {"sweep-azure", "secrets"} <= KNOWN + + +def test_packs_json_lists_azure_labs_with_no_credential(capsys): + assert main(["packs", "--json"]) == 0 + out = capsys.readouterr().out + cat = {p["id"]: p for p in json.loads(out)["packs"]} + assert cat["azure-labs"]["platforms"] == ["azure"] and SUB not in out diff --git a/tests/test_nl.py b/tests/test_nl.py index d15937a..a63f334 100644 --- a/tests/test_nl.py +++ b/tests/test_nl.py @@ -94,7 +94,7 @@ def test_prompt_is_small_lists_real_packs_and_asks_only_for_action_and_pack(): sys_prompt = msgs[0]["content"] assert "rag-sec-tutorial" in sys_prompt and "rag-tutorial" in sys_prompt and "db-agent" in sys_prompt and "rag-ide" in sys_prompt assert "idle_minutes" not in sys_prompt and "profile" not in sys_prompt # parameters are not the model's job - assert len(sys_prompt) < 2500 # short enough for a 0.5b model to follow + assert len(sys_prompt) < 3000 # short enough for a 0.5b model to follow; each pack adds about 300 assert msgs[1]["content"] == "Request: deploy rag-tutorial" diff --git a/tests/test_packs.py b/tests/test_packs.py index 84ec472..bf877cf 100644 --- a/tests/test_packs.py +++ b/tests/test_packs.py @@ -75,7 +75,7 @@ def go(c): def test_registry_lists_the_current_packs_and_the_cli_refuses_unknown_ones(capsys, home): - assert sorted(registry.all_packs()) == ["aml", "db-agent", "dbx-lakehouse", "dbx-workspace", "rag-ide", "rag-sec-tutorial"] + assert sorted(registry.all_packs()) == ["aml", "azure-labs", "db-agent", "dbx-lakehouse", "dbx-workspace", "rag-ide", "rag-sec-tutorial"] assert main(["packs"]) == 0 and "rag-sec-tutorial" in capsys.readouterr().out assert main(["launch", "nope", "--name", "x"]) == 2 @@ -149,7 +149,7 @@ def test_an_explicit_profile_is_never_overridden(home): def test_packs_json_describes_every_pack_with_platforms_and_params(capsys, home): assert main(["packs", "--json"]) == 0 cat = {p["id"]: p for p in json.loads(capsys.readouterr().out)["packs"]} - assert sorted(cat) == ["aml", "db-agent", "dbx-lakehouse", "dbx-workspace", "rag-ide", "rag-sec-tutorial"] + assert sorted(cat) == ["aml", "azure-labs", "db-agent", "dbx-lakehouse", "dbx-workspace", "rag-ide", "rag-sec-tutorial"] assert cat["rag-ide"]["platforms"] == ["local", "ec2", "modal"] and cat["rag-ide"]["cost_tier"] == "metered" assert {"name": "hours", "default": 4, "help": "Hard cap per session, 1 to 24", "required": False} in cat["rag-ide"]["params"] assert all(p["title"] and p["summary"] and p["platforms"] and p["cost_tier"] for p in cat.values())