Project: Dummy.Lab Author: Tom Stryhn Status: v1.0.0 Last updated: 2026-04-14
A Hyper-V lab automation platform that deploys isolated Windows Server AD lab environments from a single command. Uses golden images (sysprepped VHDXs built from ISOs) as parents for fast differencing disks. Multiple labs can coexist, each fully isolated with its own network.
- Composable - create a lab, then add VMs as needed. No rigid scenarios.
- Pipeline-native - typed output from every cmdlet; one-line end-to-end pipelines work.
- Multi-version - mix Server 2016, 2019, 2022, and 2025 in the same lab.
- Golden image pattern - build once, deploy many. Differencing disks make VM creation near-instant.
- Idempotent - every cmdlet is safe to re-run. State is detected, not assumed.
- Isolated by default - each lab has its own Hyper-V Internal switch and /27 subnet. Labs cannot reach each other. Internet access is on by default and can be disabled at creation (
-NoInternet) or toggled on running labs (Set-DLabInternet). - Conflict-safe - pre-flight validation checks VM names, switch names, IP ranges, and NAT objects.
- One module - single
DummyLabmodule, no nested modules, no peer modules. - Source-to-build - module functions live as individual files in
src/. RunScripts\Build-DummyLab.ps1to assemble. - Parallel deploys - file-locked state allows adding multiple VMs simultaneously.
- Edition-aware - each ISO exposes all available editions (DC, DC Core, Standard, Standard Core). One ISO, many golden images.
- Cached discovery - ISO scan results cached per-file. First scan mounts; subsequent calls are instant.
- Historical images - date-stamped golden images allow reproducing specific patch levels for troubleshooting.
- Central audit trail - every operation becomes a
DLab.Operationdocument, every narrative line becomes aDLab.Eventin a JSONL log. Both survive lab teardown.
# 1. Install (one-liner)
Invoke-Expression (Invoke-WebRequest 'https://raw.githubusercontent.com/tomstryhn/Dummy.Lab/main/Install-Dummy.Lab.ps1' -UseBasicParsing).Content
# 2. Load the module
Import-Module C:\Dummy.Lab\Scripts\Modules\DummyLab\DummyLab.psd1
# 3. Host preflight
Test-DLabHost
# 4. Place ISOs and build golden images
# Copy ISOs to C:\Dummy.Lab\ISOs\
Find-DLabISO
New-DLabGoldenImage -OSKey WS2025_DC -SkipUpdates# Create the default lab (named 'Dummy') with a DC
New-DLab
# Or create a named lab
New-DLab -LabName ProdTest
# Add VMs as needed - uses the default lab when -LabName is omitted
Add-DLabVM -Role Server
Add-DLabVM -LabName ProdTest -Role Server -VMName SRV02
# One-line end-to-end composition
Get-DLabGoldenImage -OSKey WS2025_DC | Sort-Object BuildDate -Descending | Select-Object -First 1 |
New-DLab -LabName Demo |
Add-DLabVM -Role Server
# Inspect
Get-DLab
Get-DLabVM -LabName ProdTest
Test-DLab -Name ProdTest
# Remove a single VM (cleans up AD object too). Remove-* cmdlets do NOT
# fall back to the default LabName - destructive ops must always name
# their target explicitly.
Remove-DLabVM -LabName ProdTest -Name SRV02 -Confirm:$false
# Tear down entire lab (golden images preserved)
Remove-DLab -Name ProdTest -Confirm:$falseFor the full cmdlet cheat-sheet, run .\DLab.ps1 all from the installed root.
Module source lives in Scripts\src\DummyLab\. The assembled module in Scripts\Modules\DummyLab\ is build output.
Scripts\src\DummyLab\
DummyLab.psd1 <- Source manifest
Config\DLab.Defaults.psd1 <- Bundled defaults
Formats\DummyLab.Format.ps1xml <- Typed output formatter
Private\ <- Internal helpers (one function per file)
Public\ <- Exported cmdlets (one function per file)
.\Scripts\Build-DummyLab.ps1 # Build the module
.\Scripts\Build-DummyLab.ps1 -Validate # Build, syntax-check, Test-ModuleManifest
.\Scripts\Build-DummyLab.ps1 -Clean # Remove output dir before buildingRule: Edit in src/, run Build-DummyLab.ps1. Never hand-edit Scripts\Modules\DummyLab\DummyLab.psm1.
DummyLab has a single source of truth for defaults: Scripts\src\DummyLab\Config\DLab.Defaults.psd1 (built to Scripts\Modules\DummyLab\DLab.Defaults.psd1). Every cmdlet that needs a default value reads it via Get-DLabConfig, including the golden-image build path.
Do not edit the bundled defaults. Override at install-time by creating either of:
%APPDATA%\DummyLab\config.psd1- user override, picked up automatically.$env:DUMMYLAB_CONFIG- path to a.psd1file, takes precedence over the user override.
The override file only needs the keys you want to change. Unspecified keys fall through to the bundled defaults.
OS-specific data (build numbers, WIM patterns, golden prefixes) lives in Scripts\Config\OS.Catalog.psd1. That file is a separate concern and is consumed by Get-DLabCatalog, Find-DLabISO, and the golden-image build plan.
Defined in Scripts\Config\OS.Catalog.psd1. ISOs are identified by WIM build number (read from ISO metadata, not filename). Each OS version supports multiple editions from a single ISO.
Each OS version has 4 edition keys plus a short alias that defaults to Datacenter with GUI:
| Alias | Resolves to | Edition |
|---|---|---|
WS2025 |
WS2025-DC |
Datacenter (Desktop Experience) |
WS2025-DC-CORE |
Datacenter (Server Core) | |
WS2025-STD |
Standard (Desktop Experience) | |
WS2025-STD-CORE |
Standard (Server Core) |
Same pattern for WS2022, WS2019, WS2016. Users type dashes (-OSKey WS2019-DC-CORE), internally normalized to underscores for PSD1 compatibility.
| OS | Build | Editions |
|---|---|---|
| Windows Server 2025 | 26100 | 4 (DC, DC-CORE, STD, STD-CORE) |
| Windows Server 2022 | 20348 | 4 |
| Windows Server 2019 | 17763 | 4 |
| Windows Server 2016 | 14393 | 4 |
Each catalog entry has a WIMImageName wildcard pattern matched against WIM ImageName. Wildcards handle naming differences across ISO types (Evaluation ISOs include "Evaluation" in the image name, e.g. "Datacenter Evaluation (Desktop Experience)"). Core editions use WIMImageExclude to exclude Desktop Experience images.
First scan mounts each ISO and reads WIM metadata. Results are cached per-ISO in .scan-cache\ as JSON files, validated by ISO file size + last-modified date. Subsequent calls read from cache (no mounting).
- Place the ISO in
C:\Dummy.Lab\ISOs\. - Add edition entries to
Scripts\Config\OS.Catalog.psd1with:BuildNumber,WIMImageName,GoldenPrefix. - Run
Find-DLabISOto verify detection. - Run
New-DLabGoldenImage -OSKey <newkey>to build.
No code changes required.
The shared supernet is 10.74.18.0/23. One DLab-NAT NetNat object covers the entire CIDR. Windows only supports one user-created NetNat, so a single shared NAT serves all segments.
The supernet divides into 16 /27 segments (32 addresses each):
| Segment | CIDR | Role |
|---|---|---|
| 0 | 10.74.18.0/27 |
Staging (golden-image builds) |
| 1 | 10.74.18.32/27 |
Lab 1 |
| 2 | 10.74.18.64/27 |
Lab 2 |
| ... | ... | ... |
| 15 | 10.74.19.224/27 |
Lab 15 |
Address layout within each /27 (base = segment network address):
| Offset | Role |
|---|---|
| base+0 | Network address |
| base+1 | Gateway (host vEthernet adapter) |
| base+2 to base+5 | Domain Controllers (static, 4 slots) |
| base+6 to base+21 | Member Servers (static, 16 slots) |
| base+22 to base+30 | DHCP pool (9 addresses) |
| base+31 | Broadcast |
Static IPs fall below the DHCP pool. No DHCP exclusion ranges are needed.
Each lab gets a dedicated Hyper-V Internal switch named DLab-{LabName}, with the host adapter assigned base+1/27. The DLab-Internal switch serves segment 0 for golden-image builds only and is shared infrastructure, not owned by any lab. Lab switches are isolated from each other. Internet access works because every segment falls within the DLab-NAT supernet. A VM with a default gateway sends traffic to the host and out through NAT. A VM without a gateway stays isolated inside its /27.
Internet access is on by default. Disable it at creation time or toggle it on a running lab:
# Create a lab with no internet
New-DLab -LabName Isolated -NoInternet
# Toggle internet on a running lab
Set-DLabInternet -LabName Demo -Enabled $true
Set-DLabInternet -LabName Demo -Enabled $falseIPv6 is disabled on all lab VMs by default.
The installer creates this structure automatically under LabStorageRoot (default: C:\Dummy.Lab\):
C:\Dummy.Lab\
ISOs\ <- YOU: drop Windows Server ISOs here
.scan-cache\ <- FRAMEWORK: per-ISO scan cache (JSON, auto-managed)
GoldenImages\ <- FRAMEWORK: golden VHDXs (read-only, protected)
WS2019-DC-2026.04.12.vhdx
WS2019-DC-2026.04.12-unpatched.vhdx
WS2019-DC-CORE-2026.04.12-unpatched.vhdx
WS2019-STD-2026.03.15.vhdx <- Historical image for patch reproduction
latest-WS2019-DC.txt <- Pointer to current image
Labs\ <- FRAMEWORK: lab instances
MyLab\ <- one folder per lab (teardown deletes this)
Disks\ <- Differencing disks (tiny, fast)
VMs\ <- Hyper-V VM configs
Operations\ <- Per-lab operation documents
lab.state.json <- Lab state tracking
Events\ <- FRAMEWORK: central JSONL event log (survives teardown)
dlab-events-2026-04.jsonl
Operations\ <- FRAMEWORK: central operation documents (survives teardown)
2026-04-14T12-00-00-Demo-<opid>.json
Reports\ <- Export-DLabReport output
[project files]
DLab.ps1 <- Cheat-sheet printer
Install-Dummy.Lab.ps1 <- Installer
Scripts\
Build-DummyLab.ps1
Config\
GuestScripts\
Modules\DummyLab\ <- Assembled module (Import-Module this)
src\DummyLab\ <- Module source (kept for contributors)
Golden images are protected with read-only + deny-delete ACL. Remove-DLab only removes Labs\{LabName}\; operations and events at the root survive.
Dummy.Lab\
DLab.ps1 <- In-terminal cheat-sheet printer
Install-Dummy.Lab.ps1 <- Bootstrap installer (one-liner entry point)
CHANGELOG.md
README.md
LICENSE
manifest.json <- Machine-readable project structure
docs\
DESIGN.md <- This document
CMDLET-MAP.md <- Composition diagrams (who calls whom)
SECURITY.md
TEST-GUIDE.md
TROUBLESHOOTING.md
ISOs\
README.md <- Instructions for adding ISOs
Scripts\ <- Framework internals
Build-DummyLab.ps1 <- Assembles the module
Config\
OS.Catalog.psd1 <- OS definitions (build numbers, WIM patterns, prefixes)
unattend-server.xml <- Unattend template for Server OS
GuestScripts\ <- Scripts that run inside VMs via PowerShell Direct
Install-DC.ps1 <- DC promotion state machine
Install-DHCP.ps1 <- DHCP server setup on DC
Install-MemberServer.ps1 <- Member server domain join
Add-LabUsers.ps1 <- Optional test OUs, users, groups
Invoke-GoldenImagePrep.ps1 <- Updates + cleanup + sysprep
Modules\DummyLab\ <- Assembled module (build output)
src\DummyLab\ <- Module source
DummyLab.psd1
Config\DLab.Defaults.psd1
Formats\DummyLab.Format.ps1xml
Private\*.ps1
Public\*.ps1
DummyLab exports 52 public cmdlets across 11 tiers. For the full list and examples, run .\DLab.ps1 all or see docs\CMDLET-MAP.md.
| Tier | Cmdlets |
|---|---|
| Configuration and discovery | Get-DLabConfig, Set-DLabConfig, Test-DLabConfig, Get-DLabCatalog, Find-DLabISO, Get-DLabISOCatalog |
| Inventory | Get-DLab, Get-DLabVM, Get-DLabNetwork, Get-DLabGoldenImage |
| State | Get-DLabState, Update-DLabState, New-DLabVMSlot, Complete-DLabVMSlot, Remove-DLabVMSlot |
| Network | Get-DLabNetworkConfig, Get-DLabNAT, Find-DLabFreeSubnet, New-DLabSwitch, Remove-DLabSwitch, Set-DLabNAT, Remove-DLabNAT |
| Storage | New-DLabVHDX, Optimize-DLabVHDX, New-DLabDifferencingDisk |
| Preflight | Test-DLabHost, Invoke-DLabPreflight |
| VM | New-DLabVM, Start-DLabVM, Stop-DLabVM, Wait-DLabVM, New-DLabCheckpoint, Test-DLabVM |
| Guest | Send-DLabGuestFile, Invoke-DLabGuestScript |
| Golden images | New-DLabGoldenImage, Remove-DLabGoldenImage, Protect-DLabGoldenImage, Test-DLabGoldenImage, Import-DLabGoldenImage, Export-DLabGoldenImage |
| Composition | New-DLab, Add-DLabVM, Remove-DLabVM, Remove-DLab, Test-DLab, Set-DLabInternet |
| Observability | Get-DLabOperation, Get-DLabEventLog, Watch-DLabOperation, Get-DLabMetrics, Export-DLabReport |
Every cmdlet supports Get-Help <name> -Full for detailed help.
Every narrative line emitted by the module is a DLab.Event written on two channels:
- PowerShell Information stream (channel 6) as a typed
DLab.Eventwith tags (DLab,DLab.<Level>,DLab.Source.<Cmdlet>) for live capture. - Append-only JSONL at
<LabStorageRoot>\Events\dlab-events-<yyyy-MM>.jsonlfor historical analysis.
Levels: Info, Ok, Warn, Error, Step, Debug.
Schema (one JSON line per event):
{
"t": "<ISO 8601 local time with offset>",
"lvl": "<Level>",
"src": "<cmdlet name>",
"op": "<OperationId or empty>",
"msg": "<human-readable message>",
"data": { "<arbitrary structured fields>" }
}Multi-step cmdlets (New-DLab, Add-DLabVM, Remove-DLab, Remove-DLabVM, New-DLabGoldenImage, Remove-DLabGoldenImage, Import-DLabGoldenImage, Export-DLabGoldenImage) wrap their work in a DLab.Operation record persisted to <LabStorageRoot>\Operations\<timestamp>-<lab>-<opid>.json. Each Operation has a list of DLab.OperationStep entries with per-step durations and status.
Query via Get-DLabOperation, Get-DLabEventLog, Watch-DLabOperation, Get-DLabMetrics, and Export-DLabReport.
1.0.0 ships with no retention policy. Archive or delete old files under Events\ and Operations\ manually to match your audit window.
The Event and Operation stores solve different problems. Contributors adding new cmdlets should classify their logging using the rules below so the two stores stay coherent.
Use a DLab.Operation when the cmdlet has two or more logically distinct phases whose per-phase duration and per-phase outcome matter for forensics. One Operation file per run; each phase is an Add-DLabOperationStep / Complete-DLabOperationStep pair. Weeks later you can reconstruct "got through phase 3, failed at phase 4 after 02:11".
Use only DLab.Events (no Operation) when the cmdlet is a single-shot primitive: one named action, atomic from the user's point of view, with no meaningful sub-steps. These often sit inside a larger wrapping Operation (a Step event from the outer Operation correlates them via OperationId), but they also work standalone.
Emit nothing on the happy path when the cmdlet is read-only with no disk side effect. Reads do not pollute the log. There is one exception: if a read detects corruption or drift (a referenced resource that is missing, a state file that won't parse, a VM in state that Hyper-V doesn't have), emit a Warn event so the finding survives past the current console session.
Event levels carry specific meanings:
| Level | When | Example |
|---|---|---|
Step |
"About to do X" - phase boundary marker | Emitted by Add-DLabOperationStep at phase entry |
Ok |
A named thing succeeded | "Removed NAT: Prod-NAT" |
Info |
Benign context, not a direct action outcome | "Build skipped - reusing existing image" |
Warn |
Problem that did not abort; partial success, drift, or corruption | "Corrupt state file skipped", "Undocumented VM matches lab pattern" |
Error |
Hard failure, typically paired with a throw |
"Copy failed: access denied" |
Debug |
Developer-only diagnostics, off by default | Cache hits, internal decision points |
Source attribution. The Source field on every event is the top-level cmdlet that owns the action, not the private helper doing the work. New-DLab emits events with Source='New-DLab' even when the code that emits them runs inside Deploy-LabDC. The Write-LabLog forwarder auto-attributes via Get-PSCallStack.
OperationId correlation. Every event emitted during an Operation's lifetime carries that OperationId. Events from standalone primitives leave it empty. Get-DLabEventLog -OperationId <guid> reconstructs a full run from the event stream alone.
Decision test (apply in order):
- Does the cmdlet have named phases a future operator would want to see timed separately? Use an Operation.
- Does it mutate a documented resource or the lab state? Use Events (and an Operation if multi-phase).
- Is it a single-shot atomic primitive? Events only.
- Is it read-only? No logging on the happy path;
Warnonly on detected drift.
New-DLabGoldenImage orchestrates six named phases. Each phase is wrapped in a DLab.OperationStep with its own start/complete event, so a failed build tells you exactly which phase broke via (Get-DLabOperation -Last 1).Steps.
| Phase | Private helper | Responsibility |
|---|---|---|
| Plan | Resolve-GoldenImagePlan |
Normalize OS key, resolve ISO, compute output paths, merge settings, short-circuit if today's image already exists. |
| ApplyWIM | New-GoldenImageVHDX |
Create VHDX from ISO (WIM apply + unattend injection + bcdboot). |
| Boot | Start-GoldenImageBuildVM |
Ensure shared DLab-Internal switch and DLab-NAT exist, create VM on staging segment, start, wait for PowerShell Direct, verify internet when patching. |
| Patch | Invoke-GoldenImageUpdate |
Windows Update rounds with internet re-verification between reboots (skipped when -SkipUpdates). |
| Sysprep | Invoke-GoldenImageSysprep |
Run guest cleanup + sysprep /generalize /oobe /shutdown; wait for VM to power off. |
| Finalize | Complete-GoldenImageBuild |
Remove temp VM/switch/NAT, compact VHDX (Optimize-VHD), apply protection ACL, update latest-<GoldenPrefix>.txt pointer. |
Shared helper: Confirm-GoldenImageInternet (used by Boot and Patch) configures the VM's static IP over PowerShell Direct and verifies outbound reachability.
If internet cannot be established in Boot, the plan flips to the -unpatched variant and Patch reports Skipped. If an unpatched image already exists for today, the build aborts rather than producing a duplicate.
- Resolve network config, reserve DC slot, create differencing disk (ComputerName in unattend).
- Boot VM, unattend handles OOBE + admin password.
- PowerShell Direct:
Install-DC.ps1state 1 (set static IP, install AD DS role, promote to DC - triggers reboot). - Wait for DC to come back with domain credentials.
- PowerShell Direct:
Install-DC.ps1state 2 (post-promotion config - DNS forwarder). - PowerShell Direct:
Install-DHCP.ps1(DHCP scope). - Verify: NTDS and DNS services running; lab state persisted.
- Reserve VM slot, create differencing disk (ComputerName + domain join in unattend).
- Boot VM, unattend handles OOBE + rename + domain join automatically.
- PowerShell Direct:
Install-MemberServer.ps1(set static IP, verify domain membership). - Finalise slot, persist lab state.
- UILanguage: Always
en-US - InputLocale: Auto-detected from host (e.g. Danish keyboard)
- UserLocale: Auto-detected from host (e.g.
da-DK) - SystemLocale:
en-US - TimeZone: Auto-detected from host
All configurable via %APPDATA%\DummyLab\config.psd1 (user override) with 'auto' as the default. See section 4 for the full override mechanism.
Remove-DLab -Name ProdTest -Confirm:$falseRemoves: all VMs, the per-lab DLab-{LabName} switch, and lab storage folder (Labs\ProdTest\). The shared DLab-NAT and DLab-Internal switch are not removed (they serve other labs and the staging segment).
Preserves: golden images in GoldenImages\, ISOs in ISOs\, other labs, operation documents in Operations\, event logs in Events\.