1. In-development backlog

Note
Live feature backlog (RALPH task list), maintained in src/NETSetup.adoc.

solution: src/NETSetup.sln project: NETSetup testProject: NETSetup.Tests language: csharp testCommand: dotnet test src/NETSetup.sln --nologo --verbosity minimal ---

2. In Development

2.1. nixos-vm-provisioning

GOAL: Proxmox host running NETSetup provisions customer VMs per NETSetupConfig during netsetup update. NixOS-backed VMs (Nextcloud, IPFS, generic NixOS) boot generic nixos.iso, NETSetup SSHes in using master key, scps C#-generated flake + host config, runs install.sh remotely. Windows VMs use existing per-VM offline create-iso pipe. Result: fully hands-off VM deploy from a single netsetup update on the Proxmox host.

Decisions:

  • ISO artifact: nixos.iso on Proxmox ISO storage. Dropbox holds nixos.iso.7z + nixos.iso.sha256 (ArchiveSync + SevenZipCommand pattern — see Serena compressed_image_driver_sync).

  • Bootstrap SSH: single master NETSetup keypair. Pubkey committed at src/NETSetup/nixos/netsetup-master.pub, baked into iso/iso.nix root authorized_keys. Privkey shipped with NETSetup binary under src/NETSetup/Resources/netsetup-master.key (embedded resource, extracted to /NETSetup/.ssh/netsetup-master.key chmod 0600 on use).

  • OS trigger: Machine.OperatingSystem.Name.StartsWith("Nix") → nix-backed. OS.NixOS (contracts) routes to NixHostTemplate. New NETSetup-local OS constants NixNextcloud, NixIPFS defined in NETSetup.Config.Nix.NetsetupOS (NOT in contracts lib).

  • Templates: abstract NixHostTemplate base + concrete NixNextcloudTemplate, NixIpfsTemplate. Templates expose GenerateDefaultNix() (host config body) and GenerateHardwareNix(). Content derived from src/NETSetup/nixos/hosts/default-nextcloud/default.nix and default-ipfs/default.nix. GenerateFlakeNix() exists but is no longer called by ProvisionNixosVm — see post-implementation pivot below.

  • Per-VM emit shape (post-pivot, 2026-04-30): flat config.nix + hardware.nix + sops secrets.yaml + .default.key at the per-VM generated dir root. No emitted flake.nix — the base nix tree at /iso/nixos-config/flake.nix already exposes nixosConfigurations.default = mkHost "default" ./config.nix (nixos/hosts.nix:113). Overlaying our flake stripped disko + lanzaboote + sops modules from sharedHostModules, causing disko-install to crash with artifacts[1]: unbound variable (no diskoScript artifact when disko module isn’t loaded). Reusing the base flake keeps every shared module in scope.

  • End-to-end test ISO bootstrap: ProxmoxNixosNextcloudTests self-bootstraps every prerequisite in [TestInitialize] plus nixos.iso per run:

    • EnsureChocoPackageCommand (nested NETCommand-style wrapper around choco install <pkg> -y, idempotent) ensures sops is on PATH for SopsCommand.

    • EnsureWslDistroCommand (nested NETCommand-style wrapper around wsl --import) ensures the NixOS WSL distro is registered. IsDistroRegistered short-circuits on wsl -l -q so already-imported distros skip the Dropbox fetch. When absent, the rootfs tarball is pulled from Images/NixOSWsl.tar.gz on Dropbox (TestContext.GetImageRemoteSource() prefers the local Dropbox folder for instant copy, falls back to the API). The Dropbox sub-path is encoded as a const on the test class so it’s the single point of update if the image moves.

    • BuildNixosIsoCommand (wsl.exe -d NixOS — nix build .#iso against the local src/NETSetup/nixos flake → C:\NETSetupDEV\nixos.iso) builds the ISO, then pveHost.UploadISO ships it sha-driven so unchanged builds skip the wire transfer.

  • Why wsl --import instead of wsl --install -d NixOS? The Microsoft Store catalogue version is not always the pinned NixOS-WSL build the flake expects (nixpkgs lock + cached flake inputs). Importing a tarball we publish ourselves keeps WSL state reproducible across dev boxes.

  • install.sh invoked with -h default (not the per-VM hostname). Per-VM OS hostname identity preserved via networking.hostName set inside the emitted config.nix by NixHostTemplate.

  • Installer SOPS age key: single committed pair at src/NETSetup/nixos/.sops/installer.{key,pub}. Public key pinned as ProvisionNixosVm.InstallerAgePublicKey constant. Private key shipped via csproj <Content CopyToOutputDirectory="Always" CopyToPublishDirectory="Always">, deployed to /NETSetup/.sops/installer.key (700/600) by netsetup-first-boot.sh section 4c. ProvisionNixosVm.EmitConfigBundle generates a random 32-byte hex LUKS password, encrypts it via SopsCommand to a per-VM secrets.yaml, and copies the installer private key into .default.key so install.sh L156 can decrypt during disko-install. Local generated dir is wiped on success (TryCleanupConfigDir).

  • Disk: plain btrfs (no LUKS). Default /dev/sda; VirtIO VMs use /dev/vda. NixHostTemplate takes device param.

  • Entry point: extend UpdateMethods.RunProxmoxUpdate (append EnsureCustomerVms(host, config) at end). InstallCommand.InstallStage.ProxmoxVMs stays a stub.

  • New commands live in NETSetup/NETCommand/ — no changes to shared osisa.NETCommand. NixRemoteInstallCommand composes LinuxCommandBase for ssh/scp. QmGuestCommand wraps Proxmox-API nodes/<node>/qemu/<vmid>/agent via corsinvest client (reuse existing Proxmox/Host wrappers from osisa.SystemManagement.Proxmox).

  • No System.IO: use AbsolutePath + PhysicalFileOperator. No raw Process: use NETCommand builders.

  • VM identification: reuse CreateVirtualMachines.IsForHost + GetOrderedVmsForHost (DC-first ordering preserved).

  • Local emission path for generated nix configs: AbsolutePath("/NETSetup/generated-nix") / vmName (Linux Proxmox host), wiped + rewritten each update cycle.

  • UPDATE (2026-07-13, IPFS cluster secrets - commit a997e484, live test green end-to-end): NixIPFS provision now GENERATE + EMIT its own sops secrets on top of luks_password. Per-VM secrets.yaml also carry ipfs/swarm_key (multiline psk block /key/swarm/psk/1.0.0/ + /base16/ + 64-lowercase-hex) and ipfs/cluster_secret (single line CLUSTER_SECRET=<64-lowercase-hex>, a systemd EnvironmentFile line), both nested under one ipfs: map. Those two are cluster-shared: IpfsClusterSecretsGenerator.EnsureOn(config) mint them ONCE per customer/cluster (RandomNumberGenerator.Fill(byte[32]) + Convert.ToHexStringLower), stash on new persisted NETSetupConfig.IpfsCluster (IpfsClusterOptions holding IpfsSwarmKey + IpfsClusterSecret Vogen VOs), STABLE across re-provision (regen would partition the cluster). Key strings ipfs/swarm_key / ipfs/cluster_secret are consts, MUST match ipfs-private-cluster.nix:297,304. NixIpfsTemplate.GetRequiredSecrets(config) throw MissingIpfsClusterSecretsException : NETSetupException when a NixIPFS host emit with config.IpfsCluster == null. luks stays UPPERCASE hex; ipfs entropy is LOWERCASE hex.

  • UPDATE (2026-07-13, secrets.yaml renderer): emission gain nested-key + block-scalar support via new pure SecretsYamlBuilder. Single-segment keys stay FLAT (luks back-compat); a/b keys nest (so both ipfs/* group under one ipfs: map). Multiline value → YAML block scalar | (content indent = key indent + 2); single-line → inline-quoted (\ + " escaped). SopsCommand now hold IReadOnlyList<NixSecretEntry> Entries (was flat dict Payload); RenderedYamlInput delegate to the builder. Templates declare their needs via new virtual NixHostTemplate.GetRequiredSecrets(config) (base = Array.Empty); EmitConfigBundle call EnsureOn(config) then loop GetRequiredSecrets into the same builder. InstallNixosVm.Run also call EnsureOn(config) at top (inline-built configs).

  • UPDATE (2026-07-13, non-interactive LUKS): encrypted disko-install is NOW non-interactive via passwordFile = "/tmp/luks.key" in disk-layouts.nix (both encrypted + dual-boot luks blocks; also disk/encrypted.nix + disk/dual-boot.nix). install.sh (L195-196) write the decrypted sops luks_password to /tmp/luks.key (chmod 600) BEFORE disko-install, delete it after TPM enroll (L306). WHY: without passwordFile disko prompt for the passphrase on stdin, get NOTHING over the ssh pipe, luksFormat abort, and the nix closure copy into the still-unmounted /mnt/disko-install-root RAM tmpfs → No space left on device. This is the root-cause of the earlier IPFS install failures.

  • UPDATE (2026-07-13, NixIPFS VM sizing): NixIPFS VM get 8 GiB RAM (installer build the closure in a RAM-backed tmpfs store - undersize = OOM) + 64 GiB disk (IPFS repo live on the system disk). Contrast: NixHostTemplate default 2 GiB / 32 GiB; NixNextcloudTemplate 8 GiB / 256 GiB.

  • UPDATE (2026-07-13): the ProxmoxNixosNextcloudTests ISO uploader connect to the Proxmox host by LAN IP, NOT the osisaLib03 hostname (DNS not resolvable from every dev box).

    • ✓ Add NETSetup-local OS constants and nix-detection extension: create src/NETSetup/Config/Nix/NetsetupOS.cs with public static readonly OperatingSystem NixNextcloud = OperatingSystem.From("NixNextcloud", OperatingSystemFamily.Linux_GNU) and NixIPFS = OperatingSystem.From("NixIPFS", OperatingSystemFamily.Linux_GNU). Create src/NETSetup/Extensions/NixOsExtensions.cs with public static bool IsNixBacked(this OperatingSystem os) returning true when os == OS.NixOS OR os.Name.StartsWith("Nix", StringComparison.OrdinalIgnoreCase). Assertions:

    • NetsetupOS.NixNextcloud.Name.Should().Be(SomeNixNextcloudName)

    • NetsetupOS.NixNextcloud.Family.Should().Be(OperatingSystemFamily.Linux_GNU)

    • NetsetupOS.NixIPFS.Name.Should().Be(SomeNixIpfsName)

    • OS.NixOS.IsNixBacked().Should().BeTrue()

    • NetsetupOS.NixNextcloud.IsNixBacked().Should().BeTrue()

    • NetsetupOS.NixIPFS.IsNixBacked().Should().BeTrue()

    • OS.Windows11.IsNixBacked().Should().BeFalse()

    • OS.Proxmox.IsNixBacked().Should().BeFalse()

    • OS.Unknown.IsNixBacked().Should().BeFalse()

    • ✓ Extend src/NETSetup/nixos/iso/iso.nix with qemu-guest-agent + master pubkey: add services.qemuGuest.enable = true; and users.users.root.openssh.authorizedKeys.keys = [ (lib.fileContents ../netsetup-master.pub) ]; (create src/NETSetup/nixos/netsetup-master.pub placeholder committed with a real ed25519 pubkey). Keep services.openssh.enable = true, ensure PermitRootLogin = "prohibit-password" (or "yes" during bootstrap). Preserve all existing options (volumeID, contents, install-nixos.sh script, supportedFilesystems). Assertions:

    • isoNixContent.Should().Contain("services.qemuGuest.enable = true")

    • isoNixContent.Should().Contain("users.users.root.openssh.authorizedKeys.keys")

    • isoNixContent.Should().Contain("netsetup-master.pub")

    • isoNixContent.Should().Contain("services.openssh.enable = true")

    • pubFileExists.Should().BeTrue()

    • pubFileContent.Should().StartWith("ssh-ed25519 ")

    • ✓ Create build-nixos-image skill + standalone script: add .claude/skills/build-nixos-image/SKILL.md (description: "Build NixOS installer ISO, 7z, sha256, upload to Dropbox") plus src/NETSetup/nixos/scripts/build-nixos-image.sh that (1) cd to nixos dir, git add -A (flakes see only tracked), (2) nix build .#iso --out-link /tmp/nixos-iso-result, (3) resolve ISO file via symlink, (4) 7z a -mx=9 /tmp/nixos.iso.7z <iso>, (5) sha256sum /tmp/nixos.iso.7z > /tmp/nixos.iso.sha256, (6) cp both to "$DROPBOX/NETSetup/ISOs/". Script must be idempotent, detect missing nix/7z and error clearly, and exit non-zero on any step failure. Skill instructs running under WSL distro nixos (modeled after update-nixos-image skill). Assertions:

    • skillFileExists.Should().BeTrue()

    • skillContent.Should().Contain("build-nixos-image")

    • skillContent.Should().Contain("WSL")

    • scriptFileExists.Should().BeTrue()

    • scriptContent.Should().Contain("nix build .#iso")

    • scriptContent.Should().Contain("7z a")

    • scriptContent.Should().Contain("sha256sum")

    • scriptContent.Should().Contain("set -euo pipefail")

    • scriptIsExecutable.Should().BeTrue() (file mode check)

    • ✓ Implement C# Nix templates under src/NETSetup/Config/Nix/Templates/: abstract NixHostTemplate with properties Hostname (MachineName), DiskDevice (string, default /dev/sda), UserName (string, default admin), TimeZone (string, default UTC), AuthorizedKeys (IReadOnlyList<string>) and virtual methods GenerateFlakeNix(), GenerateHardwareNix(), abstract GenerateDefaultNix(). Concrete NixNextcloudTemplate and NixIpfsTemplate override GenerateDefaultNix() with content ported from src/NETSetup/nixos/hosts/default-nextcloud/default.nix and default-ipfs/default.nix (literal string). Factory NixTemplateFactory.ForMachine(Machine machine) returns concrete template based on machine.OperatingSystem: NixNextcloud → NixNextcloudTemplate, NixIPFS → NixIpfsTemplate, NixOS → NixHostTemplate generic subclass NixGenericTemplate, other → throws NETSetupException. Assertions:

    • nextcloudTemplate.GenerateDefaultNix().Should().Contain("services.nextcloud.enable = true")

    • nextcloudTemplate.GenerateDefaultNix().Should().Contain(SomeHostname)

    • nextcloudTemplate.GenerateFlakeNix().Should().Contain("description")

    • nextcloudTemplate.GenerateHardwareNix().Should().Contain(SomeDiskDevice)

    • ipfsTemplate.GenerateDefaultNix().Should().Contain("services.ipfs.enable = true")

    • NixTemplateFactory.ForMachine(nextcloudMachine).Should().BeOfType<NixNextcloudTemplate>()

    • NixTemplateFactory.ForMachine(ipfsMachine).Should().BeOfType<NixIpfsTemplate>()

    • NixTemplateFactory.ForMachine(nixosMachine).Should().BeOfType<NixGenericTemplate>()

    • act.Should().Throw<NETSetupException>() for non-nix machine

    • template.GenerateDefaultNix().Should().Contain(SomeAuthorizedKey)

    • ✓ Implement Proxmox guest-agent IP discovery: src/NETSetup/Extensions/QmGuestAgentExtensions.cs with public static IPAddress DiscoverVmIpViaQmAgent(this Host host, int vmId, TimeSpan deadline, ILogger logger). Uses corsinvest _client.Nodes[<node>].Qemu[vmId].Agent.NetworkGetInterfaces.NetworkGetInterfaces() (or equivalent), parses JSON data.result[].ip-addresses[] filtering ip-address-type == "ipv4", excluding 127.0.0.0/8 and 169.254.0.0/16. Polls every 2s until first match or deadline. Throws NETSetupException with vmId in message on timeout. Assertions:

    • result.Should().Be(IPAddress.Parse(SomeValidIp))

    • result.AddressFamily.Should().Be(AddressFamily.InterNetwork)

    • (loopback-only scenario) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains(SomeVmId.ToString()))

    • (link-local-only scenario) act.Should().Throw<NETSetupException>()

    • (timeout with no response) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("timeout"))

    • (multiple ipv4) result.Should().Be(IPAddress.Parse(SomeFirstNonLoopbackIp))

    • ✓ Implement NixRemoteInstallCommand under src/NETSetup/NETCommand/NixRemoteInstallCommand.cs using NETCommand builder pattern. Builder exposes WithIp(IPAddress), WithHostname(string), WithMasterKey(AbsolutePath keyPath), WithConfigDir(AbsolutePath localGeneratedDir), WithDiskDevice(string) (default /dev/sda), CopyHostSshKeys(bool). Execute() runs: (1) ssh-keyscan target → known_hosts, (2) scp -r <localDir>/* root@<ip>:/nixos-config/, (3) ssh root@<ip> "/nixos-config/scripts/install.sh -h <name> -d <device>" with 30min timeout. Each phase is a LinuxCommandBase internally. Returns NixRemoteInstallResult with Success, StandardOutput, StandardError, Phase (KeyScan/Scp/Install) on failure. Assertions:

    • builder.WithIp(SomeIp).WithHostname(SomeHostname).WithMasterKey(SomeKeyPath).WithConfigDir(SomeConfigDir).Build().Should().NotBeNull()

    • act.Should().Throw<ArgumentException>() when WithIp not called

    • act.Should().Throw<ArgumentException>() when WithHostname not called

    • command.RenderedScpArgs.Should().Contain("root@" + SomeIp)

    • command.RenderedScpArgs.Should().Contain("/nixos-config/")

    • command.RenderedSshArgs.Should().Contain("install.sh -h " + SomeHostname)

    • command.RenderedSshArgs.Should().Contain("-d " + SomeDiskDevice)

    • command.RenderedSshArgs.Should().Contain("-i " + SomeKeyPath)

    • result.Phase.Should().Be(InstallPhase.Install) on successful full run

    • ✓ Implement EnsureNixosIsoOnProxmoxHost(ISystemHost host, IFileProvider dropbox): checks /var/lib/vz/template/iso/nixos.iso existence AND sha256 match against Dropbox NETSetup/ISOs/nixos.iso.sha256. If missing or mismatch: downloads nixos.iso.7z to temp, verifies sha256, extracts via SevenZipCommand to /var/lib/vz/template/iso/nixos.iso, logs action. Idempotent — returns silently if match. Lives at src/NETSetup/Helpers/EnsureNixosIso.cs. Assertions:

    • act.Should().NotThrow() when ISO present + hash matches

    • result.Action.Should().Be(EnsureAction.Downloaded) when missing

    • result.Action.Should().Be(EnsureAction.Replaced) when hash mismatch

    • result.Action.Should().Be(EnsureAction.UpToDate) when already current

    • act.Should().Throw<NETSetupException>() when Dropbox sha256 file missing

    • act.Should().Throw<NETSetupException>() when downloaded archive sha256 fails verification

    • isoPath.FileExists().Should().BeTrue() after successful run

    • ✓ Implement ProvisionNixosVm(Host proxmoxHost, Machine machine, NETSetupConfig config, ISystemHost sysHost) in src/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs: (1) build VirtualMachine entity from Machine (name, id from serial prefix, CPU/RAM/disk from machine hardware, IsoName = "nixos.iso"), (2) skip if VM already exists with matching serial description, (3) call proxmoxHost.CreateVirtualMachine(vm, autoStart: true), (4) DiscoverVmIpViaQmAgent(vm.Id, TimeSpan.FromMinutes(5)), (5) pick template via NixTemplateFactory.ForMachine(machine), emit files to AbsolutePath("/NETSetup/generated-nix") / machine.Name, (6) run NixRemoteInstallCommand builder with generated dir + master key path, (7) log result, throw NETSetupException on failure. Assertions:

    • (new VM scenario) proxmoxHost.Received().CreateVirtualMachine(Arg.Is<VirtualMachine>(vm ⇒ vm.IsoName == SomeNixosIsoName))

    • (existing VM scenario) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • result.VmIp.Should().Be(IPAddress.Parse(SomeDiscoveredIp))

    • result.GeneratedConfigDir.Should().Be(SomeExpectedGeneratedDir)

    • act.Should().Throw<NETSetupException>() when DiscoverVmIpViaQmAgent times out

    • act.Should().Throw<NETSetupException>() when NixRemoteInstall fails

    • generatedDir.DirectoryExists().Should().BeTrue() after successful emission (during install — wiped on success post-pivot)

    • (generatedDir / "config.nix").FileExists().Should().BeTrue()

    • (generatedDir / "hardware.nix").FileExists().Should().BeTrue()

    • (generatedDir / "secrets.yaml").FileExists().Should().BeTrue()

    • (generatedDir / ".default.key").FileExists().Should().BeTrue()

    • ✓ Implement ProvisionWindowsVmOnProxmox(Host proxmoxHost, Machine machine, NETSetupConfig config, ISystemHost sysHost, IServiceProvider services) in src/NETSetup/Stages/5-NETSetupLinux/ProvisionWindowsVmOnProxmox.cs: (1) skip if VM exists, (2) derive ticket + hostname from machine.Name + config, (3) invoke existing CreateIsoCommand offline path (MainISO.CreateOfflineProductionISO + CopyToProxmoxStorageIfApplicable) to produce NETSetup-{ticket}-{host}.iso in /var/lib/vz/template/iso/, (4) build VirtualMachine with that ISO as main, stealth hardware iff ShouldUseStealthHardware(machine), (5) proxmoxHost.CreateVirtualMachine(vm, autoStart: true). Reuses existing CreateVirtualMachines.ShouldUseStealthHardware. Assertions:

    • (new Windows 11 VM) proxmoxHost.Received().CreateVirtualMachine(Arg.Is<VirtualMachine>(vm ⇒ vm.IsoName.StartsWith("NETSetup-")))

    • (WindowsServer VM) proxmoxHost.Received().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (existing VM) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • producedIsoPath.Should().Be(ProxmoxDefaultIsoStoragePath + "/NETSetup-" + SomeTicket + "-" + SomeHostname + ".iso")

    • result.UsedStealthHardware.Should().BeTrue() when machine is Windows 11

    • result.UsedStealthHardware.Should().BeFalse() when machine is WindowsServer

    • act.Should().Throw<NETSetupException>() when offline ISO creation fails

    • ✓ Wire VM provisioning into UpdateMethods.RunProxmoxUpdate: add new method EnsureCustomerVms(ISystemHost host, NETSetupConfig config, IServiceProvider services) called at end of RunProxmoxUpdate (after RunBtrfsScrub). Method: (1) connect to local Proxmox via DI, (2) GetHostAsync(host.MachineName), (3) call EnsureNixosIsoOnProxmoxHost iff any nix-backed machine targets this host, (4) GetOrderedVmsForHost(config, host.SerialNumber) — iterate with DC first, (5) dispatch: IsNixBacked → ProvisionNixosVm, else Windows → ProvisionWindowsVmOnProxmox, other → log skip. Errors per-VM are logged + continue (one bad VM must not block others). Final aggregate throws if any VM failed. Assertions:

    • (no VMs for host) proxmoxHost.DidNotReceive().CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (mixed nix+windows VMs) proxmoxHost.Received(SomeExpectedCount).CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (DC-first ordering) firstCreatedVmSerial.Should().StartWith(SomeDcVmIdPrefix)

    • (all nix, no windows) ensureNixosIsoCalled.Should().BeTrue()

    • (only windows, no nix) ensureNixosIsoCalled.Should().BeFalse()

    • (one VM fails, others succeed) proxmoxHost.Received(SomeTotalCount).CreateVirtualMachine(Arg.Any<VirtualMachine>())

    • (any failure) act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains(SomeFailedVmName))

    • (all success) act.Should().NotThrow()

    • ✓ Documentation: add section "Proxmox VM provisioning during update" to netsetup.adoc (after existing Proxmox sections) describing the two flows (NixOS SSH-deploy + Windows ISO-deploy), required keys/paths, update cadence. Add PUML sequence diagram src/NETSetup/docs/NETSetupProvisionVms.puml showing: netsetup-update → EnsureNixosIso → CreateVirtualMachine → qm guest-agent → scp config → ssh install.sh → reboot. Include in netsetup.adoc via include::docs/NETSetupProvisionVms.puml[]. All narrative in caveman full per CLAUDE.md. Assertions:

    • adocContent.Should().Contain("Proxmox VM provisioning")

    • adocContent.Should().Contain("nixos.iso")

    • adocContent.Should().Contain("NETSetupProvisionVms.puml")

    • pumlFileExists.Should().BeTrue()

    • pumlContent.Should().Contain("@startuml")

    • pumlContent.Should().Contain("EnsureNixosIso")

    • pumlContent.Should().Contain("qm guest")

    • pumlContent.Should().Contain("scp")

    • pumlContent.Should().Contain("install.sh")

3. In Development

3.1. osisa-mikrotik

GOAL: Provide a self-contained MikroTik RouterOS REST API client library (src/osisa.Mikrotik/) mirroring the osisa.OPNSense architecture (CRUD repos + UoW + DataStoreReference + Contracts-only public surface + IMikrotikConverge entry point). Covers broad resource families (bridges, VLANs, interfaces, bonding, IP/DHCP/DNS, firewall NAT+filter, WiFi v7+, user, system). Includes NETSetup-side bridge classes (MikrotikApiCredentials, MikrotikDesiredStateBuilder, MikrotikConvergeBridge) wired into RunProxmoxUpdate for the Liberator VLAN cutover flow. MikroTik REST API uses standard HTTP verbs (GET=print, PUT=add, PATCH=set, DELETE=remove), Basic Auth, paths mirror console menu (/rest/<menu>), all values encoded as strings, resource IDs use *N format.

Decisions:

  • Full CRUD/UoW shape mirroring osisa.OPNSense - same DataStoreReference/UnitOfWork/Repository pattern for future osisa.Rest.Repository migration path

  • Project lives in netsetup repo: src/osisa.Mikrotik/ + src/osisa.Mikrotik.Tests/, added to src/NETSetup.sln

  • OPNsense-aligned repo method names: GetAllAsync, GetAsync, AddAsync, SetAsync, DeleteAsync

  • WiFi v7+ only (/interface/wifi package, not legacy /interface/wireless) - hAP ax2 runs RouterOS 7 only

  • Batch-then-push UoW: changes buffered in-memory, CommitAsync pushes all pending PUT/PATCH/DELETE in sequence. No rollback on failure - throw MikrotikException with Applied[] + Failed[] delta

  • Entity base tracks both .id (*N format) and Name (nullable) for human-readable converge diffs

  • Typed C# properties with custom MikrotikStringConverter (System.Text.Json) handling MikroTik’s "all values are strings" encoding (string to bool/int/long deserialization)

  • Auth via env file /etc/netsetup/mikrotik-api.env (MIKROTIK_URL, MIKROTIK_USER, MIKROTIK_PASSWORD) - same KEY=VALUE pattern as OPNsense

  • MikrotikDesiredState is flat typed DTO with arrays per resource family (BridgeSpec[], BridgePortSpec[], etc.)

  • v1 MikrotikDesiredStateBuilder populates full Liberator VLAN cutover: bridges, VLANs, ports, WiFi SSIDs, DHCP disable, NAT disable, bonding, system identity

  • Converge safety guard: only delete entries matching desired state or prefixed with "netsetup-" - never delete user-created resources

  • Contracts-only public surface: NETSetup sees only IMikrotikConverge + MikrotikConnection + MikrotikDesiredState + *Spec records. All repo/entity/UoW types are internal (InternalsVisibleTo osisa.Mikrotik.Tests)

  • MikroTik REST API reference: https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API

Status (2026-05-12, commits 77a43f1e + 4f94c1b9 + 03316fc6 + be41eafe + d9af2b05 + 311e7421 + 94c3e555 + 5525a0a7 + 7c0b9064 + 848a97d5 on feature/proxmoxvms, mikrotik feature COMPLETE: all 9 RALPH tasks landed, 249 mikrotik tests green = 213 in osisa.Mikrotik.Tests + 36 NETSetup-side helper tests (10 MikrotikApiCredentialsTests + 18 MikrotikDesiredStateBuilderTests + 8 MikrotikConvergeBridgeTests). Plus 17 PhysicalMikrotikIntegrationTests gated on real hardware (not in CI). See mikrotik_feature_summary.md for the architecture-at-a-glance.):

  • Scaffold landed: src/osisa.Mikrotik/ + src/osisa.Mikrotik.Tests/ wired into src/NETSetup.sln. osisa.Mikrotik.csproj targets $(DefaultNetTargetFramework), pulls Microsoft.Extensions.DependencyInjection.Abstractions + Microsoft.Extensions.Logging.Abstractions + osisa.Validation, exposes internals via <InternalsVisibleTo Include="osisa.Mikrotik.Tests" />.

  • Public surface (namespace osisa.Mikrotik.Contracts): MikrotikConnection, MikrotikDesiredState, IMikrotikConverge, MikrotikConvergeResult, MikrotikResourceDelta, plus the 16 *Spec records (BridgeSpec, BridgePortSpec, BridgeVlanSpec, InterfaceSpec, BondingSpec, IpAddressSpec, IpRouteSpec, DhcpServerSpec, DhcpLeaseSpec, DnsSpec, FirewallNatRuleSpec, FirewallFilterRuleSpec, WifiConfigurationSpec, WifiSecuritySpec, UserSpec, SystemIdentitySpec). DI: osisa.Mikrotik.MikrotikServiceCollectionExtensions.AddMikrotik(IServiceCollection). Exception: osisa.Mikrotik.Exceptions.MikrotikException (carries StatusCode + ErrorMessage + Detail?, message format "MikroTik {status}: {message}" or "MikroTik {status}: {message} ({detail})").

  • Transport (Client/MikrotikHttpClient, internal sealed IDisposable): real impl. Basic auth "Basic " + base64(user + ":" + pw) exposed on internal AuthorizationHeader test seam. HttpClient.BaseAddress = BaseUrl + "/", paths built via "rest/" + path.TrimStart('/'). Verbs: GetAsync<T> (list, [] on empty), GetSingleAsync<T>, PutAsync<T> (add), PatchAsync(path,id,body), DeleteAsync(path,id), PostAsync<T> (action endpoints). 4xx-5xx responses parse {error,message,detail} → MikrotikException; non-JSON body keeps ReasonPhrase as message. Self-signed cert skip via HttpClientHandler.ServerCertificateCustomValidationCallback (default on). Shared JsonSerializerOptions with PropertyNamingPolicy=null + DefaultIgnoreCondition.WhenWritingNull + MikrotikStringConverter. Test-only constructor accepts a pre-wired HttpClient for FakeHttpMessageHandler.

  • JSON (Json/MikrotikStringConverter, internal sealed JsonConverterFactory): real impl. CanConvert claims exactly bool | int | long | string. CreateConverter yields per-type nested converters. Read tolerates BOTH the MikroTik all-strings form ("true", "42") AND raw JSON values (true, 42) - resilient to per-version drift. Write ALWAYS emits a quoted JSON string (writer.WriteStringValue); numeric values formatted with InvariantCulture to avoid German-locale thousands-separator regressions. String converter coerces raw True/False/Number tokens into "true"/"false"/decimal-string so MikroTik fields that occasionally come back as a raw number (e.g. pvid) still bind to a string? property.

  • Entity base (Entities/MikrotikEntity.cs, internal abstract): real impl. Two writable string properties - [JsonPropertyName(".id")] string? Id (literal MikroTik wire key ".id", dot-prefixed - non-negotiable) and [JsonPropertyName("name")] string? Name. Both nullable - new entities pre-PUT have Id == null, families without a name field leave Name == null. Concrete entities (task 4) inherit this base + add family-specific string?/bool?/int?/long? properties with [JsonPropertyName("kebab-case")] where MikroTik’s wire key diverges from C# PascalCase.

  • Repository base (Repositories/MikrotikRepositoryBase<T> : IMikrotikRepository<T> where T : MikrotikEntity, internal abstract): real impl. Concrete subclasses only override abstract string ResourcePath (e.g. "interface/bridge"). GetAllAsync delegates HttpClient.GetAsync<T>(ResourcePath, ct); GetAsync(id, ct) → HttpClient.GetSingleAsync<T>(ResourcePath + "/" + id, ct). Add/Set/Delete buffer PendingOperation records into a List<PendingOperation>; IsDirty == Pending.Count > 0. FlushAsync(ct) iterates pending ops in insertion order: Add → PutAsync (returns saved entity, Applied[] captures saved.Id ?? ""), Set → PatchAsync(ResourcePath, op.Id, body), Delete → DeleteAsync(ResourcePath, op.Id). Per-op try/catch over Exception captures into MikrotikFlushFailure(Id, Error); Pending.Clear() runs unconditionally after the loop. Result type MikrotikFlushResult(IReadOnlyList<string> Applied, IReadOnlyList<MikrotikFlushFailure> Failed). Pending op carries PendingOperationKind enum (Add | Set | Delete) + id (empty string for Add until device assigns) + nullable object? body. No retry, no rollback - caller decides via Applied/Failed split. GetAllAsync + Add + Set + Delete are virtual so singleton + read-only subclasses can override.

  • Entities + repositories (17 resource families): all internal sealed. Entities inherit MikrotikEntity and decorate properties with [JsonPropertyName("kebab-case")] where the MikroTik wire key diverges. The catalog (with REST paths + kebab keys + repo kind): Bridge (interface/bridge, vlan-filtering, protocol-mode); BridgePort (interface/bridge/port, bridge, interface, pvid, tagged); BridgeVlan (interface/bridge/vlan, bridge, tagged, untagged, vlan-ids); RouterInterface (interface - renamed to dodge the C# keyword clash; type, default-name, disabled, running, mac-address); Bonding (interface/bonding, slaves, mode, transmit-hash-policy, lacp-rate); IpAddress (ip/address, address, network, interface, disabled, comment); IpRoute (ip/route, dst-address, gateway, distance, comment); DhcpServer (ip/dhcp-server, interface, disabled, address-pool); DhcpLease (ip/dhcp-server/lease, address, mac-address, comment, disabled); DnsSettings SINGLETON (ip/dns, servers, allow-remote-requests); FirewallNatRule (ip/firewall/nat, chain, action, out-interface, in-interface, in-interface-list, disabled, comment); FirewallFilterRule (ip/firewall/filter, chain, action, protocol, dst-port, in-interface, in-interface-list, comment, disabled); WifiConfiguration (interface/wifi, ssid, country, channel, security); WifiSecurity (interface/wifi/security, authentication-types, passphrase); User (user, password, group); SystemIdentity SINGLETON (system/identity, hostname via inherited Name); SystemResource SINGLETON + READ-ONLY (system/resource, platform, board-name, version, uptime, free-memory : long?, total-memory : long? - Add/Set/Delete throw NotSupportedException). Singleton repos override GetAllAsync to wrap the single object in a one-entry array. Repository <→ Contracts spec name drift to bridge in the mapper (task 6): Bridge.Protocol → protocol-mode vs spec VlanFiltering (spec keeps only Name + VlanFiltering); BridgePort.Tagged (entity, literal wire key) vs BridgePortSpec.TaggedVlans (caller-friendly semantic).

  • Unit of work (UnitOfWork/IMikrotikUnitOfWork + MikrotikUnitOfWork, internal): real impl. Exposes one IMikrotikRepository<T> getter per resource family (17 props), all backed by a single MikrotikHttpClient injected in the ctor. CommitAsync(ct) iterates the 17 repos in declaration order via a private FlushIfDirtyAsync<T> helper: skips repos with !IsDirty, otherwise awaits FlushAsync and appends to the result list. Sequential (never parallel - shared HttpClient + MikroTik’s REST engine misorders rapid PATCH bursts). Caller is responsible for dependency ordering inside the desired state (bridges before bridge ports, etc.). Result is MikrotikCommitResult(IReadOnlyList<MikrotikFlushResult> Results) with HasFailures ⇒ Results.Any(r ⇒ r.Failed.Count > 0). Empty Results (no repos were dirty) != failure.

  • Data store + reference (DataStore/, all internal): real impl. MikrotikDataStore : IMikrotikDataStore, IDisposable is the canonical HttpClient owner - production ctor (MikrotikConnection) builds a fresh MikrotikHttpClient(connection, NullLogger.Instance) + a MikrotikUnitOfWork(httpClient) and disposes the HttpClient on Dispose() (idempotent via _disposed flag). Internal test-seam ctor accepts a pre-built HttpClient for FakeHttpMessageHandler. MikrotikDataStoreReference : IMikrotikDataStoreReference carries a MikrotikConnection and a Func<MikrotikConnection, MikrotikHttpClient> factory (defaults to new MikrotikHttpClient(connection, NullLogger.Instance); internal ctor lets tests inject). ExistsAsync(ct) probes GET /rest/system/resource via a throw-away HttpClient (using var http = _httpFactory(_connection)), returns true on any successful deserialization, false on any Exception (auth failure, timeout, JSON parse, cancellation - all reduce to "not reachable"). OpenDataStore() builds a fresh HttpClient + MikrotikDataStore (caller owns disposal). MikrotikDataStoreReferenceProvider is a stateless factory returning a new reference per call. Provider’s GetReference return type tightened from object to IMikrotikDataStoreReference in this commit.

  • Entity mapper (Mapping/MikrotikEntityMapper, internal static): real impl. 32 static methods - one ToEntity(XxxSpec) + one ToSpec(Xxx entity) per family for 16 spec families (SystemResource excluded - read-only, no Spec counterpart). Mechanical copy-property-to-property, no business logic. ToEntity projects spec record positionals to entity nullable properties as-is. ToSpec collapses entity nullables to spec record positionals via entity.X ?? string.Empty / entity.X ?? false / entity.Pvid ?? 0 (spec records are non-nullable positional records). The notable bridge: BridgePortSpec.TaggedVlans <→ BridgePort.Tagged (semantic spec name vs literal wire-key entity property). Bridge.Protocol is NOT in BridgeSpec and does not round-trip - the mapper writes nothing for it; future extension is additive.

  • Converge orchestrator (MikrotikConverge, internal sealed : IMikrotikConverge): real impl, replacing the scaffold-time NotImplementedException stub. Ctor takes IMikrotikDataStoreReferenceProvider + ILogger<MikrotikConverge>. RunAsync flow: (1) Precondition.NotNull on connection + desired; (2) reachability gate via reference.ExistsAsync(ct) → if false, log warning + return MikrotikConvergeResult(Reachable=false, Deltas=Array.Empty); (3) using var store = reference.OpenDataStore() → 16-repo UoW; (4) 16 RunResourceAsync(label, () ⇒ ConvergeXxxAsync(…​)) calls in fixed declaration order (Bridge → BridgePort → BridgeVlan → Interface → Bonding → IpAddress → IpRoute → DhcpServer → DhcpLease → FirewallNatRule → FirewallFilterRule → WifiConfiguration → WifiSecurity → User → Dns → SystemIdentity), each captured into the deltas list; (5) uow.CommitAsync(ct) wrapped in try/catch (warn + swallow on failure); (6) return MikrotikConvergeResult(true, deltas.ToArray()) with all 16 entries. RunResourceAsync<T> is the per-resource isolation seam: catches every Exception, logs warning with the family label, returns MikrotikResourceDelta(label, 0, 0, 0) so failures appear as zero-delta entries (3-layer failure pattern: per-op via FlushAsync.Failed, per-family via RunResourceAsync, global commit via the outer try/catch). Match keys per family: Name for Bridge/Interface/Bonding/Wifi*/User/DhcpServer; composite Bridge+Interface for BridgePort; composite Bridge+VlanIds for BridgeVlan; Address for IpAddress; composite DstAddress+Gateway for IpRoute; MacAddress (case-insensitive!) for DhcpLease; composite Chain+Comment for Firewall*; FirstOrDefault for Dns/SystemIdentity singletons. Delete safety guard via private const string NetsetupPrefix = "netsetup-" + IsNetsetupManaged(label): only 5 families have a delete loop (Bridge, Bonding, IpAddress, FirewallNatRule, FirewallFilterRule) and they only delete entries whose Name or Comment starts with netsetup- - manually-created MikroTik resources NEVER get touched. Singleton converges (ConvergeDnsAsync + ConvergeSystemIdentityAsync) treat null desired spec as a no-op zero-delta (so non-Liberator hosts can leave MikrotikDesiredState.Dns = null and SystemIdentity = null and the family is visited but does nothing). Interface and DhcpServer converges are Set-only (no Add path - device boots with a fixed set, converge toggles Disabled). IpRoute, FirewallFilterRule, WifiSecurity, User are Add-only (no Set, no Delete - reflects Liberator cutover semantics).

  • osisa.Mikrotik library is FEATURE-COMPLETE for adoc tasks 1-6 (Contracts + transport + JSON + entity catalog + UoW/DataStore + mapper + converge). NETSetup-side tasks 7 (credentials parser) + 8 (desired-state builder) + 9 (converge bridge + EnsureCustomerVms wire-in) are also done. All 9 RALPH tasks landed - feature COMPLETE. Single known follow-up: the task 9 wire-in chose EnsureCustomerVms.Run (paired with OpnsenseConvergeBridge.Run at lines 177-182) instead of the adoc-spec "explicit Phase 3e step 4 in RunProxmoxUpdate`" - operationally equivalent today since `EnsureCustomerVms runs inside RunProxmoxUpdate; revisit if/when explicit ConfigureProxmoxBridges + RewireVmNicsToVlans helpers are added.

  • NETSetup credentials parser (src/NETSetup/Helpers/MikrotikApiCredentials.cs, public static): real impl. public const string ApiEnvPath = "/etc/netsetup/mikrotik-api.env". TryLoad() → MikrotikConnection? reads the env file via (AbsolutePath)ApiEnvPath + path.FileExists()/path.ReadAllText() (osisa IO conventions); returns null if the file is missing. Parse(string) → MikrotikConnection? is a pure helper: empty/whitespace content returns null; per-line iteration over Split('\n', RemoveEmptyEntries) with Trim() strips CRLF, skip-on-blank-or-#, split on first =, value gets Trim('"') to support KEY="value" shell-style env files. Recognized keys: MIKROTIK_URL, MIKROTIK_USER, MIKROTIK_PASSWORD. Unknown keys silently ignored (forward-compat). Missing required key THROWS NETSetupException("<KEY> missing in mikrotik-api.env") - intentional divergence from OpnsenseApiCredentials (returns null on incomplete) because a present-but-malformed file means flash-mikrotik wrote it wrong, which is a real bug to surface. The resulting MikrotikConnection.AllowSelfSignedCertificate keeps its default true. NETSetup.csproj has a ProjectReference to osisa.Mikrotik.csproj enabling Contracts visibility. 10/10 tests green in MikrotikApiCredentialsTests covering valid parse + 3 missing-key throws + comments + quoted values + empty content + nonexistent file + unknown extra keys.

  • NETSetup converge bridge (src/NETSetup/Helpers/MikrotikConvergeBridge.cs, public static): real impl, FINAL task. public static void Run(ISystemHost host, NETSetupConfig config, IServiceProvider? services = null). Synchronous (.GetAwaiter().GetResult() on IMikrotikConverge.RunAsync) to match the synchronous EnsureCustomerVms flow. Best-effort: every failure path log + swallow, NEVER throws past the catch-all Exception handler. Phases: (1) ArgumentNullException on null host/config; (2) CredentialsProvider() (defaults to MikrotikApiCredentials.TryLoad, internal static Func<MikrotikConnection?> test seam) - null → info log "creds not found" + return; (3) LiberatorGate.IsFullLiberatorFlow(host, config) ? "Liberator" : "" + MikrotikDesiredStateBuilder.Build(config, hostDescription); (4) private IsEmptyState 4-property heuristic (Bridges.Length == 0 && SystemIdentity is null && WifiConfigurations.Length == 0 && Users.Length == 0) → info log "skipping …​ empty desired state" + return; (5) ResolveConverge(services) (DI lookup → fallback to self-built ServiceCollection with AddLogging() + AddMikrotik() if IMikrotikConverge not registered); (6) inside try: converge.RunAsync(connection, desired).GetAwaiter().GetResult() → !result.Reachable → warn "unreachable" + return, otherwise iterate result.Deltas info-logging "MikroTik converge {Resource}: +{Added}/~{Updated}/-{Deleted}"; (7) catch-all Exception → warn "converge threw - continuing" with exception attached. The LiberatorGate check lives INSIDE the bridge body (caller minimalism - EnsureCustomerVms calls unconditionally). Wired into src/NETSetup/Helpers/EnsureCustomerVms.cs at lines 177-182, immediately after OpnsenseConvergeBridge.Run, NOT inside RunProxmoxUpdate as the adoc spec suggested - the two REST-converge bridges paired side-by-side is more discoverable, and operational cadence is identical because EnsureCustomerVms itself runs inside RunProxmoxUpdate. 8/8 tests green covering null-creds skip, non-Liberator empty-state skip, unreachable warn, exception swallow, DI-vs-fallback ResolveConverge.

  • NETSetup desired-state builder (src/NETSetup/Helpers/MikrotikDesiredStateBuilder.cs, public static): real impl. Pure mapper, zero IO. public const string ManagedPrefix = "netsetup-" (matches delete-safety prefix) + public const string DefaultIdentityName = "Lib_AP". Build(NETSetupConfig config, string hostDescription) → MikrotikDesiredState. Non-Liberator (anything other than literal "Liberator" with StringComparison.Ordinal) returns new MikrotikDesiredState() = all-empty arrays + null singletons (no-op converge). Liberator branch emits the recipe: Bridges = [BridgeSpec("bridge1", "yes")] (VLAN filtering on, non-prefixed well-known name); BridgePorts 4 entries (ether2 + ether3 trunk with Pvid=1 + tagged "10,20,250,254"; ether4 + ether5 access with Pvid=20 + empty tagged); BridgeVlans 4 entries (VLAN 10/20/250/254 on bridge1, tagged on ether2+ether3, VLAN 20 also untagged on ether4+ether5); IpAddresses = [IpAddressSpec("10.0.250.1/24", "bridge1", "netsetup-mgmt")]; DhcpServers + FirewallNatRules + FirewallFilterRules empty (OPNsense owns DHCP/NAT/filter); WifiConfigurations 3 entries on wifi1/wifi2/wifi3 for SSIDs Liberator + Liberator-Guest + admin-oob-lib, country CH, channel 2ghz-onlyn,5ghz-ax / 2ghz-onlyn / 5ghz-ax, each Security field referencing a WifiSecuritySpec.Name; WifiSecurities 3 entries with PSK profiles (wpa2-psk,wpa3-psk for Liberator + open for Guest + wpa3-psk for admin-oob), passwords from config.LiberatorWifiPassword + config.MikrotikAdminPassword (null → empty placeholder); Users = [UserSpec("admin", adminPassword, "full")]; SystemIdentity = new SystemIdentitySpec(config.SelectedHostname ?? "Lib_AP"). New NETSetupConfig fields (added in this commit): LiberatorWifiPassword + MikrotikAdminPassword (both public string?; latter doubles as admin user pw AND admin-oob-lib WPA3 PSK - single source of truth). Adoc-spec drift: spec mentioned MachineName.Value, impl uses the real NETSetupConfig.SelectedHostname field. 18/18 tests green.

  • UPDATE (2026-05-29, dual-WAN inbound): the desired-state builder no longer leaves FirewallNatRules + FirewallFilterRules empty for inbound published services - it now emits per-ServedPort netsetup-dnat-{port} dst-nat + netsetup-allow-{port} forward-accept rules. New const MikrotikDesiredStateBuilder.WanInterfaceList = "WAN". Those rules set InInterfaceList = "WAN" with EMPTY InInterface (NOT literal ether1). Reason: behind an XGS-PON modem the WAN arrives tagged on VLAN 10 so ingress is ether1-vlan10, not ether1; an ether1-pinned port-forward would never match and inbound mail/nextcloud would be dead. Matching the WAN interface-list (which holds BOTH ether1 + ether1-vlan10) fires the rule on whichever iface carries the WAN. osisa.Mikrotik gained InInterfaceList on FirewallNatRuleSpec + FirewallFilterRuleSpec (+ entities + mapper + NAT drift-check). DEPENDENCY: the converge REFERENCES the WAN list but does NOT create it - it relies on the flash-mikrotik .rsc (MikrotikRscBuilder, new const WanVlanId = "10") having created the WAN interface-list + both members + a dhcp-client on each iface. Flash-then-update is the normal order. (OPNsense still owns the per-VLAN DHCP + outbound NAT + inter-VLAN filter; only the inbound published-service port-forwards live on the Mikrotik.)

  • Tests: 213 green (42 in `94c3e555`). Contracts + records (Connection, DesiredState, ConvergeResult, ResourceDelta, all 16 spec records, exception) + consolidated `MikrotikContractsTests` for DI + serialization shape. `MikrotikHttpClientTests` covers Basic auth header, 4xx/5xx -> exception mapping, JSON read-write round-trip, self-signed-cert toggle. `MikrotikStringConverterTests` covers raw-and-quoted Read and always-quoted Write per primitive. `MikrotikEntityTests` covers `.id` + `name` JSON round-trip and nullable defaults. `MikrotikRepositoryBaseTests` covers IsDirty transitions, GetAll/Get routing, FlushAsync verb dispatch (Add->PUT/Set->PATCH/Delete->DELETE), partial-fail capture (continue on per-op exception, Applied vs Failed split), Pending unconditional clear, empty-batch no-op. `MikrotikFlushResultTests` covers the result + failure record shapes. `MikrotikRepositoriesTests` (+32 in `d9af2b05`) asserts 17 `ResourcePath` strings + per-family entity JSON deserialization through `MikrotikStringConverter` (kebab-case keys, bool/long round-trip) + singleton-wrap behaviour for Dns/SystemIdentity/SystemResource + read-only `NotSupportedException` guard for SystemResource Add/Set/Delete. `MikrotikUnitOfWorkTests` ( in 311e7421) covers 17-repo getter non-null + CommitAsync with 0/1/3 dirty repos + sequential flush order + HasFailures propagation. MikrotikCommitResultTests covers HasFailures computation across mixed inner outcomes. MikrotikDataStoreReferenceTests covers ExistsAsync true on 200, false on refused/401/timeout/non-JSON, OpenDataStore returns a usable store, factory seam exercised. MikrotikServiceCollectionExtensionsTests asserts AddMikrotik registers IMikrotikConverge + IMikrotikDataStoreReferenceProvider as singletons (idempotent on multiple calls). MikrotikEntityMapperTests (+ in 94c3e555) covers ToEntity/ToSpec round-trip per family + null projection (string → "", bool → false, int → 0) + the BridgePortSpec.TaggedVlans <→ BridgePort.Tagged drift bridge. MikrotikConvergeTests (+ in 94c3e555) covers reachability-false short-circuit, golden-path mixed Add/Set/Delete, per-resource failure isolation (one family throws, others still run with correct deltas), netsetup--prefix delete safety, singleton Dns/SystemIdentity Set behaviour, null desired Dns = no-op zero-delta, all 16 families appear in the result. PhysicalMikrotikIntegrationTests (17 tests, Skip = "Requires a physical MikroTik device.") runs manually against a real hAP ax2 during commissioning - NOT executed in CI. TestInfrastructure/FakeHttpMessageHandler.cs route-keyed (mirrors osisa.OPNSense.Tests); TestInfrastructure/TestValues.cs hosts shared constants. (Test agent renamed the auth-header property assertion AuthHeader → AuthorizationHeader to match the shipped name.)

    • ✓ Create project scaffold + MikrotikHttpClient + MikrotikStringConverter + MikrotikException: Create src/osisa.Mikrotik/osisa.Mikrotik.csproj (net10, <InternalsVisibleTo Include="osisa.Mikrotik.Tests" />) and src/osisa.Mikrotik.Tests/osisa.Mikrotik.Tests.csproj (MSTest + FluentAssertions). Add both to src/NETSetup.sln. Create Client/MikrotikHttpClient.cs (internal) wrapping HttpClient with: Basic auth header (Authorization: Basic base64(username:password)), self-signed cert skip via HttpClientHandler.ServerCertificateCustomValidationCallback, typed methods GetAsync<T>(string path) returning T (GET /rest/{path}), GetSingleAsync<T>(string path) for singleton resources, PutAsync<T>(string path, T entity) returning T with .id (PUT /rest/{path}), PatchAsync(string path, string id, object body) (PATCH /rest/{path}/{id}), DeleteAsync(string path, string id) (DELETE /rest/{path}/{id}), PostAsync<T>(string path, object body) returning T (POST /rest/{path}). Error responses (HTTP 4xx/5xx) parsed from JSON {"error": N, "message": "…​", "detail": "…​"} into MikrotikException. Create Json/MikrotikStringConverter.cs - custom System.Text.Json.Serialization.JsonConverter factory handling MikroTik’s all-strings encoding: deserializes "true"/"false" to bool, numeric strings to int/long, preserves string passthrough. Registered on the shared JsonSerializerOptions used by MikrotikHttpClient. Create Exceptions/MikrotikException.cs with int StatusCode, string ErrorMessage, string? Detail. Create src/osisa.Mikrotik.Tests/TestInfrastructure/FakeHttpMessageHandler.cs (same pattern as osisa.OPNSense.Tests) with route-keyed response dictionary. Assertions:

    • httpClient.AuthHeader.Should().Be("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(SomeUser + ":" + SomePassword)))

    • getResult.Should().HaveCount(ExpectedCount)

    • getResult[0].Id.Should().Be(SomeEntityId)

    • putResult.Id.Should().NotBeNullOrEmpty()

    • act.Should().ThrowAsync<MikrotikException>().Where(e ⇒ e.StatusCode == 404 && e.ErrorMessage == SomeErrorMessage)

    • MikrotikStringConverter deserializing "true" .Should().Be(true)

    • MikrotikStringConverter deserializing "42" .Should().Be(42)

    • MikrotikStringConverter deserializing "ether1" .Should().Be("ether1")

    • MikrotikStringConverter serializing true .Should().Be("true")

    • MikrotikStringConverter serializing 42 .Should().Be("42")

    • ✓ Create Contracts public surface types: Create Contracts/ namespace in src/osisa.Mikrotik/ with all public types. MikrotikConnection record: string BaseUrl, string Username, string Password, bool AllowSelfSignedCertificate = true. IMikrotikConverge interface: Task<MikrotikConvergeResult> RunAsync(MikrotikConnection connection, MikrotikDesiredState desired, CancellationToken ct). MikrotikConvergeResult record: bool Reachable, MikrotikResourceDelta[] Deltas. MikrotikResourceDelta record: string ResourceType, int Added, int Updated, int Deleted. Desired state spec records (all in Contracts/): BridgeSpec(string Name, string VlanFiltering), BridgePortSpec(string Bridge, string Interface, int Pvid, string TaggedVlans), BridgeVlanSpec(string Bridge, string Tagged, string Untagged, string VlanIds), InterfaceSpec(string Name, bool Disabled), BondingSpec(string Name, string Slaves, string Mode, string TransmitHashPolicy, string LacpRate), IpAddressSpec(string Address, string Interface, string Comment), IpRouteSpec(string DstAddress, string Gateway, string Comment), DhcpServerSpec(string Name, bool Disabled), DhcpLeaseSpec(string Address, string MacAddress, string Comment), DnsSpec(string Servers, bool AllowRemoteRequests), FirewallNatRuleSpec(string Chain, string Action, string OutInterface, bool Disabled, string Comment), FirewallFilterRuleSpec(string Chain, string Action, string Protocol, string DstPort, string InInterface, string Comment, bool Disabled), WifiConfigurationSpec(string Name, string Ssid, string Country, string Channel, string Security), WifiSecuritySpec(string Name, string AuthenticationTypes, string Passphrase), UserSpec(string Name, string Password, string Group), SystemIdentitySpec(string Name). MikrotikDesiredState record: typed array properties for each spec (BridgeSpec[] Bridges, BridgePortSpec[] BridgePorts, BridgeVlanSpec[] BridgeVlans, InterfaceSpec[] Interfaces, BondingSpec[] Bondings, IpAddressSpec[] IpAddresses, IpRouteSpec[] IpRoutes, DhcpServerSpec[] DhcpServers, DhcpLeaseSpec[] DhcpLeases, DnsSpec? Dns, FirewallNatRuleSpec[] FirewallNatRules, FirewallFilterRuleSpec[] FirewallFilterRules, WifiConfigurationSpec[] WifiConfigurations, WifiSecuritySpec[] WifiSecurities, UserSpec[] Users, SystemIdentitySpec? SystemIdentity). MikrotikServiceCollectionExtensions.AddMikrotik(this IServiceCollection services) registering IMikrotikConverge + provider as singletons. Assertions:

    • new MikrotikConnection(SomeUrl, SomeUser, SomePassword).BaseUrl.Should().Be(SomeUrl)

    • new MikrotikConnection(SomeUrl, SomeUser, SomePassword).AllowSelfSignedCertificate.Should().BeTrue()

    • new MikrotikDesiredState with empty arrays .Bridges.Should().BeEmpty()

    • new BridgeSpec(SomeBridgeName, SomeVlanFiltering).Name.Should().Be(SomeBridgeName)

    • new MikrotikConvergeResult(true, Array.Empty<MikrotikResourceDelta>()).Reachable.Should().BeTrue()

    • new MikrotikResourceDelta(SomeResourceType, 1, 2, 0).Added.Should().Be(1)

    • ✓ Create MikrotikEntity base + MikrotikRepositoryBase<T> with batch buffering: Create Entities/MikrotikEntity.cs (internal) - base class with [JsonPropertyName(".id")] string? Id { get; set; } and string? Name { get; set; }. All MikroTik entity types will inherit from this. Create Repositories/IMikrotikRepository.cs (internal) with: Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct), Task<T> GetAsync(string id, CancellationToken ct), void Add(T entity) (buffers), void Set(string id, T entity) (buffers), void Delete(string id) (buffers), Task<MikrotikFlushResult> FlushAsync(CancellationToken ct), bool IsDirty. Create Repositories/MikrotikRepositoryBase.cs (internal abstract): stores pending operations in a List<PendingOperation> (discriminated union of Add/Set/Delete), GetAllAsync delegates to MikrotikHttpClient.GetAsync<List<T>>(ResourcePath), GetAsync delegates to MikrotikHttpClient.GetSingleAsync<T>(ResourcePath + "/" + id), Add/Set/Delete append to pending list, FlushAsync iterates pending ops pushing via HttpClient (PUT for adds, PATCH for sets, DELETE for deletes) collecting Applied[] and Failed[] into MikrotikFlushResult, clears pending on success. Abstract string ResourcePath { get; }. Create Repositories/MikrotikFlushResult.cs (internal): IReadOnlyList<string> Applied, IReadOnlyList<(string Id, Exception Error)> Failed. Assertions:

    • entity.Id.Should().Be(SomeEntityId)

    • entity.Name.Should().Be(SomeEntityName)

    • repo.IsDirty.Should().BeFalse() initially

    • repo.Add(someEntity); repo.IsDirty.Should().BeTrue()

    • getAllResult.Should().HaveCount(ExpectedCount) (via FakeHttpMessageHandler)

    • getResult.Id.Should().Be(SomeEntityId)

    • flushResult.Applied.Should().HaveCount(ExpectedAppliedCount) after Add+Set+Delete

    • flushResult.Failed.Should().BeEmpty() on success

    • repo.IsDirty.Should().BeFalse() after successful FlushAsync

    • flushResult on mid-batch failure: flushResult.Applied.Should().HaveCount(SomePartialCount) and flushResult.Failed.Should().HaveCount(1)

    • ✓ Create all concrete entity types + repository implementations for 17 MikroTik resource families: In Entities/ create internal entity classes inheriting MikrotikEntity, each with typed properties (via MikrotikStringConverter) matching the MikroTik REST API JSON fields. Bridge.cs: string? VlanFiltering, string? Protocol. BridgePort.cs: string? Bridge, string? Interface, int? Pvid, string? TaggedVlans (mapped from tagged). BridgeVlan.cs: string? Bridge, string? Tagged, string? Untagged, string? VlanIds (mapped from vlan-ids). RouterInterface.cs: string? Type, string? DefaultName, bool? Disabled, string? Running, string? MacAddress. Bonding.cs: string? Slaves, string? Mode, string? TransmitHashPolicy (mapped from transmit-hash-policy), string? LacpRate (mapped from lacp-rate). IpAddress.cs: string? Address, string? Network, string? Interface, bool? Disabled, string? Comment. IpRoute.cs: string? DstAddress (mapped from dst-address), string? Gateway, string? Distance, string? Comment. DhcpServer.cs: string? Interface, bool? Disabled`, string? AddressPool. DhcpLease.cs: string? Address, string? MacAddress (mapped from mac-address), string? Comment, bool? Disabled. DnsSettings.cs: string? Servers, bool? AllowRemoteRequests (mapped from allow-remote-requests). FirewallNatRule.cs: string? Chain, string? Action, string? OutInterface (mapped from out-interface), bool? Disabled, string? Comment. FirewallFilterRule.cs: string? Chain, string? Action, string? Protocol, string? DstPort (mapped from dst-port), string? InInterface (mapped from in-interface), string? Comment, bool? Disabled. WifiConfiguration.cs: string? Ssid, string? Country, string? Channel, string? Security. WifiSecurity.cs: string? AuthenticationTypes (mapped from authentication-types), string? Passphrase. User.cs: string? Password, string? Group. SystemIdentity.cs (singleton). SystemResource.cs (read-only): string? Platform, string? BoardName, string? Version, string? Uptime, long? FreeMemory, long? TotalMemory. In Repositories/ create concrete repos: BridgeRepository (path interface/bridge), BridgePortRepository (interface/bridge/port), BridgeVlanRepository (interface/bridge/vlan), InterfaceRepository (interface), BondingRepository (interface/bonding), IpAddressRepository (ip/address), IpRouteRepository (ip/route), DhcpServerRepository (ip/dhcp-server), DhcpLeaseRepository (ip/dhcp-server/lease), DnsSettingsRepository (ip/dns) - override GetAllAsync for singleton (GET returns single object, wrap in array), FirewallNatRuleRepository (ip/firewall/nat), FirewallFilterRuleRepository (ip/firewall/filter), WifiConfigurationRepository (interface/wifi), WifiSecurityRepository (interface/wifi/security), UserRepository (user), SystemIdentityRepository (system/identity) - singleton override, SystemResourceRepository (system/resource) - read-only (Add/Set/Delete throw NotSupportedException). Use [JsonPropertyName("kebab-case")] attributes on entity properties where MikroTik JSON key differs from C# PascalCase. Assertions:

    • bridgeRepo.ResourcePath.Should().Be("interface/bridge")

    • ipAddressRepo.ResourcePath.Should().Be("ip/address")

    • firewallNatRepo.ResourcePath.Should().Be("ip/firewall/nat")

    • wifiConfigRepo.ResourcePath.Should().Be("interface/wifi")

    • systemResourceRepo.ResourcePath.Should().Be("system/resource")

    • Bridge deserialized from {“.id”:"*1","name":"bridge1","vlan-filtering":"true"}: result.Id.Should().Be("*1"), result.Name.Should().Be("bridge1"), result.VlanFiltering.Should().Be("true")

    • IpAddress deserialized from sample JSON: result.Address.Should().Be(SomeAddress), result.Interface.Should().Be(SomeInterface)

    • DnsSettingsRepository.GetAllAsync wraps singleton response in array: result.Should().HaveCount(1)

    • SystemResourceRepository.Add should throw NotSupportedException

    • FirewallFilterRule deserialized with dst-port mapped to DstPort property

    • ✓ Create UoW + DataStore + DataStoreReference + Provider + DI registration: In UnitOfWork/ create IMikrotikUnitOfWork.cs (internal): exposes all 17 repos as read-only properties (IMikrotikRepository<Bridge> Bridges, IMikrotikRepository<BridgePort> BridgePorts, etc.) + Task<MikrotikCommitResult> CommitAsync(CancellationToken ct). Create MikrotikCommitResult.cs (internal): IReadOnlyList<MikrotikFlushResult> Results, bool HasFailures. Create MikrotikUnitOfWork.cs (internal): constructor takes MikrotikHttpClient, instantiates all 17 concrete repos, CommitAsync iterates repos where IsDirty == true, calls FlushAsync on each, aggregates into MikrotikCommitResult. In DataStore/ create IMikrotikDataStore.cs (internal): IMikrotikUnitOfWork UnitOfWork { get; }, IDisposable. Create MikrotikDataStore.cs: creates MikrotikHttpClient from MikrotikConnection, passes to MikrotikUnitOfWork. Create IMikrotikDataStoreReference.cs (internal): Task<bool> ExistsAsync(CancellationToken ct), IMikrotikDataStore OpenDataStore(). Create MikrotikDataStoreReference.cs: ExistsAsync probes GET /rest/system/resource - returns true on 200, false on any exception (connection refused, timeout, auth failure). Create IMikrotikDataStoreReferenceProvider.cs (internal): IMikrotikDataStoreReference GetReference(MikrotikConnection connection). Create MikrotikDataStoreReferenceProvider.cs. In root, update MikrotikServiceCollectionExtensions.AddMikrotik: register IMikrotikDataStoreReferenceProvider → MikrotikDataStoreReferenceProvider (singleton), IMikrotikConverge → MikrotikConverge (singleton). Assertions:

    • uow.CommitAsync with no dirty repos: result.Results.Should().BeEmpty()

    • uow.Bridges.Add(someBridge); uow.CommitAsync: result.Results.Should().HaveCount(1)

    • uow.CommitAsync with 3 dirty repos: result.Results.Should().HaveCount(3)

    • uow.CommitAsync with one repo failing: result.HasFailures.Should().BeTrue()

    • dataStoreRef.ExistsAsync returns true on 200 response

    • dataStoreRef.ExistsAsync returns false on connection refused

    • provider.GetReference(someConn).Should().NotBeNull()

    • AddMikrotik DI: services.BuildServiceProvider().GetRequiredService<IMikrotikConverge>().Should().NotBeNull()

    • AddMikrotik DI: services.BuildServiceProvider().GetRequiredService<IMikrotikDataStoreReferenceProvider>().Should().NotBeNull()

    • ✓ Create MikrotikEntityMapper + MikrotikConverge orchestrator: Create Mapping/MikrotikEntityMapper.cs (internal static) with bidirectional mapping methods between Contracts *Spec records and internal entity types. One ToEntity/ToSpec method pair per resource family (e.g., Bridge ToEntity(BridgeSpec spec), BridgeSpec ToSpec(Bridge entity)). Handle nullable fields. Create MikrotikConverge.cs (internal, implements IMikrotikConverge): RunAsync flow: (1) provider.GetReference(connection), (2) ExistsAsync - if false, return MikrotikConvergeResult(Reachable: false, Deltas: []), (3) OpenDataStore() → UoW, (4) per resource family: GetAllAsync current state, diff against desired by name/key matching (name for bridges/interfaces/bondings/wifi/users/identity, address for IPs, chain+comment for firewall rules, mac-address for DHCP leases), queue Add for missing, Set for changed (compare all properties), Delete for entries present on device but not in desired state AND (name starts with "netsetup-" OR matches an entry in desired state that was removed - safety guard: never delete user-created resources), (5) uow.CommitAsync(), (6) build MikrotikConvergeResult with per-resource MikrotikResourceDelta, (7) per-resource failures captured as warnings in result (same isolation pattern as osisa.OPNSense) - one resource family failing must not block others. DnsSettings and SystemIdentity are singletons: diff by comparing current vs desired, Set if different, no Add/Delete. Assertions:

    • mapper: MikrotikEntityMapper.ToEntity(someBridgeSpec).Name.Should().Be(SomeBridgeName)

    • mapper: MikrotikEntityMapper.ToSpec(someBridgeEntity).VlanFiltering.Should().Be(SomeVlanFiltering)

    • converge unreachable: result.Reachable.Should().BeFalse()

    • converge golden path (add 2 bridges + set 1 IP + delete 1 NAT rule): result.Deltas.Should().Contain(d ⇒ d.ResourceType == "Bridge" && d.Added == 2)

    • converge empty desired state: result.Deltas.Should().AllSatisfy(d ⇒ d.Added == 0 && d.Updated == 0 && d.Deleted == 0)

    • converge per-resource failure: bridges fail, IPs succeed → result still contains IP delta with correct counts

    • converge safety guard: existing "manual-rule" not in desired state → NOT deleted. existing "netsetup-old-rule" not in desired state → deleted

    • converge singleton (SystemIdentity): current "MikroTik" + desired "Lib_AP" → delta Updated == 1

    • ✓ Create NETSetup MikrotikApiCredentials env file parser: Create src/NETSetup/Helpers/MikrotikApiCredentials.cs with static Parse(string content) returning MikrotikConnection. Reads KEY=VALUE format (one per line, # comments, empty lines skipped): MIKROTIK_URL → BaseUrl, MIKROTIK_USER → Username, MIKROTIK_PASSWORD → Password. Missing required field (URL, USER, PASSWORD) throws NETSetupException with field name in message. Add TryReadFromDisk(AbsolutePath path, ILogger logger) → MikrotikConnection?: returns null if file doesn’t exist (info log), otherwise reads + Parse. Env file path constant: /etc/netsetup/mikrotik-api.env. Test class in src/NETSetup.Tests/HelperTests/MikrotikApiCredentialsTests.cs. Assertions:

    • Parse valid content: result.BaseUrl.Should().Be(SomeMikrotikUrl)

    • Parse valid content: result.Username.Should().Be(SomeMikrotikUser)

    • Parse valid content: result.Password.Should().Be(SomeMikrotikPassword)

    • Parse missing MIKROTIK_URL: act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("MIKROTIK_URL"))

    • Parse missing MIKROTIK_USER: act.Should().Throw<NETSetupException>().Where(e ⇒ e.Message.Contains("MIKROTIK_USER"))

    • Parse with comments and empty lines: result.BaseUrl.Should().Be(SomeMikrotikUrl)

    • TryReadFromDisk with nonexistent file: result.Should().BeNull()

    • ✓ Create NETSetup MikrotikDesiredStateBuilder for Liberator VLAN cutover: Create src/NETSetup/Helpers/MikrotikDesiredStateBuilder.cs with Build(NETSetupConfig config, string hostDescription) returning MikrotikDesiredState. For hostDescription == "Liberator" (role gate from ): Bridges = one "bridge1" with VlanFiltering="yes". BridgePorts = ether2 pvid=1 tagged 10,20,250,254 (trunk to Proxmox), ether3 pvid=1 tagged 10,20,250,254 (trunk to Proxmox), ether4 pvid=20 (LAN access), ether5 pvid=20 (spare LAN). BridgeVlans = VLAN 10,20,250,254 on bridge1 with appropriate tagged/untagged ports. IpAddresses = 10.0.250.1/24 on bridge1 VLAN 250 (OOB/MGMT). DhcpServers = all found servers set Disabled=true (OPNsense handles DHCP). FirewallNatRules = masquerade rule set Disabled=true (OPNsense handles NAT). FirewallFilterRules = empty (OPNsense handles filtering). WifiConfigurations = "Liberator" on wifi1 (2.4+5 GHz) VLAN 20, "Liberator-Guest" on wifi2 open VLAN 254, "admin-oob-lib" hidden WPA3 VLAN 250. WifiSecurities = WPA2/3 PSK for Liberator, WPA3 for admin-oob-lib, none for Guest. SystemIdentity = hostname from config or "Lib_AP". Users = admin with config.MikrotikAdminPassword (from flash-mikrotik flashed password). For non-Liberator hostDescription: return MikrotikDesiredState with all empty arrays and null singletons (no-op converge). WiFi passwords sourced from NETSetupConfig fields (existing WiFi password config or flash-mikrotik defaults). Test class in src/NETSetup.Tests/HelperTests/MikrotikDesiredStateBuilderTests.cs. Assertions:

    • Liberator: result.Bridges.Should().HaveCount(1)

    • Liberator: result.Bridges[0].Name.Should().Be("bridge1")

    • Liberator: result.Bridges[0].VlanFiltering.Should().Be("yes")

    • Liberator: result.BridgePorts.Should().HaveCount(4) (ether2, ether3, ether4, ether5)

    • Liberator: result.BridgePorts.First(p ⇒ p.Interface == "ether2").Pvid.Should().Be(1)

    • Liberator: result.BridgeVlans.Should().Contain(v ⇒ v.VlanIds == "10")

    • Liberator: result.DhcpServers.Should().AllSatisfy(s ⇒ s.Disabled.Should().BeTrue())

    • Liberator: result.FirewallNatRules.Should().AllSatisfy(r ⇒ r.Disabled.Should().BeTrue())

    • Liberator: result.WifiConfigurations.Should().HaveCount(3)

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "Liberator")

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "Liberator-Guest")

    • Liberator: result.WifiConfigurations.Should().Contain(w ⇒ w.Ssid == "admin-oob-lib")

    • Liberator: result.SystemIdentity.Should().NotBeNull()

    • non-Liberator: result.Bridges.Should().BeEmpty()

    • non-Liberator: result.SystemIdentity.Should().BeNull()

    • ✓ Create NETSetup MikrotikConvergeBridge + wire into RunProxmoxUpdate: Create src/NETSetup/Helpers/MikrotikConvergeBridge.cs with Run(ISystemHost host, NETSetupConfig config, IServiceProvider services, ILogger logger). Best-effort pattern (same as OpnsenseConvergeBridge): (1) MikrotikApiCredentials.TryReadFromDisk - null → info log "no mikrotik credentials" + return, (2) MikrotikDesiredStateBuilder.Build(config, host.Description) - empty desired state (non-Liberator) → info log + return, (3) resolve IMikrotikConverge from services (build one-shot ServiceCollection with AddMikrotik() if not pre-registered), (4) converge.RunAsync(connection, desired, ct), (5) if !result.Reachable → warn log "MikroTik unreachable at {url}" + return, (6) log each delta, (7) catch all exceptions → warn log + continue (never throw). Wire into src/NETSetup/Helpers/UpdateMethods.cs RunProxmoxUpdate as step 4 from Liberator design (after EnsureCustomerVms/OpnsenseConvergeBridge, before ConfigureProxmoxBridges), gated on runFullNetworkFlow (host.Description == "Liberator" && hasOpnsenseVm). Test class in src/NETSetup.Tests/HelperTests/MikrotikConvergeBridgeTests.cs. Assertions:

    • missing creds file: should not throw, logger should contain "no mikrotik credentials"

    • non-Liberator host: should not throw, should not call IMikrotikConverge.RunAsync

    • unreachable MikroTik: should not throw, logger should contain "unreachable"

    • successful converge: IMikrotikConverge.RunAsync.Received(1)

    • IMikrotikConverge.RunAsync throws: should not throw (caught), logger should contain warning

    • RunProxmoxUpdate calls MikrotikConvergeBridge.Run when isLiberator && hasOpnsenseVm

    • RunProxmoxUpdate skips MikrotikConvergeBridge.Run when !isLiberator

4. In Development

4.1. vm-datastore-v5

GOAL: Virtual machines are handled like records in a database. The virtualisation environment (Hyper-V, Proxmox) is the database provider; a host/scope is the data store; a VM is a record. Adding a VM is an Insert, removing one is a Delete, changing one is an Update - and an Update on a hypervisor means stop → change → restart. The whole surface follows the netbase V5 DataStore schema (DataStoreReferenceBase5 + DependencyInjectionDataStoreReferenceBuilder5 + IDataStore2<TUnitOfWork> + IUnitOfWork + ICanRead/ICanInsert/ICanUpdate/ICanDelete), and the tests follow the netbase test patterns (DataStoreReference5TestBase, UnitOfWorkRepositoryTestsBase3, UnitTestsBase, TestValues Some* constants, TypedTestDataBuilderDataSource).

ACCEPTANCE CRITERION (this is what "done" means): two test classes exist that differ only in the wiring of the provider - HyperVVirtualMachineReadAllTests (local Hyper-V) and ProxmoxVirtualMachineReadAllTests (osisaLib03) - both derive from one shared VirtualMachineReadAllTestBase<TReference, TBuilder> that holds the entire test body, and both deliver the correct VM count for their connection. Every line of scenario logic lives in the base; the derived classes contribute a ConfigureBuilder and a native-API count oracle and nothing else. If a reader can diff the two files and see only the provider wiring, the abstraction is proven.

NON-GOAL / HARD CONSTRAINT: This iteration is purely additive. EnsureCustomerVms, CreateVirtualMachines, ProvisionNixosVm, InstallNixosVm, UpdateNixosVm, ProvisionWindowsVmOnProxmox, ProvisionOpnsenseVm and UpdateMethods.RunProxmoxUpdate keep their current runtime behaviour byte for byte. Nothing in the new layer is called from the production update path in this iteration. The DB semantics become visible in the tests - the new suite is the proof that the model holds - not in a changed netsetup update. Wiring the planner into RunProxmoxUpdate is a deliberate follow-up spec, gated behind a green suite here.

Decisions:

  • Is there already a UnitOfWork pattern here? YES - and it is reused, not reinvented. ISystemManagementUnitOfWork (ships in osisa.SystemManagement.Windows, built on DataStoreReferenceBase5 + DataStoreNameDependencyInjectionDataStoreReferenceBuilder5 + CanGetSingleUnitOfWorkDataStoreReferenceBase) is already consumed in this repo - see NETSetup.Tests/ExeTests/GetTests.cs and NETSetup.Tests/UEFIDriverTests.cs:

    var provider = new MachineScopeProvider();
    var dataStoreReference = provider.GetReferences().First();
    var dataStore = dataStoreReference.OpenDataStore<ISystemManagementUnitOfWork>();
    var unitOfWork = dataStore.GetUnitOfWork();
    var repository = unitOfWork.GetRepository<IDisk>();
    var disks = repository.ReadAll();

    That is exactly the shape this spec needs, one level up: the machine scope is the data store, IDisk is the record, ReadAll() is the query. The VM layer is the same sentence with a Proxmox/Hyper-V data store and IVirtualMachine as the record. MachineScopeProvider3 : IDataStoreReferenceProvider3 is the provider precedent to copy.

  • Suggested UnitOfWork interface - deliberately mirrors ISystemManagementUnitOfWork so the two read identically at the call site:

    namespace osisa.VirtualMachines.Contracts;
    
    /// <summary>
    /// Unit of work over one virtualisation environment. The environment (Hyper-V scope,
    /// Proxmox node) is the data store; a virtual machine is a record.
    /// </summary>
    public interface IVirtualizationUnitOfWork : IUnitOfWork
    {
        /// <summary>Virtual machines in this environment. Insert / Update / Delete are buffered
        /// until <see cref="IUnitOfWork.Commit"/>.</summary>
        IVirtualMachineRepository VirtualMachines { get; }
    }
    
    /// <summary>Full-CRUD repository of virtual machines.</summary>
    public interface IVirtualMachineRepository : IGenericRepository<VirtualMachineSettings>
    {
        // inherits ICanRead / ICanInsert / ICanUpdate / ICanDelete<VirtualMachineSettings>
    }

    The named VirtualMachines property is the ergonomic accessor; unitOfWork.GetRepository<VirtualMachineSettings>() MUST resolve the same instance, so both idioms work and the generic ISystemManagementUnitOfWork habit carries over unchanged.

  • AddReference2 → the real API is AddReference3<TBuilder>(Action<TBuilder>) on DataStoreReferenceProvider3.Builder. osisa.Repository exposes exactly two: AddReference(Func<IServiceProvider, ICanGetAnyUnitOfWork>) (Shape A) and AddReference3<TBuilder>(Action<TBuilder>) (Shape B). There is no AddReference2. Both providers are registered on one builder, one call each:

    _inner = new DataStoreReferenceProvider3.Builder(services)
        .AddReference3<HyperVVmDataStoreReference5.Builder>(b => b
            .WithDataStoreName(LocalHyperVStoreName)
            .WithManagementScope(LiveMethods.ConnectToLiveLocalSystem(@"root\virtualization\v2")))
        .AddReference3<ProxmoxVmDataStoreReference5.Builder>(b => b
            .WithDataStoreName(OsisaLib03StoreName)
            .WithHost(proxmox.GetHost(OsisaLib03StoreName)))
        .Build();

    GetReferences() then yields two references, and the same OpenDataStore3<IVirtualizationUnitOfWork>() → GetUnitOfWork() → VirtualMachines.ReadAll() walk works against both. That walk is the validation the acceptance criterion asks for.

  • New projects: src/osisa.VirtualMachines/ + src/osisa.VirtualMachines.Tests/, both added to src/NETSetup.sln. Target $(DefaultNetTargetFramework). Test project name is {Source}.Tests - capital T, plural s - per netbase convention. Precedent to mirror for the library shape: osisa.Mikrotik (Contracts-only public surface, internal repositories/UoW/DataStore, InternalsVisibleTo for its .Tests).

  • Provider = environment. Two real providers plus one in-memory provider:

    • HyperVVmDataStoreReference5 - a Hyper-V ManagementScope (root\virtualization\v2) is one data store.

    • ProxmoxVmDataStoreReference5 - a Proxmox node (osisa.SystemManagement.Proxmox.Host) is one data store.

    • MemoryVmDataStoreReference5 - deterministic in-process store. This is what makes the CRUD semantics testable with zero hypervisor, and it is the store the netbase UnitOfWorkRepositoryTestsBase3 CRUD suite runs against.

  • Provider constants live in NETSetup, NOT in netbase: WellKnownDataStoreProviders ships Sqlite/SqlServer/Memory/Mock/Excel/Postgres/Rest only. Add NetsetupDataStoreProviders (public static class) with HyperV, Proxmox, MemoryVm string constants, used by [DataStoreProvider(…​)] on each reference.

  • VirtualMachineSettings is a projection, not a competitor, of osisa.SystemManagement.Contracts.Interfaces.IVirtualMachine - the read-model both hypervisor libraries already share. A flat provider-neutral record is what lets one repository, one UoW and one test base span Hyper-V and Proxmox; the native types stay behind the operations seam.

  • Record identity: keyed by SerialNumber ({VMID}@{HostSerial}), NOT by MachineName. The serial is the already-existing primary key of the NETSetup VM model (CreateVirtualMachines.IsForHost parses it) and it survives a rename; a name change must read as an Update, not Delete + Insert. VirtualMachineKey is a Vogen VO wrapping the serial string. For a VM discovered on a host that carries no NETSetup serial (hand-created), the key falls back to {VmId}@{HostName} so ReadAll never drops a record it cannot name.

  • Change buffering (batch-then-commit, same as MikrotikUnitOfWork): Insert/Update/Delete mutate an in-memory pending list and flip IsDirty; nothing touches the hypervisor until Commit(). NoCommit therefore genuinely means "the VM was never touched" - that is the assertion that proves the transaction boundary is real. ReadAll is always live and unbuffered.

  • Commit ordering is fixed and asserted: Delete first, then Update, then Insert. Deleting before inserting frees vmids and MACs for the inserts in the same commit; inserting last means a failed insert cannot orphan a half-updated VM.

  • Update == stop → change → restart is the core semantic and is modelled explicitly, not implied:

    • VmOperation enum: Read, Insert, Stop, Change, Start, Delete.

    • IVirtualMachineOperations (internal, one impl per provider) exposes Read, Insert, Stop, Change, Start, Delete as six separate calls.

    • Commit() expands one pending Update into exactly three recorded operations, in order: Stop, Change, Start.

    • A VM that was already stopped before the update is NOT restarted - Start is emitted only when the pre-update PowerState was Running. Restarting a deliberately-stopped VM would be a behaviour change smuggled in through the abstraction.

    • An Update whose diff is empty is a no-op: zero operations recorded, no stop, no restart. Idempotent netsetup update cycles must not bounce healthy VMs.

  • VmCommitResult (public record) carries IReadOnlyList<VmOperationRecord> Operations (Key, Operation, Ordinal) plus Inserted/Updated/Deleted counts and Failed keys. Tests assert on this list - it is the observable proof of stop/change/restart.

  • Provider mapping of the six operations. No new hypervisor code - every verb already exists in the shipped libraries:

    • Hyper-V (osisa.SystemManagement.HyperV): Read → ManagementScope.ReadAllVirtualMachines() (ReadVirtualMachine(name) for single); Insert → ManagementScope.InsertVirtualMachine (already DB-named - see NETSetup.Helpers.VMHelper.CreateVM); Stop → VMHelper.StopVM (vm.System.Stop()); Start → VMHelper.StartVM (vm.System.Start()); Delete → RemoveVirtualMachineAsync / DestroyVirtualMachine.

    • Proxmox (osisa.SystemManagement.Proxmox): Read → Host.GetVirtualMachines() / TryGetVirtualMachine; Insert → Host.CreateVirtualMachine(vm, autoStart, storage, isoStorage); Stop → StopVirtualMachineAsync (ShutdownVirtualMachineAsync when the guest agent is up); Start → StartVirtualMachineAsync; Delete → RemoveVirtualMachineAsync.

    • VMHelper.RebootVM and RebootVirtualMachineAsync are deliberately NOT used to implement Update: reboot is one atomic guest operation and would hide the Change step between stop and start.

  • Test targets, from the existing in-repo infrastructure - do not invent new ones:

    • Proxmox: node osisaLib03, API 192.168.3.233:8006, user root, password default4244$ - same constants ProxmoxNixosNextcloudTests and ProxmoxStandaloneWin11NodeTests already use. Connect by LAN IP, not hostname: osisaLib03 is not DNS-resolvable from every dev box.

    • Hyper-V: LiveMethods.ConnectToLiveLocalSystem(@"root\virtualization\v2"), exactly as VirtualHyperVTestBase.HyperVScope does.

  • How "the correct VM count" is asserted. The count is environment-dependent, so a hard-coded number would be a false green on any other box. The oracle is the native API of the same provider: the DataStore count must equal scope.ReadAllVirtualMachines().Count() (Hyper-V) resp. pveHost.GetVirtualMachines().Count() (Proxmox). One abstract ExpectedVmCount on the base, one line in each derived class. The base additionally asserts the count is > 0 (an empty result would satisfy an equality-only oracle trivially) and TestContext.WriteLine`s every VM name so the run is diagnosable. Both live classes carry `[TestCategory("local")] and Assert.Inconclusive when their environment is absent - the suite must stay green on a build agent with no Hyper-V and no route to 192.168.3.233.

  • Commit() is fail-fast per record but aggregating across records: a failing record records its partial operations, is added to VmCommitResult.Failed, and the loop continues; Commit() throws VmDataStoreException at the end listing every failed key - same contract as EnsureCustomerVms.RunCore, so a future wire-in is a drop-in.

  • VmDesiredStateBuilder (NETSetup side, public static, pure, zero IO) reuses CreateVirtualMachines.GetOrderedVmsForHost verbatim so desired-state ordering (OPNsense → DC → rest → NixDns last) stays the same single source of truth as today’s provisioning order. No duplicated ordering logic.

  • VmDataStorePlanner (NETSetup side, public static, pure, zero IO) diffs desired vs live. Delete safety guard, mirroring MikrotikDesiredStateBuilder.ManagedPrefix: a live VM is only ever proposed for deletion when its serial is hosted on this host (CreateVirtualMachines.IsForHost). A hand-created VM, or one belonging to another host, is never in Deletes. VmChangePlan has no Apply in this iteration - the planner is read-only by design, and that is what keeps the spec behaviour-neutral.

  • No System.IO, no raw Process: AbsolutePath + PhysicalFileOperator and the NETCommand builders, per repo convention.

  • Test packages for src/osisa.VirtualMachines.Tests/: osisa.Repository, osisa.Repository.Contracts, osisa.Repository.TestInfrastructure, osisa.Domain.Mock, osisa.Specifications, osisa.TestInfrastructure, osisa.TestInfrastructure.Contracts, osisa.Hosting.TestInfrastructure, osisa.SystemManagement.HyperV, osisa.SystemManagement.Proxmox, MSTest + FluentAssertions. RISK: osisaRepositoryVersion is pinned at 0.19.0-ModelFeature.191 while the restored osisa.Repository.TestInfrastructure carrying DataStoreReference5TestBase is 0.19.0-ModelFeature.292+. If .191 does not ship DataStoreReference5TestBase, bump osisaRepositoryVersion to the netbase-current 0.19.0-ModelFeature.292 in one isolated commit and re-run the full suite BEFORE starting task 9.

  • Test values: src/osisa.VirtualMachines.Tests/TestInfrastructure/TestValues.cs with Some* constants only (SomeVmName, SomeOtherVmName, SomeHostSerial, SomeVmSerial, SomeMemoryBytes, SomeUpdatedMemoryBytes, SomeCoreCount, SingleItemCount, TwoItemCount, SixOperationCount, …​). No Value, no Integer1.

Status (2026-08-30, SOFT-DELETE CONTRACT MOVED TO NETBASE + HYPER-V FIRMWARE BY OS, UNCOMMITTED in BOTH repo): four change, two of them BEHAVIOUR.

  • IPermanentDeletions<TEntity> left this repo. It now ship in netbase osisa.Repository.Contracts/Repositories/, derive IRepositoryBase<TEntity>, and keep the same two member (ReadSoftDeleted() + ExecutePermanentDeletionsAsync(ct)). The old osisa.Virtualization/Repositories/IPermanentDeletions[TEntity].cs is DELETED; consumer only repoint the using. Reason: a two-step deletion is a repository capability, so a netbase TEST BASE can gate on it - which is impossible while the interface live in a consumer library. Semantics unchanged, see the netsetup root NETSetup.adoc section "Deletion is two steps".

    • Doc crefs in osisa.Virtualization must now be FULLY QUALIFIED: osisa.Repository.Contracts export a MachineName of its own and a bare cref go ambiguous.

    • The interface is INVARIANT on purpose. Covariance was tried and dropped - out TEntity would foreclose a later PermanentlyDeleteAsync(TEntity) forever.

  • CRUD suites moved v3 → v4. Netbase gained UnitOfWorkRepositoryTestsBase4[…​] plus the […​,TKey] keyed variant: v3 plus SIX soft-delete case, gated on RepositorySupportsPermanentDeletions. VirtualMachineRepositoryCrudTestBase2 (and with it the HyperV + Proxmox live suites) sit on v4 now - 8 → 14 case per provider suite. ReadSoftDeletedCount() and ExecutePermanentDeletions() are virtual because a live hypervisor is a SHARED store; the base override both and filter through IsOwn, and the destroy route through the existing ExecuteOwnDeletions guard, which go Assert.Inconclusive rather than destroy a guest the suite did not create. New tests + a fake cover the contract in osisa.Repository.Contracts.Tests. NETConfigDataStoreTests2 deliberately STAY on v3: its store is JSON-backed, so the six cases would mark and destroy a config entry, not a guest.

  • BEHAVIOUR: Hyper-V firmware is now decided by the row OS and applied AT CREATION. New Operations/HyperVFirmware.cs + a changed HyperVVmOperations: a Windows-family row is created with Secure Boot ON and a vTPM, anything else with both OFF, passed as the VirtualBIOS constructor argument. Both halves are needed - Windows 11 demand the pair, and the Microsoft UEFI CA reject the unsigned nixos ISO the moment Secure Boot is on. New Contracts/OperatingSystemExtensions.Is(this OperatingSystem, OperatingSystemFamily) is the one place that answer "is this a Windows row".

    Warning
    enabling a vTPM AFTER the guest exist is UNRECOVERABLE. Measured on the bench this session: the vTPM seal guest state to itself, so a guest that was installed without one refuse to start with 0xC000A002 (STATUS_AUTH_TAG_MISMATCH) once one is added. There is no later switch - which is exactly why this belong in the create call and NOT in an Update a synchronisation could apply.

    HyperVVirtualMachineRepositoryCrudTests2 therefore provision a Windows 11 shaped guest, 64 GB, that actually BOOTS (a row with no OS would only ever prove the Linux half, and a guest that never boot never exercise the firmware it was given), gate on its boot image fixture (Assert.Inconclusive when the image is absent - the netbase VirtualMachine ctor refuse a guest with no ISO), and honour NETSETUP_KEEP_CRUD_GUESTS to keep the created guests for inspection instead of destroying them.

    COST, no full-suite figure available: only ONE case was timed this session, UnitOfWorkRepositoryTestBase_Insert_Commit on Hyper-V - 11m29s on the old Minimal shape, then 18m19s and 20m30s once the guest became the booting 64 GB Windows 11 shape. Do NOT extrapolate a 14-case suite time from that. Measure the suite before quoting one.

  • BEHAVIOUR: osisa.OPNSense.Rest/UnitOfWork/FirewallGroupsUnitOfWork3.cs ported. Netbase UnitOfWorkBase3 take an ILogger instead of an ITransaction3 and declare an abstract OnCommit(); the transaction is HELD by the unit of work and flushed in OnCommit. This is the port the version hold recorded below (see the 2026-08-24 audit entry) said would be needed - it is done, and the hold in src/Directory.Packages.props is released.

  • PACKAGE VERSIONS in both repo currently point at LOCALLY PACKED builds. They are local-only and must not be committed, so no version number is written here - a doc that name an unpublished package send the next reader to a feed that does not have it.

  • BEHAVIOUR: Stages/0-Iso/S5NETSetupDev.UpdateISO fixed. Two defects, both of which produced a SILENT bad outcome:

    1. Deadlock. It blocked on builder.Build().ExecuteAsync().Result, which deadlock under a test host’s SynchronizationContext. Symptom: publish complete, then zero CPU and zero bytes until the case time out three hours later - it read as a slow build, not as a hang. Now Task.Run) ⇒ command.ExecuteAsync(.GetAwaiter().GetResult().

    2. 1-byte ISO. The result was logged at Debug and the exit code NEVER checked, so a failed build look identical to a good one: the caller moved a file that was never written and handed a guest an unbootable medium. UpdateISO now THROW on non-zero exit, and the new private EnsureIsoBuilt verify the image exist and is >= MinimumPlausibleIsoBytes (1 MB).

Bench findings from the SAME session, measured on osisaLib03 (192.168.3.233) and the local Hyper-V bench. These are environment + creation-path rules, not repository work, so they live in full in docs/winpe.adoc section "Deploy Blockers" (durable rules) and in the netsetup-windows-node-proxmox skill reference references/deploy-blockers.md (command recipes). Recorded here only because they decide whether a run on this bench can be trusted:

  • A live suite that provision an INSTALL guest must disable ballooning / dynamic memory. WinPE have no balloon driver, so the guest get squeezed and the DISM apply HANG at zero bytes rather than fail. VirtualHyperVTestBase today call DisableDynamicMemory only for non-Windows rows - and a Windows row install through WinPE just the same, so the OS gate is the wrong gate.

  • The vTPM measurement behind the firmware change above. Enabling a vTPM after the guest have state give 0xC000A002 STATUS_AUTH_TAG_MISMATCH and is unrecoverable - already stated in the firmware bullet, repeated here because it is what makes "AT CREATION" non-negotiable.

  • DEFECT, creation path: default cpu=x86-64-v3 cannot start on a host without AVX2. osisaLib03 lack avx2/bmi1/bmi2/fma/movbe/abm and QEMU exit code 1. ProxmoxWindowsVmFactory, ProvisionNixosVm and ProxmoxVmOperations already override to host; any NEW creation path must, or the guest is created and never start.

  • CreateVirtualMachine can return true while qmcreate failed. The failure surface later as Failed to find VirtualMachine …​ Sequence contains no matching element from the round-trip read

    • which read like a READ defect and is not. Read /nodes/<node>/tasks.

  • devboxiso is the reason a Proxmox Win11 test take seconds. It is a CIFS mount of the dev box NETSetupISO share whose template\iso is a junction to C:\NETSetupDEV, so the host REFERENCE the 11.9 GB ISO. Offline → no guest can be created, and uploading is not a fallback (0.15 MB/s measured, ~20 h).

  • A [TestCategory("Prod")] test report GREEN having run nothing. src/Test.Build.props force local.runsettings, whose TestCategory!=Prod filter apply at DISCOVERY, so the method is never enumerated and an inherited base-class case pass instead. Use --settings src/prod.runsettings, and FullyQualifiedName~ (not Name=) because these methods are [DataRow]-driven. Any Prod result quoted in this file must have been produced that way.

Status (2026-08-23, LANDED + GREEN, 18/18 tests): the acceptance criterion is met. dotnet test --filter "FullyQualifiedName~DataStoreTests" → Fehler: 0, erfolgreich: 18 (9 shared test method x 2 provider class). Live count delivered: LocalHyperV = 10 VMs, osisaLib03 = 9 VMs, each equal to its own native API count.

  • Code landed in src/NETSetup/DataStore/ + src/NETSetup/Exceptions/VmDataStoreException.cs; test in src/NETSetup.Tests/DataStoreTests/. NOT a separate osisa.VirtualMachines project - NETSetup already reference osisa.SystemManagement.HyperV + .Proxmox, so only three PackageReference (osisa.Repository, osisa.Repository.Contracts, osisa.Specifications.Contracts) were added and no sln entry was needed. Splitting the library out stay a follow-up.

  • Reference base is CanGetSingleUnitOfWorkDataStoreReferenceBase<VmConnectionStringBuilder, TSelf, IVirtualizationUnitOfWork> (shared VmDataStoreReferenceBase<TSelf>), NOT DataStoreReferenceBase5 direct - a VM store serve exactly ONE unit of work type, which is the same reason MachineScopeReference5 use that base.

  • Builders are plain nested Builder class, not DataStoreNameDependencyInjectionDataStoreReferenceBuilder5: that base demand SetDataStore + DI plumbing this store does not need, and the live handle (management scope, Proxmox host) is injected, never serialised.

  • VirtualizationDataStoreReferenceProvider3 wrap each single-UoW reference in the netbase SingleAsAnyUnitOfWorkDataStoreReference<IVirtualizationUnitOfWork> so GetReferences() satisfy IDataStoreReferenceProvider3. AddReference2 does not exist in osisa.Repository - see the decision above.

  • VirtualMachineKey is a plain string on the record for now, not a Vogen VO. Vogen wrapping is a follow-up and does not change any signature above.

  • KEY DERIVATION BUG FOUND AND FIXED live: SerialNumber.From("9000@osisaLib03") yield Value == "osisaLib03" and HostedId == "9000" - the vmid is SPLIT OFF. Keying on .Value collide every NETSetup VM on a node onto one key. A hosted serial must be REASSEMBLED as {HostedId}@{Value}. Non-hosted guest report a hardware serial, "0" or "empty" (all non-unique), so they key on the node-qualified VM name instead. Hyper-V has no vmid and no unique BIOS serial, so it always key on the scope-qualified name. ReadAll_ShouldReturnUniquelyKeyedRecords is what caught it.

  • Still open from the task list below: the in-memory provider (task 4), the cross-provider GetReferences() walk (task 9), the netbase DataStoreReference5TestBase / UnitOfWorkRepositoryTestsBase3 suite (task 10), the stop-change-restart semantics test (task 11), VmDesiredStateBuilder (task 12), VmDataStorePlanner (task 13), the neutrality regression test (task 14) and the doc/puml task (task 15). Nothing in the landed code is called from netsetup update, so behaviour is unchanged by construction.

Status (2026-08-24, UNIT-OF-WORK INVARIANT AUDIT): osisa.Virtualization.Tests 54/54; live DataStoreTests 26/26; regression 988 / 0.

THE INVARIANT, stated by the repo owner and now audited: Insert / Update / Delete on the repository do NOTHING. The environment change only on Commit of the unit of work. No commit - no insert, update or delete.

  • HOLDS, verified line by line. Insert/Update/Delete only Enqueue into _pending. All five mutating seam call (InsertAsync, DeleteAsync, StopAsync, ChangeAsync, StartAsync) live inside FlushAsync/ApplyUpdateAsync, and FlushAsync have exactly ONE caller repo-wide: CommitAsync. FlushAsync and Rollback are deliberately NOT on IVirtualMachineRepository, so no consumer can flush without committing. Read is live and unbuffered by design - a pending insert is invisible to ReadAll until commit, declared on the contract.

  • NOT PROVED BY DERIVING netbase UnitOfWorkRepositoryTestsBase3, which is what this repo normally use. Environment blocker, not a choice: osisaRepositoryVersion is pinned at 0.19.0-ModelFeature.191, NO csproj in netsetup consume osisa.Repository.TestInfrastructure so that version was never pulled into the local NuGet cache, and the GitHub Packages feed answer 403 without credential. VersionOverride to .300 fail too - its transitive osisa.Json / osisa.Strings at .300 are equally absent. DO NOT fix this by bumping osisaRepositoryVersion: this file record that .292 change the UnitOfWorkBase3 ctor and require porting osisa.OPNSense.Rest, and that the last attempt measured 188 build error and 0 runnable test. The real fix is a read:packages token plus a single PackageReference at .191 - every .191 transitive dep is already cached.

    SUPERSEDED (2026-08-30): the hold is released. osisa.OPNSense.Rest IS ported to the new UnitOfWorkBase3 (ILogger ctor + abstract OnCommit(), transaction held and flushed there), so the repository version could move and the live CRUD suites now DO derive the netbase base - v4, not v3. Keep the paragraph above: it is the record of WHY the hold existed, and the 188-build-error attempt it describe is what a naive bump still cost.

  • IN ITS PLACE VirtualMachineRepositoryCrudTests mirror the six base case test-for-test (Insert/Update/Delete x Commit/NoCommit), assert on READ-BACK STATE the way the base do, and add CommitAsync variant, an all-three-verbs-uncommitted case and the dispose case. Shaped so swapping to the real base is mechanical.

  • MUTATION TESTED, twice, because a guard nobody has seen fail is not a guard. (1) Making Insert apply immediately turn 8 test red. (2) Emptying Rollback() originally left ALL 48 GREEN - the two dispose test could not fail, one verifying through a fresh unit of work owning a different buffer, the other only asserting the fake had recorded nothing. Dispose_ShouldClearTheBufferItself assert the buffer directly and now catch it.

  • CommitAsync now refuse a DISPOSED unit of work (ObjectDisposedException). It used to flush, inert only as a side effect of Rollback clearing.

  • ChangeAsync DEFECT, fixed by refusing rather than pretending. Both provider apply NO field. Driven through the update path that mean: stop the guest, change nothing, restart, report Updated++ for work never done - and since nothing was applied the same difference persist, so EVERY reconciliation bounce the guest again. IVirtualMachineOperations.CanApplyChanges now declare the capability, both provider return FALSE, and the repository refuse the update BEFORE StopAsync. The ORDER is the point: throwing from inside ChangeAsync would leave the guest POWERED OFF, because a per-record failure is caught and the restart never run. Same rule the provider already follow for NetworkName / Storage / VmId - a provider that cannot honour a field say so instead of silently dropping it.

  • KNOWN LIMIT of the unit suite: the fake DOES apply a change, so Update_Commit_ShouldApplyTheChange prove the REPOSITORY buffering, never a provider’s CRUD. No provider apply fields yet, so there is nothing to prove on that side until ChangeAsync is implemented.

Status (2026-08-24, ARCHITECT APPROVED after five review rounds): dotnet build src/NETSetup.sln clean; osisa.Virtualization.Tests 40/40; live DataStoreTests 26/26 against real Hyper-V + osisaLib03, both guest SSH-connectable; regression 988 passed / 0 failed.

The review chain kept finding ONE defect class wearing different clothes: an update that silently did nothing and reported a clean commit. Each round closed one instance; the last round closed the CONDITION instead. Worth reading before touching the matching or reporting code.

  • KEY MATCHING, round 3+4: a row built by VirtualMachineBuilder carry no key, and that is the NORMAL update path. Matching it needed three corrections. (1) The builder used to invent From(name), a third key shape no provider project - removed. (2) A name-based fallback compared case-SENSITIVELY (MachineName do not normalise) while the key path did not - removed. (3) Matching only the projected key missed every Proxmox guest carrying a NETSetup serial, because those project FromSerial while a keyless row key as env/name. Final shape: ResolveLive RE-KEY every live row as FromEnvironment(EnvironmentName, r.Name) and compare that, so there is ONE comparison and it is always a normalised VirtualMachineKey. Comparing the projected key as well would be dead code - a serial never contain the separator a re-keyed name always has.

  • MATCHING DEPEND ON UNIQUE NAMES. Proxmox do not enforce unique guest name, so the live suite assert ReadAll_ShouldReturnUniquelyNamedRecords alongside the key assertion. Two guest sharing a name on one node would make an update ambiguous.

  • REPORTING, round 4: "no live counterpart" and "nothing differs" used to share one silent return false, and THAT shared silence is what let all three matching bugs survive. VmCommitResult now carry NotFound, and ApplyUpdateAsync return an UpdateOutcome tri-state (Applied / Unchanged / NotFound) so a MISS is caller-visible and testable rather than only a log line.

  • NAMING, round 4: VmOperationRecord and VmCommitFailure now carry MachineName Name as well as the key, because the key is EMPTY for a keyless row - the documented path - so a key-only record rendered every operation of that path anonymous and a failure rendered as failed for: . naming nothing. VmCommitFailure.Describe() fall back to the name. Stop/Change/Start are keyed from the RESOLVED LIVE row, never the desired one.

  • LOGGER: CommitLogger ?? Logger ?? NullLogger.Instance - an explicitly configured logger WIN. The other order made WithLogger a no-op whenever the service provider happened to supply a logger. WithLogger is wired at all four live test call site.

  • ResolveIpAddress return IPAddress?, not a string. The Proxmox side already hold a typed address and used to throw the type away, which forced a hand-rolled dotted-quad check in the base.

  • STILL DEFERRED, deliberately: ChangeAsync on both provider only RESOLVE the machine, it apply no field - so an update currently stop and restart a guest without changing anything. That is the update spec’s work and it is documented at both call site. Also open: ICanReadAsync<T> / IGenericRepositoryAsync<T> are shipped by netbase but not implemented here, so the two Read overload stay sync-over-async; and VmConnectionStringBuilder / VirtualizationDataStoreReferenceProvider3 / VirtualizationDataStore have no dedicated unit test, being thin and covered end-to-end by the live suite.

Status (2026-08-23, EXTRACTED TO osisa.Virtualization + VO CONVERSION, GREEN): the layer left NETSetup and became a library, and the raw-primitive surface is gone. dotnet build src/NETSetup.sln clean; osisa.Virtualization.Tests 34/34; live DataStoreTests 24/24 against real Hyper-V + osisaLib03; regression 988 passed / 0 failed.

  • LIBRARY: src/NETSetup/DataStore/ is GONE. Code now live in src/osisa.Virtualization/ (Contracts/ ValueObjects/ DataStore/ Operations/ Repositories/ UnitOfWork/ Exceptions/) plus src/osisa.Virtualization.Tests/, both registered in src/NETSetup.sln, consumed by NETSetup and NETSetup.Tests via ProjectReference (the in-solution convention - the PackageReference rule is for CROSS-solution deps). ONE project with a Contracts/ folder, following osisa.Mikrotik / osisa.OPNSense / osisa.Inventory rather than the two-assembly split. VmDataStoreException became VirtualizationException : Exception - a library must not depend on the app assembly.

  • VALUE OBJECTS: new Vogen VirtualMachineKey (IKeyOf<VirtualMachineSettings>, only two factories - FromSerial(SerialNumber) and FromEnvironment(MachineName, MachineName)), VmId, VirtualSwitchName, StorageName, WmiNamespace. REUSED rather than reinvented: MachineName, OperatingSystem, DataSize (twice). Cores stay int and AutoStart stay bool - a genuine count and a genuine two-state.

  • THE Iso SPLIT is the substantive design change. One string used to mean an absolute local path on Hyper-V and a storage-relative name on Proxmox, which is why the test base could only assert EndWith("nixos.iso"). The row now carry ISubPath Iso (the NAME) plus StorageName IsoStorage, and each provider resolve it against its own root - Proxmox against the storage, Hyper-V through the new IBootImageResolver (implemented by NETSetup.Helpers.BootImageResolver over the keyed operator). The identical row now go to both hypervisor unchanged.

  • ASYNC: IVirtualMachineOperations is Task-returning, the repository flush is async, and the unit of work implement ICanCommitAsync. CommitAsync is the real path; the synchronous Commit() delegate to it at the OUTER boundary only, because netbase IUnitOfWork declare a synchronous Commit. Only Commit() is genuinely FORCED (netbase declare it synchronous with no async counterpart); the two Read overload are a second and third sync-over-async point that could be lifted, because netbase also ship ICanReadAsync<T> / IGenericRepositoryAsync<T> - not implemented yet. The code comment name all three rather than claiming one.

  • SEVEN DEFECTS FIXED. The sharpest: the "empty diff is a no-op" guard never worked. It compared full record equality, but a read-back record never carry Iso / NetworkName / Storage, so a desired record built with an ISO ALWAYS compared unequal and every idempotent cycle stopped and restarted a healthy machine - the exact behaviour the code claimed to prevent. VirtualMachineSettings.DescribesSameMachineAs now compare only the fields Change can apply. Also: Proxmox insert now honour record.VmId (WithVmId(PickFreeVmId(…​)) used to be silently dropped); a failed record no longer escape Flush leaving the unit of work dirty; the real exception survive into VmCommitFailure and the aggregate throw; Hyper-V Stop/Start throw instead of silently no-op while the commit log claim they ran.

  • A provider that CANNOT honour a field now throw instead of discarding it silently - Hyper-V on Storage/IsoStorage/VmId, Proxmox on NetworkName. A field a provider drop without a word is the same class of lie as the dual-meaning Iso was.

  • VOGEN BUG FOUND: a [Instance] carrying backslashes is emitted into a NON-verbatim literal without re-escaping, so root\virtualization\v2 silently became rootirtualization2 (the \v pair read as a vertical tab) and every Hyper-V connection failed. Anything with a backslash must be built through From in a plain static property instead. WmiNamespace_HyperV_ShouldKeepItsBackslashes guard it.

  • KEY MATCHING, fixed after review: VirtualMachineBuilder must NOT invent a key. It used to default to From(name), a THIRD key shape neither provider ever project, so the repository could never match the row and every update of a built row was a silent no-op reported as a clean commit - the same defect as the empty-diff bug, re-entering through the builder. A built row now leave the key empty and the repository key it exactly the way a provider key a keyless guest, so there is ONE match path.

  • KEY CASE, fixed after review: VirtualMachineKey documented case-insensitive comparison but only an explicit ComparisonValue uppercased - the generated == / GetHashCode stayed ordinal, and the repository dictionary use the DEFAULT comparer. A Vogen NormalizeInput now lower-case on creation so every consumer obey one rule. Null is passed THROUGH that hook on purpose: Vogen run it BEFORE Validate, so coalescing there would turn From(null) into a silent empty key.

  • NETSetupFileOperators.Iso2 renamed to BootImages (+ DefaultBootImagesRoot / BootImagesRoot) - numbered names say nothing, and the key needed a five-line comment to justify itself.

  • The raw ProcessStartInfo in the Hyper-V test is gone. Two nested NETCommand facade command (GetVmNetworkAdapterCommand, NeighborLookupCommand) delegate to PowershellCommand.Builder().UseEncoded(), carry a RenderedShellCommand test seam, and set $ErrorActionPreference = 'Stop' so a cmdlet failure is distinguishable from an empty result - the old helper swallowed exit code and stderr entirely.

  • ADDRESS DISCOVERY, learned the hard way: do NOT use ArpScanner.FindIPByMAC to find a guest on the Default Switch. It actively ARP-SWEEP every address of the subnet, and that switch is a /20 - roughly 4000 synchronous SendARP calls, which crashed the test host unscoped and outlasted the whole boot timeout when scoped. READ the neighbour cache with Get-NetNeighbor instead; the guest is already in it by the time sshd is up. Hyper-V connect went from 6m50s (fail) to 48s (pass).

Status (2026-08-23, VM BUILDER + INSERT + CONNECT, LANDED + GREEN, 22/22 tests): a machine is now DESCRIBED once with a fluent builder and INSERTED into either environment, and the resulting guest is provably reachable.

  • VirtualMachineBuilder (src/NETSetup/DataStore/VirtualMachineBuilder.cs) set every property through a With…​ call: WithName, WithKey, WithOperatingSystem, WithVmId, WithMemory/WithMemoryBytes, WithCores, WithDisk/WithDiskBytes, WithPowerState, WithIso, WithNetwork, WithStorage, WithIsoStorage, WithAutoStart. Create() start empty, From(record) start from an existing row, Build() demand a name.

  • VirtualMachineSettings gained OPTIONAL init property (Iso, NetworkName, Storage, IsoStorage, AutoStart). The positional signature is untouched, so every existing construction keep compiling - purely additive.

  • Insert on both provider now realise those value with the SAME hardware shape ProvisionNixosVm already use: Processor + RandomAccessModule + VMDisk on Proxmox, and the VirtualMachine(name, iso, switch, disk, cpu, ram) ctor on Hyper-V. The 4-arg Hyper-V ctor default to 4 cores / 4 GiB, so a WithCores / WithMemory would silently vanish - the wider overload is used whenever the row specify them.

  • Hyper-V gen 2 guest need TWO firmware knob before an unsigned Linux ISO boot: Secure Boot OFF and the optical drive FIRST. (UPDATED 2026-08-30: Secure Boot is no longer OFF for everybody - the row OS decide it, Windows family get Secure Boot + vTPM at CREATION. See the top status entry. The optical-drive-first rule is unchanged.) Both are set natively through Msvm_VirtualSystemSettingData + ModifySystemSettings (no PowerShell, no raw process). ComputerSystemMethods.ModifySettingData was tried first and fail with "failed to modify resources" - it target RESOURCE setting, not the SYSTEM setting where firmware knob live.

  • Boot source selection: the DVD and the hard disk BOTH describe themselves as EFI SCSI Device on a gen 2 guest, so a description match pick the wrong device. A boot source InstanceID is the drive own device path plus a trailing marker, so the DVD is matched on THAT.

  • IPFS node row (identical on both): 8 GiB RAM, 2 core, 64 GiB disk, nixos.iso, autostart. Verified on the Hyper-V guest: ProcessorCount 2, MemoryStartup 8 GiB.

  • CONNECT PROOF: a real ssh session as root, authenticated with netsetup-master.key (its public half is baked into nixos.iso root authorized_keys), then uname -a executed and asserted - a half-open socket cannot pass. Both guest answer Linux nixos-installer 6.12.78 …​ x86_64 GNU/Linux. TestIpfsNixProxmox on osisaLib03 and TestIpfsNixHyperV on the local Hyper-V are LEFT RUNNING on purpose so they stay connectable.

  • Address discovery is provider wiring: Proxmox use the existing DiscoverVmIpViaQmAgent (the ISO enable qemu-guest-agent); Hyper-V ask integration service first and fall back to adapter MAC → host arp cache, because the NixOS live image run no Hyper-V KVP daemon.

  • The local boot image live at C:\NETSetupImages\nixos.iso, NOT under C:\NETSetupDEV: the Stage0 dev-env test wipe that folder, and a running guest hold its mounted ISO locked - 15 Stage0 test failed with a sharing violation until the image moved out.

    • ✓ Scaffold osisa.VirtualMachines + Contracts public surface: Create src/osisa.VirtualMachines/osisa.VirtualMachines.csproj (targets $(DefaultNetTargetFramework), <InternalsVisibleTo Include="osisa.VirtualMachines.Tests" />, references osisa.Repository, osisa.Repository.Contracts, osisa.Specifications, osisa.Validation, osisa.SystemManagement.Contracts, Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Logging.Abstractions) and src/osisa.VirtualMachines.Tests/osisa.VirtualMachines.Tests.csproj. Add both to src/NETSetup.sln. Create namespace osisa.VirtualMachines.Contracts with: VirtualMachineKey (Vogen VO over string, From(SerialNumber) + From(int vmId, string hostName) fallback), VmPowerState enum (Unknown, Stopped, Running), VmOperation enum (Read, Insert, Stop, Change, Start, Delete), VirtualMachineSettings record (VirtualMachineKey Key, string Name, string OperatingSystem, int VmId, long MemoryBytes, int Cores, long DiskBytes, VmPowerState PowerState), VmOperationRecord record (VirtualMachineKey Key, VmOperation Operation, int Ordinal), VmCommitResult record (IReadOnlyList<VmOperationRecord> Operations, int Inserted, int Updated, int Deleted, IReadOnlyList<VirtualMachineKey> Failed), VmDataStoreException : Exception, NetsetupDataStoreProviders static class (HyperV, Proxmox, MemoryVm consts). Assertions:

    • VirtualMachineKey.From(SerialNumber.From(SomeVmSerial)).Value.Should().Be(SomeVmSerial)

    • VirtualMachineKey.From(SomeVmId, SomeHostName).Value.Should().Be(SomeVmId + "@" + SomeHostName)

    • new VirtualMachineSettings(SomeKey, SomeVmName, …​).Key.Should().Be(SomeKey)

    • new VirtualMachineSettings(…​).PowerState.Should().Be(VmPowerState.Stopped)

    • two VirtualMachineSettings with identical values .Should().Be(each other)

    • Enum.GetValues<VmOperation>().Should().HaveCount(SixOperationCount)

    • VmOperation.Stop.Should().NotBe(VmOperation.Change)

    • NetsetupDataStoreProviders.HyperV.Should().Be(nameof(NetsetupDataStoreProviders.HyperV))

    • NetsetupDataStoreProviders.Proxmox.Should().NotBe(NetsetupDataStoreProviders.MemoryVm)

    • new VmCommitResult(Array.Empty<VmOperationRecord>(), 0, 0, 0, Array.Empty<VirtualMachineKey>()).Operations.Should().BeEmpty()

    • typeof(VmDataStoreException).Should().BeDerivedFrom<Exception>()

    • ✓ Create IVirtualMachineRepository + VirtualMachineRepositoryBase with buffered Insert/Update/Delete: Create Contracts/IVirtualMachineRepository.cs as IGenericRepository<VirtualMachineSettings> so it inherits netbase ICanRead, ICanInsert, ICanUpdate, ICanDelete of VirtualMachineSettings. Create Repositories/VirtualMachineRepositoryBase.cs (internal abstract): holds IVirtualMachineOperations Operations (injected), a pending-change list, and bool IsDirty. Insert/Update/Delete only enqueue; ReadAll/ReadSingle go straight through to Operations.Read (always live). FlushAsync applies pending changes in the fixed order Delete → Update → Insert and returns the VmOperationRecord list, expanding each Update into Stop → Change → Start (Start omitted when the pre-update record’s PowerState != Running; the whole triple omitted when the diff is empty). Clears pending + IsDirty on success. Assertions:

    • repository.IsDirty.Should().BeFalse() initially

    • repository.Insert(SomeRecord); repository.IsDirty.Should().BeTrue()

    • repository.Insert(SomeRecord); operations.Recorded.Should().BeEmpty()

    • repository.Delete(SomeRecord); repository.IsDirty.Should().BeTrue()

    • repository.ReadAll() with pending inserts: operations.Recorded.Should().ContainSingle(o ⇒ o.Operation == VmOperation.Read)

    • after FlushAsync: repository.IsDirty.Should().BeFalse()

    • flush with one Insert + one Delete: recorded.Select(o ⇒ o.Operation).Should().ContainInOrder(VmOperation.Delete, VmOperation.Insert)

    • flush with one Update on a Running record: recorded.Select(o ⇒ o.Operation).Should().ContainInOrder(VmOperation.Stop, VmOperation.Change, VmOperation.Start)

    • flush with one Update on a Stopped record: recorded.Select(o ⇒ o.Operation).Should().ContainInOrder(VmOperation.Stop, VmOperation.Change) and .Should().NotContain(VmOperation.Start)

    • flush with an Update whose desired equals live: recorded.Should().BeEmpty()

    • typeof(IVirtualMachineRepository).Should().Implement<ICanInsert<VirtualMachineSettings>>()

    • typeof(IVirtualMachineRepository).Should().Implement<ICanUpdate<VirtualMachineSettings>>()

    • typeof(IVirtualMachineRepository).Should().Implement<ICanDelete<VirtualMachineSettings>>()

    • typeof(IVirtualMachineRepository).Should().Implement<ICanRead<VirtualMachineSettings>>()

    • ✓ Create IVirtualizationUnitOfWork + VirtualizationUnitOfWork with Commit: Create Contracts/IVirtualizationUnitOfWork.cs (: IUnitOfWork) exposing IVirtualMachineRepository VirtualMachines { get; } - shaped after the existing ISystemManagementUnitOfWork so GetRepository<VirtualMachineSettings>() resolves the SAME instance as the named property. Create UnitOfWork/VirtualizationUnitOfWork.cs (internal, : UnitOfWorkBase3 where the base fits, else the interface directly): ctor takes IVirtualMachineOperations + ILogger. Commit() flushes the repository when IsDirty, aggregates per-record failures into VmCommitResult.Failed and throws VmDataStoreException listing every failed key; a clean Commit() on a non-dirty UoW is a no-op returning an empty result. Dispose() drops pending changes without applying them. Assertions:

    • unitOfWork.VirtualMachines.Should().NotBeNull()

    • unitOfWork.GetRepository<VirtualMachineSettings>().Should().BeSameAs(unitOfWork.VirtualMachines)

    • typeof(IVirtualizationUnitOfWork).Should().Implement<IUnitOfWork>()

    • Commit() on a non-dirty unit of work: result.Operations.Should().BeEmpty()

    • Commit() on a non-dirty unit of work: operations.Recorded.Should().BeEmpty()

    • Insert then Commit(): result.Inserted.Should().Be(SingleItemCount)

    • Insert + Update + Delete then Commit(): result.Deleted.Should().Be(SingleItemCount) && result.Updated.Should().Be(SingleItemCount) && result.Inserted.Should().Be(SingleItemCount)

    • Insert then Dispose() without Commit(): operations.Recorded.Should().BeEmpty()

    • one failing record among three: act.Should().Throw<VmDataStoreException>().WithMessage("" + SomeVmSerial + "")

    • one failing record among three: the other two records .Should() still be applied

    • Commit() twice with no changes in between: second result.Operations.Should().BeEmpty()

    • ❏ Create MemoryVmDataStoreReference5 (V5 in-memory provider): Create DataStore/VmConnectionStringBuilder.cs (: DbConnectionStringBuilder, DataStoreName + HostName + nested ValueNames class) and DataStore/MemoryVmDataStoreReference5.cs decorated [DataStoreProvider(NetsetupDataStoreProviders.MemoryVm)], deriving DataStoreReferenceBase5<VmConnectionStringBuilder, MemoryVmDataStoreReference5>. Override Exists(), EnsureDeleted(), GetName() (→ DataStoreName.From(ConnectionStringBuilder.DataStoreName)) and OpenDataStoreImplementation<TUnitOfWork>(out bool isNew). Nested Builder : DataStoreNameDependencyInjectionDataStoreReferenceBuilder5<MemoryVmDataStoreReference5, VmConnectionStringBuilder, Builder> with WithDataStoreName + WithSeed(params VirtualMachineSettings[]). Backing InMemoryVmOperations keeps records in a dictionary keyed by VirtualMachineKey and records every VmOperation it is asked to perform. Assertions:

    • new MemoryVmDataStoreReference5.Builder(Services).WithDataStoreName(TestContext.TestName).Build().Should().NotBeNull()

    • reference.Exists().Should().BeFalse() before open

    • reference.OpenDataStore3<IVirtualizationUnitOfWork>(); reference.Exists().Should().BeTrue()

    • reference.Name.ToString().Should().Be(TestContext.TestName)

    • reference.ConnectionString.Should().Contain(nameof(VmConnectionStringBuilder.ValueNames.DataStoreName))

    • reference.ConnectionString.Should().Contain(NetsetupDataStoreProviders.MemoryVm)

    • MemoryVmDataStoreReference5.FromConnectionString(FromConnectionString, Services).Name.ToString().Should().Be(SomeDataStoreName)

    • seeded with two records: unitOfWork.VirtualMachines.ReadAll().Should().HaveCount(TwoItemCount)

    • reference.EnsureDeleted(); reference.Exists().Should().BeFalse()

    • ✓ Create HyperVVmDataStoreReference5 + HyperVVmOperations: Create DataStore/HyperVVmDataStoreReference5.cs decorated [DataStoreProvider(NetsetupDataStoreProviders.HyperV)], same DataStoreReferenceBase5 shape, nested Builder with WithDataStoreName + WithManagementScope(ManagementScope). Exists() probes the scope. Create Operations/HyperVVmOperations.cs (internal : IVirtualMachineOperations): Read → ManagementScope.ReadAllVirtualMachines() projected to VirtualMachineSettings; Insert → ManagementScope.InsertVirtualMachine (already DB-shaped - see NETSetup.Helpers.VMHelper.CreateVM); Stop → VMHelper.StopVM; Start → VMHelper.StartVM; Delete → RemoveVirtualMachineAsync/DestroyVirtualMachine; Change applies diffed fields only. VMHelper.RebootVM MUST NOT appear - reboot collapses stop and start into one guest operation and would hide the Change step. Assertions:

    • typeof(HyperVVmDataStoreReference5).Should().BeDecoratedWith<DataStoreProviderAttribute>()

    • reference.ConnectionString.Should().Contain(NetsetupDataStoreProviders.HyperV)

    • operations.Read() on a stubbed scope with two VMs .Should().HaveCount(TwoItemCount)

    • operations.Read() maps name + state: record.Name.Should().Be(SomeVmName) && record.PowerState.Should().Be(VmPowerState.Running)

    • operations.Insert(SomeRecord): stubbed scope InsertVirtualMachine called exactly once

    • operations.Stop(SomeKey) then operations.Start(SomeKey): stub records Stop before Start

    • HyperVVmOperations source .Should().NotContain(nameof(VMHelper.RebootVM))

    • ✓ Create ProxmoxVmDataStoreReference5 + ProxmoxVmOperations: Create DataStore/ProxmoxVmDataStoreReference5.cs decorated [DataStoreProvider(NetsetupDataStoreProviders.Proxmox)], nested Builder with WithDataStoreName + WithHost(PveHost). Exists() pings the node through the existing osisa.SystemManagement.Proxmox wrapper. Create Operations/ProxmoxVmOperations.cs (internal : IVirtualMachineOperations): Read → Host.GetVirtualMachines() projected to VirtualMachineSettings; Insert → Host.CreateVirtualMachine; Stop → StopVirtualMachineAsync (ShutdownVirtualMachineAsync when the guest agent responds); Start → StartVirtualMachineAsync; Delete → RemoveVirtualMachineAsync; Change applies diffed fields only. RebootVirtualMachineAsync MUST NOT be used for Update. No new hypervisor code - every call goes through the existing wrapper behind the IVirtualMachineOperations seam so unit tests never touch a real node. Assertions:

    • typeof(ProxmoxVmDataStoreReference5).Should().BeDecoratedWith<DataStoreProviderAttribute>()

    • reference.ConnectionString.Should().Contain(NetsetupDataStoreProviders.Proxmox)

    • operations.Read() on a stubbed host returning two qemu entries .Should().HaveCount(TwoItemCount)

    • operations.Read() maps vmid + name: record.VmId.Should().Be(SomeVmId) && record.Name.Should().Be(SomeVmName)

    • operations.Read() maps a running guest: record.PowerState.Should().Be(VmPowerState.Running)

    • operations.Insert(SomeRecord): stubbed host CreateVirtualMachine called exactly once

    • operations.Stop(SomeKey) then operations.Start(SomeKey): stub records Stop before Start

    • ProxmoxVmOperations source .Should().NotContain("RebootVirtualMachine")

    • unreachable host: reference.Exists().Should().BeFalse()

    • [~] Create VirtualizationDataStoreReferenceProvider3 wiring BOTH providers on one builder: Create DataStore/VirtualizationDataStoreReferenceProvider3.cs (: IDataStoreReferenceProvider3, Shape B - hold an inner DataStoreReferenceProvider3.Builder and delegate GetReference/GetReferences), registering AddReference3<HyperVVmDataStoreReference5.Builder>(…​) AND AddReference3<ProxmoxVmDataStoreReference5.Builder>(…​). NOTE: the API is AddReference3<TBuilder>(Action<TBuilder>); osisa.Repository has no AddReference2. Store names are consts on the class: LocalHyperVStoreName = "LocalHyperV", OsisaLib03StoreName = "osisaLib03". Mirrors MachineScopeProvider3 from osisa.SystemManagement.Windows. PARTIAL (2026-08-24): the provider + builder landed and the store key moved to the test side as DataStoreTests/OsisaLib03.Store / LocalHyperV.Store (they name a BENCH environment, which is test knowledge, not library knowledge). The DI half - VirtualMachinesServiceCollectionExtensions.AddVirtualMachines() + AddUnitOfWorkMapping<IVirtualizationUnitOfWork, VirtualizationUnitOfWork>() - is NOT built: nothing resolve the store through DI yet, every reference is built through its own builder. Do it when a consumer actually need DI resolution. Assertions:

    • provider.GetReferences().Should().HaveCount(TwoItemCount)

    • provider.GetReference(DataStoreName.From(LocalHyperVStoreName)).Should().NotBeNull()

    • provider.GetReference(DataStoreName.From(OsisaLib03StoreName)).Should().NotBeNull()

    • provider.GetReference(DataStoreName.From(SomeUnknownStoreName)).Should().BeNull()

    • provider.GetReferences().Select(r ⇒ r.GetType()).Should().Contain(typeof(HyperVVmDataStoreReference5))

    • provider.GetReferences().Select(r ⇒ r.GetType()).Should().Contain(typeof(ProxmoxVmDataStoreReference5))

    • every reference .Should().BeAssignableTo<ICanGetAnyUnitOfWork>()

    • (OPEN, DI half not built) services.AddVirtualMachines(); services.BuildServiceProvider().GetRequiredService<IDataStoreReferenceProvider3>().Should().NotBeNull()

    • VirtualizationDataStoreReferenceProvider3 source .Should().NotContain("AddReference2")

    • ✓ ACCEPTANCE: one shared read-all test base + two provider-wiring-only test classes: Create src/osisa.VirtualMachines.Tests/ReadAll/VirtualMachineReadAllTestBase.cs - public abstract class VirtualMachineReadAllTestBase<TReference, TBuilder> : HostingUnitTestsBase holding the ENTIRE scenario: build the reference from ConfigureBuilder, OpenDataStore3<IVirtualizationUnitOfWork>(), GetUnitOfWork(), VirtualMachines.ReadAll(), count + name dump. Abstract members, and ONLY these: ConfigureBuilder(TBuilder builder), int ExpectedVmCount (the native-API oracle), bool EnvironmentAvailable (→ Assert.Inconclusive when false). Then create ReadAll/HyperVVirtualMachineReadAllTests.cs (ConfigureBuilder → WithManagementScope(LiveMethods.ConnectToLiveLocalSystem(@"root\virtualization\v2")), ExpectedVmCount → scope.ReadAllVirtualMachines().Count()) and ReadAll/ProxmoxVirtualMachineReadAllTests.cs (ConfigureBuilder → WithHost(proxmox.GetHost("osisaLib03")) against 192.168.3.233:8006 root/default4244$ by LAN IP - NOT hostname, it is not DNS-resolvable everywhere; ExpectedVmCount → pveHost.GetVirtualMachines().Count()). Both [TestClass], [TestCategory("local")]. THE TWO FILES MUST DIFFER ONLY IN THAT WIRING - no test method, no assertion, no arrange step may live in a derived class. Assertions:

    • ReadAll_ShouldReturnTheSameCountAsTheNativeApi (inherited): records.Count.Should().Be(ExpectedVmCount)

    • ReadAll_ShouldReturnAtLeastOneVm (inherited): records.Should().NotBeEmpty()

    • ReadAll_ShouldReturnUniquelyKeyedRecords (inherited): records.Select(r ⇒ r.Key).Should().OnlyHaveUniqueItems()

    • ReadAll_ShouldPopulateEveryName (inherited): records.Should().OnlyContain(r ⇒ !string.IsNullOrWhiteSpace(r.Name))

    • ReadAll_ShouldClassifyEveryPowerState (inherited): records.Should().OnlyContain(r ⇒ r.PowerState != VmPowerState.Unknown)

    • OpenDataStore_ShouldReturnAVirtualizationUnitOfWork (inherited): unitOfWork.Should().BeAssignableTo<IVirtualizationUnitOfWork>()

    • ReadAll_ShouldNotRecordAnyWriteOperation (inherited): unitOfWork.VirtualMachines.IsDirty.Should().BeFalse()

    • HyperV class: result count .Should().Be(local ManagementScope.ReadAllVirtualMachines().Count())

    • Proxmox class: result count .Should().Be(osisaLib03 PveHost.GetVirtualMachines().Count())

    • typeof(HyperVVirtualMachineReadAllTests).GetMethods(DeclaredOnly | Public).Where(has TestMethodAttribute).Should().BeEmpty()

    • typeof(ProxmoxVirtualMachineReadAllTests).GetMethods(DeclaredOnly | Public).Where(has TestMethodAttribute).Should().BeEmpty()

    • both classes declare exactly the same member names (ConfigureBuilder, ExpectedVmCount, EnvironmentAvailable)

    • ❏ Add the cross-provider walk over GetReferences(): Create src/osisa.VirtualMachines.Tests/ReadAll/VirtualizationDataStoreReferenceProvider3ReadAllTests : HostingUnitTestsBase, [TestCategory("local")]. Resolves VirtualizationDataStoreReferenceProvider3, iterates GetReferences(), and for EACH reference runs the identical OpenDataStore3<IVirtualizationUnitOfWork>() → GetUnitOfWork() → VirtualMachines.ReadAll() walk, writing "{storeName}: {count} VMs" per reference. This is the validation that one AddReference3 registration list spans both environments with one code path. Assertions:

    • GetReferences().Should().HaveCount(TwoItemCount)

    • the walk over both references .Should().NotThrow()

    • counts.Keys.Should().BeEquivalentTo(LocalHyperVStoreName, OsisaLib03StoreName)

    • counts[LocalHyperVStoreName].Should().Be(local ManagementScope.ReadAllVirtualMachines().Count())

    • counts[OsisaLib03StoreName].Should().Be(osisaLib03 PveHost.GetVirtualMachines().Count())

    • counts.Values.Should().OnlyContain(c ⇒ c > 0)

    • the walk uses exactly one shared code path (no per-provider branch in the test body)

    • ❏ Add the netbase DataStore test suites (DataStoreReference5TestBase + UnitOfWorkRepositoryTestsBase3): Create src/osisa.VirtualMachines.Tests/TestInfrastructure/TestValues.cs (Some* constants only). Create MemoryVmDataStoreReference5Tests : DataStoreReference5TestBase<MemoryVmDataStoreReference5, MemoryVmDataStoreReference5.Builder> - [TestClass], [TestCategory(DataStoreReferenceTest)], DefaultUnitOfWorkTestMapping ⇒ new(typeof(IVirtualizationUnitOfWork), typeof(VirtualizationUnitOfWork)), FromConnectionString, Configure registering AddUnitOfWorkMapping<IVirtualizationUnitOfWork, VirtualizationUnitOfWork>(), plus AssertConnectionString/AssertDataStoreType/AssertFromConnectionString/ConfigureBuilder. Create HyperVVmDataStoreReference5Tests and ProxmoxVmDataStoreReference5Tests from the same base against stubbed operations. Create VirtualMachineRepositoryCrudTests : UnitOfWorkRepositoryTestsBase3<VirtualMachineSettings, IVirtualMachineRepository, IVirtualizationUnitOfWork> implementing CreateDataStore, CreateEntity(index, unitOfWork) ($"{SomeVmName}_{index}"), CreateUnitOfWork, ExpectedInitialCount, GetRepository, OnEnsureDeleteDataStore, UpdateEntity (bump MemoryBytes). The inherited CRUD suite IS the "VMs are database records" proof. Assertions:

    • Insert_Commit (inherited): count after commit .Should().Be(ExpectedInitialCount + SingleItemCount)

    • Insert_NoCommit (inherited): count .Should().Be(ExpectedInitialCount)

    • Update_Commit (inherited): reloaded entity .MemoryBytes.Should().Be(SomeUpdatedMemoryBytes)

    • Update_NoCommit (inherited): reloaded entity .MemoryBytes.Should().Be(SomeMemoryBytes)

    • Delete_Commit (inherited): count after commit .Should().Be(ExpectedInitialCount - SingleItemCount)

    • Delete_NoCommit (inherited): count .Should().Be(ExpectedInitialCount)

    • RepositorySupportsInsert.Should().BeTrue()

    • RepositorySupportsUpdate.Should().BeTrue()

    • RepositorySupportsDelete.Should().BeTrue()

    • RepositorySupportsRead.Should().BeTrue()

    • Ex(UnitOfWorkTestMapping) resolves IVirtualizationUnitOfWork for all three providers

    • ❏ Add the explicit stop-change-restart semantics tests: Create src/osisa.VirtualMachines.Tests/VirtualizationUnitOfWorkSemanticsTests : UnitTestsBase - one test per sentence of the goal, asserting against VmCommitResult.Operations (ordered VmOperationRecord list), never against logger text. Assertions:

    • AddingAVm_ShouldBeAnInsert: result.Operations.Select(o ⇒ o.Operation).Should().Equal(VmOperation.Insert)

    • RemovingAVm_ShouldBeADelete: result.Operations.Select(o ⇒ o.Operation).Should().Equal(VmOperation.Delete)

    • ChangingARunningVm_ShouldStopChangeAndRestart: result.Operations.Select(o ⇒ o.Operation).Should().Equal(VmOperation.Stop, VmOperation.Change, VmOperation.Start)

    • ChangingAStoppedVm_ShouldStopAndChangeButNotRestart: result.Operations.Select(o ⇒ o.Operation).Should().Equal(VmOperation.Stop, VmOperation.Change)

    • ChangingNothing_ShouldRecordNoOperation: result.Operations.Should().BeEmpty()

    • CommitOrder_ShouldBeDeleteThenUpdateThenInsert: result.Operations.Select(o ⇒ o.Operation).Should().ContainInOrder(VmOperation.Delete, VmOperation.Stop, VmOperation.Change, VmOperation.Start, VmOperation.Insert)

    • PendingChanges_BeforeCommit_ShouldTouchNothing: operations.Recorded.Should().BeEmpty()

    • Dispose_WithoutCommit_ShouldTouchNothing: operations.Recorded.Should().BeEmpty()

    • Operations ordinals .Should().BeInAscendingOrder()

    • PartialFailure_ShouldThrowListingOnlyFailedKeys: exception.Message.Should().Contain(SomeVmSerial) and .Should().NotContain(SomeOtherVmSerial)

    • ❏ Create NETSetup-side VmDesiredStateBuilder (pure, no IO): Create src/NETSetup/Helpers/VmDesiredStateBuilder.cs (public static). Build(NETSetupConfig config, SerialNumber hostSerial) → IReadOnlyList<VirtualMachineSettings>. MUST delegate ordering to CreateVirtualMachines.GetOrderedVmsForHost - do not reimplement the OPNsense-first / DC-second / NixDns-last rule. Maps each Machine to a VirtualMachineSettings (Key from SerialNumber, Name from MachineName, OperatingSystem from Machine.OperatingSystem.Name, sizing from the same template defaults ProvisionNixosVm already applies). Zero IO, zero hypervisor calls, no logger. Assertions:

    • Build with an empty config .Should().BeEmpty()

    • Build .Should().HaveCount(config machines for this host)

    • Build .Should().NotContain(a machine whose serial targets another host)

    • Build.Select(r ⇒ r.Name).Should().Equal(CreateVirtualMachines.GetOrderedVmsForHost(config, hostSerial).Select(m ⇒ m.MachineName.Value))

    • Build.First().OperatingSystem.Should().Be(OS.OPNSense.Name)

    • Build.Last().OperatingSystem.Should().Be(NetsetupOS.NixDns.Name)

    • Build keys .Should().OnlyHaveUniqueItems()

    • Build called twice with the same config .Should().BeEquivalentTo(each other)

    • ❏ Create NETSetup-side VmDataStorePlanner (pure diff, delete-safety guarded): Create src/NETSetup/Helpers/VmDataStorePlanner.cs (public static) + VmChangePlan record (IReadOnlyList<VirtualMachineSettings> Inserts, Updates, Deletes). Plan(desired, live, hostSerial) diffs on VirtualMachineKey: in desired but not live → Inserts; in both but with a differing field → Updates; in live but not desired → Deletes, but only when the live record’s serial is hosted on hostSerial (CreateVirtualMachines.IsForHost). A hand-created VM, or one belonging to another host, is never proposed for deletion. VmChangePlan has no Apply in this iteration - read-only by design so netsetup update behaviour is untouched. Assertions:

    • Plan(desired: one new, live: empty).Inserts.Should().HaveCount(SingleItemCount)

    • Plan(desired: empty, live: one hosted).Deletes.Should().HaveCount(SingleItemCount)

    • Plan(desired: empty, live: one foreign-host serial).Deletes.Should().BeEmpty()

    • Plan(desired: empty, live: one unhosted hand-created serial).Deletes.Should().BeEmpty()

    • Plan(identical desired and live).Inserts.Should().BeEmpty() && .Updates.Should().BeEmpty() && .Deletes.Should().BeEmpty()

    • Plan(same key, different MemoryBytes).Updates.Should().HaveCount(SingleItemCount)

    • Plan(same key, different Name).Updates.Should().HaveCount(SingleItemCount)

    • Plan(same key, different Name).Inserts.Should().BeEmpty()

    • typeof(VmChangePlan).GetMethod("Apply").Should().BeNull()

    • Plan called twice with the same inputs .Should().BeEquivalentTo(each other)

    • ❏ Add the behaviour-neutrality regression tests: Create src/NETSetup.Tests/HelperTests/VmDataStoreBehaviourNeutralityTests : UnitTestsBase proving the new layer changed nothing. EnsureCustomerVms.RunCore must still produce the exact outcome sequence it produces today for the same input, and must not reference the new types. UpdateMethods.RunProxmoxUpdate must not call VmDataStorePlanner or open a VM data store in this iteration. Assertions:

    • EnsureCustomerVms.RunCore with the existing fixture: outcomes .Should().BeEquivalentTo(the pre-change expected sequence)

    • EnsureCustomerVms.RunCore dispatch order .Should().Equal(DC first, then the rest)

    • EnsureCustomerVms.cs source .Should().NotContain(nameof(VmDataStorePlanner))

    • EnsureCustomerVms.cs source .Should().NotContain("VirtualizationUnitOfWork")

    • UpdateMethods.RunProxmoxUpdate source .Should().NotContain(nameof(VmDataStorePlanner))

    • CreateVirtualMachines.GetOrderedVmsForHost .Should().Equal(the golden fixture captured before this feature)

    • dotnet test src/NETSetup.sln: every previously green test .Should().still be green

    • ❏ Document the DataStore VM model in netsetup.adoc + a sequence diagram: Add a "Virtual machines as database records" section to the repository-root netsetup.adoc explaining the record/provider/Insert/Delete/stop-change-restart model, the Hyper-V and Proxmox provider mapping, the AddReference3 two-provider registration, and that the planner is read-only for now. Add src/NETSetup/docs/NETSetupVmDataStore.puml showing NETSetupConfig → VmDesiredStateBuilder → VmDataStorePlanner → IVirtualizationUnitOfWork.Commit → Delete / Stop-Change-Start / Insert against the provider. Add a sibling NetsetupAdocVmDataStoreTests next to NetsetupAdocProvisionVmsTests, anchoring the repo root on .git exactly as the existing class does. Assertions:

    • adocContent.Should().Contain("Virtual machines as database records")

    • adocContent.Should().Contain("Insert")

    • adocContent.Should().Contain("stop")

    • adocContent.Should().Contain("restart")

    • adocContent.Should().Contain("IVirtualizationUnitOfWork")

    • adocContent.Should().Contain("AddReference3")

    • adocContent.Should().Contain("NETSetupVmDataStore.puml")

    • pumlFileExists.Should().BeTrue()

    • pumlContent.Should().Contain("@startuml")

    • pumlContent.Should().Contain("VmDesiredStateBuilder")

    • pumlContent.Should().Contain("VmDataStorePlanner")

    • pumlContent.Should().Contain("Stop")

    • pumlContent.Should().Contain("Change")

    • pumlContent.Should().Contain("Start")

5. Done

5.1. osisa-inventory

GOAL: Active network discovery library. Sweep address, find device NOBODY told it about - any OS, no agent, no credential - and classify them. Fill the gap the existing tooling leave: scan-network verb only CIM-probe KNOWN Windows host over PS-remoting, NETSetup.Helpers.ArpScanner is a MikroTik-flow primitive, osisa.LanSweeper only READ the commercial LanSweeper product DB. Full detail: osisa.Inventory.adoc.

Decisions:

  • New project pair src/osisa.Inventory/ + src/osisa.Inventory.Tests/, wired into src/NETSetup.sln. Same shape as osisa.Mikrotik / osisa.OPNSense: packable lib (Package.Build.props), $(DefaultNetTargetFramework), InternalsVisibleTo the test project.

  • Three probe behind one IHostProbe interface, scanner run ALL per address and merge: ArpHostProbe (Win32 SendARP → MAC, Windows + same-segment only), IcmpHostProbe (Ping → round-trip, cross-segment but silent on ICMP-blocking host), TcpPortHostProbe (TcpClient over port list → open port, the one that feed classification). Rationale: no single technique see every device.

  • Probe NEVER throw for unreachable host - return HostProbeOutcome.NoResponse. Only bad INPUT throw. Address answering nothing is dropped from the result: a scan report device, never silence.

  • ScanTargetExpander COUNT before it materialize → mistyped /8 throw ScanTargetLimitExceededException instead of 16 million probe. Concurrency bounded via SemaphoreSlim from InventoryScanRequest.MaxConcurrency.

  • Reuse osisa VO for address / port / name: IP, Port, MachineName from osisa.SystemManagement.Contracts. New Vogen VO only where none exist: MacAddress (canonical AA:BB:CC:DD:EE:FF + FromAnyFormat for dash/dot/bare/lowercase wire input), OuiPrefix, VendorName (with Unknown instance).

  • MacAddress.TryFromAnyFormat use out MacAddress? NOT out MacAddress - Vogen VOG009 prohibit handing out default.

  • DeviceClassifier rule are ORDERED most-specific-first, port + vendor only. SMB+SSH → Nas BEFORE the Windows rule, else every NAS read as Windows.

  • OuiVendorTable.Default is a CURATED STARTER SET (~35 entry), NOT the IEEE registry, and NOT verified against it. Extensible by ctor injection. Full-registry file load deliberately not wired.

  • ArpHostProbe must qualify System.OperatingSystem.IsWindows() - osisa.SystemManagement.Contracts.Elements also define OperatingSystem.

  • Library stand ALONE: no CLI verb, no netsetup update hook, no NETSetupConfig feed. Deliberate - keep the scope to the project.

  • "Scan the whole network" need NO configured range: ILocalNetworkProvider / LocalNetworkProvider read the machine own NIC and emit CIDR specifier straight into InventoryScanRequest.Targets. Skip down link, loopback, tunnel, link-local 169.254.*, non-contiguous netmask, and anything wider than InventoryScanDefaults.MinimumLocalPrefixLength (/22). Floor exist because a NIC on a /16 is near always VPN or container bridge - nobody mean to walk 65k address.

  • Live-wire test gated the repo way: class-level [Ignore("manual: …​")] + [TestCategory("Integration")], same as PhysicalOpnsenseIntegrationTests. Never run in a normal dotnet test.

  • LocalNetworkProviderTests DO touch the real NIC but assert only SHAPE (prefix floor, no loopback, CIDR the expander agree with, own address inside own network) - suite stay green on any box, including one with no usable NIC.

  • PROXY-ARP GUARD (found by actually running the live sweep, NOT by design): Windows SendARP against an address routed at a VPN / TAP adapter get a SYNTHESIZED reply, so EVERY address answer with the same MAC. First live run on a dev box with an OpenVPN TAP adapter (MAC block 00:FF:*) report all 254 address of 192.168.4.0/24 as device. NetworkScanner.RemoveProxyArpArtifacts now drop a group when the address answered ARP ONLY and more than InventoryScanDefaults.MaxAddressesPerMacAddress (4) address share that MAC. Anything that ALSO answer ICMP/TCP is kept - real evidence outrank the heuristic, so the TAP gateway itself still appear. Discard logged at warning naming MAC + count, never silent.

Status (2026-08-11, uncommitted): 33 production .cs + 16 test .cs. dotnet build clean. dotnet test 89 pass / 3 skipped (the live scan, [Ignore] by design).

Verified on dev box, discovery half via .agents/scratch/show-local-networks.cs (zero traffic, NIC read only): LocalNetworkProvider resolve 2 attached network - 192.168.4.0/24 (Local Area Connection, a TAP adapter) + 192.168.3.0/24 (vEthernet External_WIFI) = 508 address.

Verified LIVE (authorized run against the osisa corp LAN, 1 m 13 s): 508 address probed → 252 ARP-only ghost discarded → 42 real device. Result cross-check against known inventory: osisaLib01 .200 + osisaLib02 .202 + .215 + .217 + .233 all classified Hypervisor off port 8006, osisaDC06 .201 Windows, CHOCOSERVER .220, SAGESERVER .221, osisaNextcloud .225 Linux, FWROE04 .254 (ICMP only, no port), a Printer at .241 off 9100/515/631. Classification and reverse-DNS both behaving.

OPEN DEFECT from that run: ArpHostProbe pass sourceIp = 0 to SendARP, so Windows choose the outgoing interface and on a TAP-equipped box choose wrong - MAC resolved for only 3 of 42 device, so vendor read Unknown for the rest. Fix: inject ILocalNetworkProvider into the probe, match the target to the NIC whose subnet hold it, pass THAT address as sourceIp. Not applied yet.

Timing note for anyone running the live test: three probe run in SEQUENCE per address and an unanswered Win32 SendARP block ~2 s, so budget ~3 s per 64 silent address. One mostly-empty /24 at concurrency 128 is ~8-15 s. dotnet build clean (0 error). dotnet test 76/76 green, all offline via StubHostProbe + StubHostNameResolver - nothing touch the wire.

  • ✓ Project scaffold + VO + entity + exception + contract + service + DI extension, src/osisa.Inventory/.

  • ✓ Test project src/osisa.Inventory.Tests/ - 85 offline test over MacAddress, OuiPrefix, ScanTargetExpander (incl. limit ceiling), LocalNetworkProvider, OuiVendorResolver, DeviceClassifier, NetworkScanner (responding + silent host), AddInventory registration.

  • ✓ Whole-network scan: ILocalNetworkProvider + LocalNetworkProvider + InventoryScanDefaults.MinimumLocalPrefixLength, registered in AddInventory.

  • ✓ Live example src/osisa.Inventory.Tests/LiveNetworkScanIntegrationTests.cs - 3 test (sweep every attached network / assert answering method recorded / sweep env-configured range via NETSETUP_INVENTORY_RANGE), prints an address+kind+mac+vendor+hostname+port table to the test output.

  • ✓ Project doc src/osisa.Inventory/osisa.Inventory.adoc - surface, classify rule table, safety, OUI-table caveat, limit.

  • ✓ Proxy-ARP guard NetworkScanner.RemoveProxyArpArtifacts + InventoryScanDefaults.MaxAddressesPerMacAddress, covered by NetworkScannerProxyArpTests (ARP-only shared MAC → dropped) and NetworkScannerSharedMacWithRealEvidenceTests (shared MAC + ICMP → kept).

  • ✓ Free-tool evaluation vs LanSweeper, measured 2026-08-11/12. LanSweeper is commercial + closed, so it is a BENCHMARK not an option (src/osisa.LanSweeper only read one customer’s existing install). Full requirement matrix, every measured number, licensing analysis, blocker list and phased plan: docs/inventory-stack-evaluation.adoc. Verdict: no single free LanSweeper exist, but a free STACK cover it - GLPI + GLPI-Agent (GPLv2) match per-asset depth, nmap win discovery, Lextm.SharpSnmpLib (MIT) own the gear layer. Licensing catch: nmap is NPSL, so invoke-as-prerequisite only, NEVER bundle in NETSetup without a paid OEM license; SharpSnmpLib is MIT and IS shippable. The real gap is not tooling - it is no scanning credential (remote WMI/DCOM Zugriff verweigert, WinRM filtered) and SNMP enabled on exactly 1 of 254 device.

  • ❏ OPEN (supersedes the sourceIp fix): wrap nmap behind a NETCommand builder + parse -oX XML, keep osisa.Inventory domain model on top. Retire the curated OUI table AND the ArpHostProbe sourceIp = 0 defect in one move, and gain service version + OS fingerprint. Cheaper than reimplementing probes.

  • ❏ OPEN: pull ARP + DHCP lease table from MikroTik / OPNsense over API instead of sweeping - zero traffic, immune to the routing trap, and osisa.Mikrotik + osisa.OPNSense.Rest client already exist in this repo. Highest value per effort.

  • ❏ OPEN (infra, not code): credentialed inventory is blocked on this network - remote WMI/DCOM returns Zugriff verweigert and WinRM 5985 is filtered. Needs a dedicated scanning account with local-admin + firewall rule before ANY tool can reach LanSweeper depth.

  • ❏ OPEN: scan-inventory CLI verb in src/NETSetup/CLI/Commands/Network/ writing JSON via the existing ScanOutputPath / ScanProbeRunner output convention.

  • ❏ OPEN: load full IEEE MA-L registry from file instead of the curated table.

  • ❏ OPEN: feed discovered device into NETSetupConfig / customer .cs draft, or push to an infrastructure DB (NetBox / Snipe-IT). Parked reference source for the push shape: src/osisa.Inventory/Reference/ExternalNetworkScannerSample.cs (external ICMP-only sample, fully commented out, prefix = parked). Read its header before borrowing - it hardcode the API token and fire 254 unbounded ping. The osisa way is a NETCommand builder or osisa.Rest.Repository UnitOfWork, not raw HttpClient.

6. NETSetup Specification

Parent Entry Document: NETSetup.adoc

6.1. CLI Command Specification

Direct GitHub downloads require sign-in with access to private osisa/NETSetup repository. Anonymous requests can return HTTP 404 even when asset exists.

SHA-256 Hash of NETSetup.exe:
D1B899C2517BDF5D96716783CF9BFE89662A11763B4F0AF1A5E22B371C516530

NETSetup.exe install and configure aspects of a given machine. During regular NETSetup installation it need not be downloaded separately. Executable provided here for advanced users.

Use NETSetup.exe [command] --help to get help for a specific command.

6.1.1. How to call a command

One binary, one verb per call, no sub-verbs:

NETSetup.exe <command> [--option <value>] [--flag]

NETSetup.exe --help list every command; NETSetup.exe <command> --help list that command’s options. --version print build version. Every run print NETSetup banner + version line first, so log always say which build produced it.

Where binary sit, depending how machine got it:

Situation Path

Installed via Chocolatey (Windows)

on PATH - just NETSetup.exe <command>

Booted from a NETSetup ISO / USB stick (WinPE)

X:\NETSetup\NETSetup.exe (stick) - boot flow call it itself

Proxmox / Linux host

/NETSetup/NETSetup (no .exe), single-file linux-x64 build

Dev box, from source

dotnet run --project src/NETSetup — <command>

Rules that hold for every command:

  • Elevation. Anything that write to machine (install, update, create-iso, install-drivers, backup, restore, disk + edition + domain commands) call ExitIfNotElevated and abort with NEEDS TO BE RUN PRIVILEGED (AS ADMINISTRATOR) when not elevated. Read-only commands (get-, check-, list-*, info) run unelevated.

  • Exit codes. 0 success, 1 failure - nothing else. Scripts can branch on that alone. On failure message also land in timestamped <yyyy-MM-ddTHH-mm-ss>.err file next to logs.

  • Logs. Under LoggingOptions.LogsPathOverride from appsettings.json, else /NETSetup/Logs.

  • Config lookup is automatic. Commands that need customer config (install, update) resolve it by machine’s own serial number: serial → matching Config/<serial>#<ticket>.map MapFile → #<ticket>.json. Nothing to pass by hand. install accept --ticket / --hostname to override that detection; update take no config argument at all.

  • Remote source. Commands that fetch files (ensure-file, ensure-folder, create-iso, image/driver steps) read from configured remote source (Dropbox NETSetup/BOOT/…​). Paths passed to ensure-* are relative to that boot directory, not to local disk.

6.1.3. Core Lifecycle Commands

Source domain: src/NETSetup/CLI/Commands/Core/

6.1.3.1. install
NETSetup.exe install [--ticket <ticket>] [--hostname <hostname>]
  • Privilege: Elevated required.

  • Options:

    • --ticket <ticket> (optional): Override automatic serial-to-ticket lookup.

    • --hostname <hostname> (optional): Override target machine block inside customer config.

  • Behavior: Install and configure machine according to its NETSetup configuration. Look up config by machine’s serial number (Config/<serial>#<ticket>.map → #<ticket>.json). On WinPE / LinuxPE it run full OS installation pipeline (auto-backup if existing Windows found, disk wipe/partition, image apply, driver injection, boot config). On installed OS it configure software, domain join, capabilities, backup restore, and trigger update.

Example
# Auto-detect ticket and hostname by hardware serial number
.\NETSetup.exe install

# Explicit override for ticket and machine hostname
.\NETSetup.exe install --ticket 9154 --hostname devBuilder05
6.1.3.2. update
NETSetup.exe update [--skip-os-updates] [--skip-choco-packages]
  • Privilege: Elevated required.

  • Options:

    • --skip-os-updates (optional): Skip HP / Windows / kernel OS-update steps.

    • --skip-choco-packages (optional): Skip Chocolatey package upgrade/install while keeping choco itself plus NETSetup self-update. Both flags exist for CI and test runs where OS-update leg is slow and irrelevant.

  • Behavior: Update installed machine software. Run self-update check (EnsureLatestBinaryOrReexec on Proxmox), Chocolatey upgrade-all, ensure all config-specified packages with exact versions are installed, and refresh Group Policy if domain-joined. On Proxmox host this verb also run EnsureProxmoxPostInstall, EnsureCustomerVms, and EnsureCustomerLxcs to provision and converge the customer VM/LXC stack (see How it works).

Example
# Full production update (or Proxmox host VM/LXC convergence)
.\NETSetup.exe update

# Fast CI/test update skipping slow OS and package upgrade legs
.\NETSetup.exe update --skip-os-updates --skip-choco-packages
6.1.3.3. ensure-update-proxmox
NETSetup.exe ensure-update-proxmox [--host <ip-or-hostname>] [--user <user>] [--password <password>] [--port <port>] [--jump-host <ip>] [--target-major <version>] [--minor-only] [--reboot]
  • Privilege: Root on target Proxmox VE host (local or remote SSH).

  • Options:

    • --host <ip-or-hostname> (optional): Remote Proxmox VE host IP or hostname. When omitted, updates the local Proxmox host directly.

    • --user <user> (optional): SSH/API username for remote connection (default: root).

    • --password <password> (optional): Password for remote connection (or jump host when --jump-host is set). Falls back to ProxmoxOptions.Password in appsettings.json when omitted.

    • --port <port> (optional): Proxmox HTTPS API port for remote connection (default: 8006).

    • --jump-host <ip> (optional): Cluster peer Proxmox IP to hop through via SSH when target only accepts cluster-peer SSH keys.

    • --target-major <version> (optional): Target Proxmox VE major release (default: 9 for PVE 9 / Debian 13 trixie).

    • --minor-only (optional): Update packages within the current Proxmox VE major version only (skip major release transition).

    • --reboot (optional): Automatically schedule a detached reboot if a newer kernel was installed.

  • Behavior: Dedicated Proxmox VE host updater that brings a local or remote Proxmox host to the latest version without triggering customer VM provisioning or Liberator VLAN cutover. Executes in six phases:

    1. Probe initial pveversion and free root filesystem capacity (/).

    2. Reclaim root disk space (journalctl --vacuum-size=200M, remove .gz/.1/.old rotated logs, truncate oversized active logs > 100 MB, clean /tmp and /var/tmp, apt-get clean) and offload /var/cache/apt/archives onto /var/lib/vz/apt-archives via symlink when /var/lib/vz has greater free capacity.

    3. Normalize APT sources (bullseye / bookworm / trixie), disable enterprise .list and .sources repositories, and configure pve-no-subscription (plus pbs-no-subscription when Proxmox Backup Server is installed).

    4. Execute non-interactive dpkg --configure -a, apt-get update, apt-get dist-upgrade -y (--force-confdef, --force-confold), and apt-get --purge autoremove -y within the current major version.

    5. When currentMajor < targetMajor and --minor-only is not set, step through in-place major release upgrades (PVE 7 → 8 and PVE 8 → 9) including UEFI grub-efi-amd64 protection, LVM autoactivation migration, conflicting meta-package removal, and pve7to8 / pve8to9 checks.

    6. Re-apply the subscription nag-removal hook, verify the updated pveversion, and report initial/final versions and whether a kernel reboot is required.

Example
# Update the local Proxmox host to the latest PVE 9 release
/NETSetup/NETSetup ensure-update-proxmox

# Update a remote Proxmox host over SSH to the latest release
.\NETSetup.exe ensure-update-proxmox --host 192.168.3.200 --password "<root-password>"

# Update a remote Proxmox host via a cluster peer jump host
.\NETSetup.exe ensure-update-proxmox --host 192.168.3.200 --jump-host 192.168.3.202 --password "<peer-root-password>"
6.1.3.4. backup
NETSetup.exe backup [--source <drive>] [--target <path>]
  • Privilege: Elevated required.

  • Options:

    • --source <drive> (optional): Source Windows drive root (auto-detected by probing \Windows\System32\config\SYSTEM when omitted).

    • --target <path> (optional): Target backup folder (auto-detected by probing USB drives for \NETSetup\NETSetup.exe when omitted).

  • Behavior: Mirror \Users + selected \ProgramData (chocolatey, ssh, Microsoft\Crypto, Microsoft\UserAccountPictures) + 3rd-party drivers (DISM /Export-Driver /Image:) from existing Windows install to NETSetup USB stick. Backup layout: <usb>\NETSetup\Backup\<yyyyMMdd-HHmmss>\{Users,ProgramData,Drivers}\. Auto-run in WinPE before disk wipe - no-op when no existing Windows present. Driver export critical for fresh-laptop downgrades (Wi-Fi/NIC drivers).

Example
# Auto-detect existing Windows root and NETSetup USB stick
.\NETSetup.exe backup

# Explicit source drive and target backup root
.\NETSetup.exe backup --source C:\ --target D:\NETSetup\Backup
6.1.3.5. restore
NETSetup.exe restore [--source <drive>]
  • Privilege: Elevated required.

  • Options:

    • --source <drive> (optional): USB drive containing \NETSetup\Backup\<latest>\ (auto-detected when omitted).

  • Behavior: Pull latest backup from <usb>\NETSetup\Backup\<latest>\ onto C:. Profiles restored via robocopy with NTUSER.DAT / UsrClass.dat / AppData\Local excluded (would corrupt fresh registry/cache). AppData\Roaming restored separately. ACLs reset per profile (icacls /reset + grant user/Admins/SYSTEM) since post-reinstall SIDs differ. Skip profiles whose local user does not exist yet (warn to create + log in once first). Auto-run in install flow on Windows side just before update phase. Idempotent via BackupRestored log marker.

Example
# Restore latest backup from auto-detected NETSetup USB stick
.\NETSetup.exe restore

# Restore from explicit drive letter
.\NETSetup.exe restore --source D:\
6.1.3.6. install-drivers
NETSetup.exe install-drivers --path <path> [--recursive]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --path <path> (required): Directory containing .inf driver packages.

    • --recursive (optional): Walk subdirectories recursively.

  • Behavior: Install all .inf drivers in <path> via pnputil /add-driver /install.

Example
.\NETSetup.exe install-drivers --path C:\NETSetup\Drivers\Proxmox --recursive

6.1.4. Build Commands

Source domain: src/NETSetup/CLI/Commands/Build/

6.1.4.1. create-iso
NETSetup.exe create-iso [--offline | --reusable] [--output <path>] [--ticket <ticket>] [--hostname <hostname>] [--images <path>] [--drivers <path>]
  • Privilege: Elevated required.

  • Options:

    • --offline (optional, mutually exclusive with --reusable): Bake images, drivers AND one ticket’s config into NETSetup-<ticket>-<hostname>.iso. Require --ticket and --hostname.

    • --reusable (optional, mutually exclusive with --offline): Bake images + drivers into ONE NETSetup.iso with NO per-ticket config; each guest resolve its own config online by serial number (pair with publish-config). Need neither --ticket nor --hostname.

    • --output <path> (optional): Target output directory for generated ISO.

    • --ticket <ticket> (optional/conditional): Customer ticket ID for --offline mode.

    • --hostname <hostname> (optional/conditional): Target machine hostname for --offline mode.

    • --images <path> (optional): Override source directory for OS images.

    • --drivers <path> (optional): Override source directory for hardware drivers.

  • Behavior: Create NETSetup install ISO in one of three modes:

    1. release (no mode flag) - plain NETSetup.iso, no images, no config. Guest need network.

    2. offline (--offline) - self-contained per-machine NETSetup-<ticket>-<hostname>.iso.

    3. reusable (--reusable) - shared NETSetup.iso with images + drivers baked in. On Proxmox hosts finished ISO is copied to queried ISO storage location.

Example
# 1. Release ISO (network boot/fetch)
.\NETSetup.exe create-iso

# 2. Offline ISO for a single ticket + hostname
.\NETSetup.exe create-iso --offline --ticket 12345 --hostname DC01

# 3. Shared reusable ISO with Windows 11 image and Proxmox VirtIO drivers baked in
.\NETSetup.exe create-iso --reusable --images "Windows 11" --drivers "Proxmox"
6.1.4.2. create-binaries
NETSetup.exe create-binaries [--output <path>]
  • Privilege: Standard / build context.

  • Options:

    • --output <path> (optional): Root output path (defaults to repo/current output directory).

  • Behavior: Publish NETSetup single-file binaries (win-x64 NETSetup.exe, linux-x64 NETSetup, linux-musl-x64 NETSetup.musl) into output/artifacts/ and write SHA256 .adoc sidecar files. Does not build an ISO. Used by CI/CD deployment workflow.

Example
.\NETSetup.exe create-binaries --output C:\_gh\main\netsetup\output
6.1.4.3. publish-config
NETSetup.exe publish-config --ticket <ticket> [--config <path>]
  • Privilege: Standard (requires Dropbox remote source configured in appsettings.json or local Dropbox sync).

  • Options:

    • --ticket <ticket> (required): Ticket number (4711 and #4711 both normalize to #4711.json).

    • --config <path> (optional): Path to generated JSON file; default Config/#<ticket>.json under current directory.

  • Behavior: Upload customer config <ticket>.json and its per-machine Config/<serial><ticket>.map MapFiles to remote source (Dropbox), WITHOUT building an ISO. Online counterpart to --offline: once published, a guest booted from a --reusable NETSetup.iso resolve its config by serial against uploaded MapFiles.

Example
.\NETSetup.exe publish-config --ticket 9159 --config C:\_gh\main\netsetup\Customers\#9159.json

6.1.5. Info & Diagnostics Commands

Source domain: src/NETSetup/CLI/Commands/Info/

6.1.5.1. info
NETSetup.exe info
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Display NETSetup version, SemVer build tag, runtime environment, and build metadata.

Example
.\NETSetup.exe info
6.1.5.2. get-os
NETSetup.exe get-os
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Detect and print current operating system (WinPE, LinuxPE, Windows 11, Windows Server, Proxmox, NixOS, etc.).

Example
.\NETSetup.exe get-os
6.1.5.3. get-win-key
NETSetup.exe get-win-key
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Read and print OEM Windows product key embedded in BIOS / UEFI MSDM table.

Example
.\NETSetup.exe get-win-key
6.1.5.4. get-id
NETSetup.exe get-id
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Read and print machine’s BIOS serial number via SMBIOS (System_Management_BIOS).

Example
.\NETSetup.exe get-id
6.1.5.5. get-serial-number
NETSetup.exe get-serial-number
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Query and print normalized hardware serial number used by NETSetup for MapFile lookup (Config/<serial>#<ticket>.map).

Example
.\NETSetup.exe get-serial-number
6.1.5.6. scan-hardware
NETSetup.exe scan-hardware [--output <path>]
  • Privilege: Unelevated (local CIM read).

  • Options:

    • --output <path> (optional): Destination file or directory. Default to {LogsRoot}/hwscan-<timestamp>.json, where LogsRoot = appsettings.json LoggingOptions.LogsPathOverride else /NETSetup/Logs. If directory passed, default filename appended; if file passed, used as-is.

  • Behavior: Probe local machine hardware (CPU, RAM, disks, NICs, BIOS/serial, OS) via CIM and serialize inventory report to JSON.

Example
.\NETSetup.exe scan-hardware --output C:\NETSetup\Logs\hwscan-local.json
6.1.5.7. example-log-output
NETSetup.exe example-log-output
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Emit sample log messages at Trace, Debug, Information, Warning, Error, and Critical levels to console for verifying console log formatting and level filters.

Example
.\NETSetup.exe example-log-output
6.1.5.8. example-log-output-to-files
NETSetup.exe example-log-output-to-files
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Emit sample log messages across log levels to configured log file sinks (LoggingOptions.LogsPathOverride or /NETSetup/Logs) for testing file logger rotation and sinks.

Example
.\NETSetup.exe example-log-output-to-files

6.1.6. Hashing & Verification Commands

Source domain: src/NETSetup/CLI/Commands/Hash/

6.1.6.1. get-md5
NETSetup.exe get-md5 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print MD5 digest of <file>.

Example
.\NETSetup.exe get-md5 --file C:\temp\document.docx
6.1.6.2. get-sha2-256
NETSetup.exe get-sha2-256 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print SHA-256 digest of <file>.

Example
.\NETSetup.exe get-sha2-256 --file C:\temp\document.docx
6.1.6.3. get-sha2-384
NETSetup.exe get-sha2-384 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print SHA-384 digest of <file>.

Example
.\NETSetup.exe get-sha2-384 --file C:\temp\document.docx
6.1.6.4. get-sha2-512
NETSetup.exe get-sha2-512 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print SHA-512 digest of <file>.

Example
.\NETSetup.exe get-sha2-512 --file C:\temp\document.docx
6.1.6.5. get-crc32-le
NETSetup.exe get-crc32-le --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print CRC-32 checksum (little-endian) of <file>.

Example
.\NETSetup.exe get-crc32-le --file C:\temp\archive.7z
6.1.6.6. get-crc32-be
NETSetup.exe get-crc32-be --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print CRC-32 checksum (big-endian) of <file>.

Example
.\NETSetup.exe get-crc32-be --file C:\temp\archive.7z
6.1.6.7. get-xxh-32
NETSetup.exe get-xxh-32 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print xxHash-32 digest of <file>.

Example
.\NETSetup.exe get-xxh-32 --file C:\temp\install.wim
6.1.6.8. get-xxh-64
NETSetup.exe get-xxh-64 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print xxHash-64 digest of <file>.

Example
.\NETSetup.exe get-xxh-64 --file C:\temp\install.wim
6.1.6.9. get-xxh3-64
NETSetup.exe get-xxh3-64 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print xxHash3-64 digest of <file>.

Example
.\NETSetup.exe get-xxh3-64 --file C:\temp\install.wim
6.1.6.10. get-xxh3-128
NETSetup.exe get-xxh3-128 --file <file>
  • Privilege: Unelevated.

  • Options: --file <file> (required) - path to file.

  • Behavior: Compute and print xxHash3-128 digest of <file>.

Example
.\NETSetup.exe get-xxh3-128 --file C:\temp\install.wim
6.1.6.11. check-sha2-256
NETSetup.exe check-sha2-256 --file <file> --hash <checksum>
  • Privilege: Unelevated.

  • Options:

    • --file <file> (required): Path to file to verify.

    • --hash <checksum> (required): Expected hex SHA-256 digest.

  • Behavior: Compute SHA-256 of <file> and compare against <checksum>. Exit 0 on match, 1 on mismatch.

Example
.\NETSetup.exe check-sha2-256 --file C:\NETSetupDEV\NETSetup.iso --hash fc2b5132a901e4b8c7d6e5f4a3b2c1d09876543210fedcba012345678992bd87
6.1.6.12. check-sha2-512
NETSetup.exe check-sha2-512 --file <file> --hash <checksum>
  • Privilege: Unelevated.

  • Options:

    • --file <file> (required): Path to file to verify.

    • --hash <checksum> (required): Expected hex SHA-512 digest.

  • Behavior: Compute SHA-512 of <file> and compare against <checksum>. Exit 0 on match, 1 on mismatch.

Example
.\NETSetup.exe check-sha2-512 --file C:\temp\archive.7z --hash 3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1eb8b85103e3be7ba613b31bb5c9c36214dc9f14a42fd7a2fdb84856bca5c44c2
6.1.6.13. check-xxh3-128
NETSetup.exe check-xxh3-128 --file <file> --hash <checksum>
  • Privilege: Unelevated.

  • Options:

    • --file <file> (required): Path to file to verify.

    • --hash <checksum> (required): Expected hex xxHash3-128 digest.

  • Behavior: Compute xxHash3-128 of <file> and compare against <checksum>. Exit 0 on match, 1 on mismatch.

Example
.\NETSetup.exe check-xxh3-128 --file C:\temp\install.wim --hash 99aa06d3014798d86001c324468d497f

6.1.7. Network Commands

Source domain: src/NETSetup/CLI/Commands/Network/

6.1.7.1. connect-wifi
NETSetup.exe connect-wifi [--ssid <ssid>] [--password <password>]
  • Privilege: Elevated / network operator.

  • Options:

    • --ssid <ssid> (optional): Wireless network SSID.

    • --password <password> (optional): WPA/WPA2/WPA3 pre-shared key.

  • Behavior: Connect wireless adapter to specified Wi-Fi network profile.

Example
.\NETSetup.exe connect-wifi --ssid swisscom --password abcd-1234-efgh-5678-ijkl
6.1.7.2. set-ipv4
NETSetup.exe set-ipv4 --ip <ip> --netmask <netmask> --gateway <gateway> --dns <dns>
  • Privilege: Elevated required.

  • Options:

    • --ip <ip> (required): Static IPv4 address.

    • --netmask <netmask> (required): Subnet mask (e.g. 255.255.255.0).

    • --gateway <gateway> (required): Default gateway IPv4 address.

    • --dns <dns> (required): DNS server IPv4 address.

  • Behavior: Configure active network interface with static IPv4 address, subnet mask, default gateway, and DNS server.

Example
.\NETSetup.exe set-ipv4 --ip 192.168.3.80 --netmask 255.255.255.0 --gateway 192.168.3.254 --dns 192.168.3.201
6.1.7.3. enable-dhcp-server
NETSetup.exe enable-dhcp-server
  • Privilege: Elevated required.

  • Options: None.

  • Behavior: Enable and start local DHCP server service on machine.

Example
.\NETSetup.exe enable-dhcp-server
6.1.7.4. disable-dhcp-server
NETSetup.exe disable-dhcp-server
  • Privilege: Elevated required.

  • Options: None.

  • Behavior: Stop and disable local DHCP server service on machine.

Example
.\NETSetup.exe disable-dhcp-server
6.1.7.5. wake-machine
NETSetup.exe wake-machine --mac-address <mac> [--broadcast <ip>] [--port <port>]
  • Privilege: Unelevated.

  • Options:

    • --mac-address <mac> (required): Target NIC MAC address (AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF).

    • --broadcast <ip> (optional): Broadcast IPv4 address (default 255.255.255.255).

    • --port <port> (optional): UDP target port (default 9).

  • Behavior: Send Wake-on-LAN (WoL) UDP magic packet to wake single target machine.

Example
.\NETSetup.exe wake-machine --mac-address 00:15:5D:AB:CD:EF --broadcast 192.168.3.255 --port 9
6.1.7.6. wake-machines
NETSetup.exe wake-machines --file-path <path> [--broadcast <ip>] [--port <port>]
  • Privilege: Unelevated.

  • Options:

    • --file-path <path> (required): File containing list of MAC addresses to wake.

    • --broadcast <ip> (optional): Broadcast IPv4 address.

    • --port <port> (optional): UDP target port.

  • Behavior: Read MAC address list from <path> and broadcast Wake-on-LAN magic packets to all listed machines.

Example
.\NETSetup.exe wake-machines --file-path C:\NETSetup\Config\mac-list.txt --broadcast 192.168.3.255
6.1.7.7. enable-wol
NETSetup.exe enable-wol --interface-name <name>
  • Privilege: Elevated required.

  • Options:

    • --interface-name <name> (required): Network interface name (e.g. eth0, Ethernet).

  • Behavior: Enable Wake-on-LAN magic-packet wake capability on specified network adapter (ethtool -s <iface> wol g on Linux / NIC power management + advanced properties on Windows).

Example
.\NETSetup.exe enable-wol --interface-name "Ethernet"
6.1.7.8. query-wol-status
NETSetup.exe query-wol-status --interface-name <name>
  • Privilege: Unelevated / root for ethtool.

  • Options:

    • --interface-name <name> (required): Network interface name.

  • Behavior: Query and print current Wake-on-LAN configuration and link wake flags for <name>.

Example
.\NETSetup.exe query-wol-status --interface-name "Ethernet"
6.1.7.9. check-online
NETSetup.exe check-online
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Check whether machine has active internet connectivity. Exit 0 when online, 1 when offline.

Example
.\NETSetup.exe check-online
6.1.7.10. check-website-online
NETSetup.exe check-website-online --url <url> [--timeout-minutes <minutes>]
  • Privilege: Unelevated.

  • Options:

    • --url <url> (required): Target HTTP/HTTPS URL to probe.

    • --timeout-minutes <minutes> (optional): Poll timeout in minutes before failing.

  • Behavior: Probe whether specific URL is reachable within optional timeout window.

Example
.\NETSetup.exe check-website-online --url https://nextcloud.osisa.com --timeout-minutes 5
6.1.7.11. scan-network
NETSetup.exe scan-network [--hosts <csv>] [--range <cidr|a-b>] [--user <u>] [--password <p>] [--output <path>] [--no-self]
  • Privilege: Unelevated / domain admin credentials for remote CIM/WinRM.

  • Options:

    • --hosts <csv> (optional): Comma-separated list of IPs or hostnames.

    • --range <cidr|a-b> (optional): CIDR (10.0.0.0/24) or inclusive IP range (10.0.0.10-10.0.0.50); can combine with --hosts.

    • --user <u> (optional): Remote username (omit to use current Kerberos session).

    • --password <p> (optional): Remote user password.

    • --output <path> (optional): Output file or directory; same default rules as scan-hardware ({LogsRoot}/hwscan-<timestamp>.json).

    • --no-self (optional): Skip probing local scanning machine.

  • Behavior: Probe current machine + remote Windows hosts via PowerShell Invoke-Command hardware inventory collector. Unreachable hosts logged as failures without aborting; output JSON include per-host status and hardware details.

Example
.\NETSetup.exe scan-network --range 192.168.3.70-192.168.3.85 --no-self --output C:\NETSetup\Logs\lab-scan.json
6.1.7.12. flash-mikrotik
NETSetup.exe flash-mikrotik --admin-password <pw> [--mac <MAC>] [--ssid <ssid>] [--wifi-password <pw>] [--hostname <name>] [--flashed-new-admin-password <pw>]
  • Privilege: Elevated required (modifies local Ethernet NIC IPv4 address during factory-IP fallback and ensures putty.install).

  • Options:

    • --admin-password <pw> (required): CURRENT admin password on device (sticker password for stock units).

    • --mac <MAC> (optional): Target MikroTik MAC address. When omitted, ARP discovery is skipped and host go straight to factory-IP route (192.168.88.1), assuming MikroTik is cabled directly to host’s first Ethernet adapter.

    • --ssid <ssid> (optional): Wi-Fi SSID (default Liberator).

    • --wifi-password <pw> (optional): Wi-Fi password (default 12345678).

    • --hostname <name> (optional): Router identity hostname (default Lib_AP_<rand4>).

    • --flashed-new-admin-password <pw> (optional): Admin password to set after flashing (default "default4244$").

  • Behavior: Flash MikroTik hAP-class device into router+AP+switch mode:

    • WAN = dual-WAN auto-detect: dhcp-client on BOTH untagged ether1 AND tagged ether1-vlan10 (VLAN 10 sub-iface, const WanVlanId="10"); both join WAN interface-list; masquerade use out-interface-list=WAN. Box auto-work on plain DHCP net (untagged lease) OR behind XGS-PON modem (VLAN10-tagged lease) - same wire so only one ever lease.

    • LAN = bridge ether2-5 + wifi1 + wifi2 with static 10.0.0.1/24 + dhcp-server pool 10.0.0.2-10.0.0.254.

    • Factory route: pick first Up Ethernet adapter (log name+MAC+ifIndex+other-count), set static 192.168.88.10/24 on it, 10s countdown, probe 192.168.88.1, exit with clear message if unreachable; revert NIC to DHCP after reset-configuration (and in finally on any failure).

    • Flow: ensure putty.install choco pkg (provide pscp) → resolve MAC→IP via ARP scan (fallback 192.168.88.1) → render .rsc → pscp upload → /system reset-configuration keep-users no-defaults skip-backup run-after-reset=<file> → poll 10.0.0.1 until API respond (5 min timeout). Cable PC to ether2-5 before running.

    • Manual WinBox equivalent + iWay activation: Step-by-step → Setup phase.

Example
.\NETSetup.exe flash-mikrotik --admin-password "<sticker-pw>" --ssid "CarreraWiFi" --wifi-password "<wifi-pw>" --hostname "Lib_AP_Carrera"

6.1.8. Disk & Encryption Commands

Source domain: src/NETSetup/CLI/Commands/Disk/

6.1.8.1. list-disk
NETSetup.exe list-disk
  • Privilege: Unelevated (or elevated for raw disk metadata).

  • Options: None.

  • Behavior: Enumerate and display all physical disks detected on machine (index, model, size, bus type, removable/system flags).

Example
.\NETSetup.exe list-disk
6.1.8.2. list-usb
NETSetup.exe list-usb
  • Privilege: Unelevated.

  • Options: None.

  • Behavior: Enumerate and display all attached USB storage drives on machine.

Example
.\NETSetup.exe list-usb
6.1.8.3. select-disk
NETSetup.exe select-disk [--index <index>]
  • Privilege: Elevated required.

  • Options:

    • --index <index> (optional): Explicit disk index to select; auto-selects target OS installation disk when omitted.

  • Behavior: Select target installation disk by index (or via NETSetup disk-selection heuristics excluding USB boot media).

Example
# Auto-select OS target disk
.\NETSetup.exe select-disk

# Select disk index 0 explicitly
.\NETSetup.exe select-disk --index 0
6.1.8.4. encrypt
NETSetup.exe encrypt --text <text>
  • Privilege: Unelevated.

  • Options:

    • --text <text> (required): Plaintext string to encrypt.

  • Behavior: Encrypt <text> using NETSetup encryption key (osisa.Security.Contracts.EncryptionHelper) and print ciphertext for embedding in configuration files.

Example
.\NETSetup.exe encrypt --text "PlaintextSecretToEncrypt"
6.1.8.5. decrypt
NETSetup.exe decrypt --text <text>
  • Privilege: Unelevated.

  • Options:

    • --text <text> (required): NETSetup-encrypted ciphertext string.

  • Behavior: Decrypt <text> using NETSetup encryption key and print plaintext.

Example
.\NETSetup.exe decrypt --text "<base64-ciphertext>"

6.1.9. Windows Commands

Source domain: src/NETSetup/CLI/Commands/Windows/

6.1.9.1. change-windows-edition
NETSetup.exe change-windows-edition --to <Home|Pro|Enterprise> [--product-key <key>]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --to <Home|Pro|Enterprise> (required): Target Windows edition.

    • --product-key <key> (optional): Explicit product key; when omitted, generic (non-activation) edition-switch key is used.

  • Behavior: Flip running Windows edition. Upgrades (Home → Pro, Pro → Enterprise) use changepk.exe. Downgrade Pro → Home is UNSUPPORTED by Microsoft; implemented via registry rewrite (EditionID, ProductName, CompositionEditionID under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion) plus slmgr /ipk + /ato with Home generic key. User data stay in place. Future Windows feature update may revert edition marker. Reboot required.

Example
# Switch edition to Windows Pro using generic edition-switch key
.\NETSetup.exe change-windows-edition --to Pro
6.1.9.2. join-domain
NETSetup.exe join-domain --domain <fqdn> --user <user> --password <password> [--ou <dn>]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --domain <fqdn> (required): Active Directory domain FQDN (e.g. corp.osisa.com).

    • --user <user> (required): Domain user with join privileges.

    • --password <password> (required): Domain user password.

    • --ou <dn> (optional): Distinguished Name of target Organizational Unit (e.g. OU=STATIC,OU=CLIENTS,OU=MACHINES,OU=OSISA,DC=corp,DC=osisa,DC=com).

  • Behavior: Join machine to Active Directory domain. Without --ou use WMI Win32_ComputerSystem.JoinDomainOrWorkgroup (places computer in default CN=Computers). With --ou switch to Add-Computer -OUPath, which is the ONLY join that place computer object straight into target OU (inside GPO scope granting local admin). Reboot required to finalize.

Example
.\NETSetup.exe join-domain --domain corp.osisa.com --user osisaadmin --password "<domain-pw>" --ou "OU=STATIC,OU=CLIENTS,OU=MACHINES,OU=OSISA,DC=corp,DC=osisa,DC=com"
6.1.9.3. unjoin-domain
NETSetup.exe unjoin-domain
  • Privilege: Elevated required (Windows only).

  • Options: None.

  • Behavior: Force-unjoin machine from its Active Directory domain (DomainJoinMethods.ForceUnjoinDomain), leaving it in a workgroup. Used when re-purposing machine that still carry stale domain membership before fresh install.

Example
.\NETSetup.exe unjoin-domain
6.1.9.4. enable-winrm
NETSetup.exe enable-winrm [--local-subnet-only]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --local-subnet-only (optional): Restrict WinRM firewall rules to LocalSubnet instead of widening to -RemoteAddress Any.

  • Behavior: Enable WinRM / PowerShell Remoting: service set to Automatic + started, HTTP listener via Enable-PSRemoting -SkipNetworkProfileCheck (fresh node sit on Public profile where plain Enable-PSRemoting refuse), WINRM-HTTP-In-TCP firewall rules enabled (addressed by -Name, never localized -DisplayName), and LocalAccountTokenFilterPolicy=1 set so local admin is not token-filtered. Idempotent.

Example
# Enable WinRM accessible from any subnet
.\NETSetup.exe enable-winrm

# Enable WinRM restricted to LocalSubnet
.\NETSetup.exe enable-winrm --local-subnet-only
6.1.9.5. install-wsl
NETSetup.exe install-wsl [--reboot]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --reboot (optional): Reboot immediately after staging WSL2 features.

  • Behavior: Stage WSL2 runtime: DISM enable Microsoft-Windows-Subsystem-Linux + VirtualMachinePlatform (offline-safe, /norestart), then wsl --set-default-version 2, wsl --update, wsl --install --no-distribution (online, allowed to fail when offline because features are already staged and later re-run finish it). Reboot is ALWAYS required afterwards. Required prerequisite for Docker Desktop Linux engine.

Example
.\NETSetup.exe install-wsl --reboot
6.1.9.6. add-group-member
NETSetup.exe add-group-member --group <group> --member <principal>
  • Privilege: Elevated required (Windows only).

  • Options:

    • --group <group> (required): Local group name (Administrators, docker-users, etc.).

    • --member <principal> (required): Member principal (DOMAIN\Principal or local username).

  • Behavior: Add principal to local group. Built-in groups resolved by well-known SID first (Administrators → S-1-5-32-544, etc.) because built-in names are localized (Administratoren on German nodes). Custom groups such as docker-users fall back to literal name. Existing membership is reported and treated as success.

Example
.\NETSetup.exe add-group-member --group Administrators --member "osisa\LOCALADMINS"
6.1.9.7. apply-capabilities
NETSetup.exe apply-capabilities [--hostname <hostname>] [--reboot]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --hostname <hostname> (optional): Override which machine block from resolved customer config to apply.

    • --reboot (optional): Reboot immediately if any applied capability requires restart.

  • Behavior: Apply WindowsCapabilities block declared on machine’s config (WinRM, WSL2 runtime, local-group membership, Docker autostart) to an ALREADY installed node. Same code path Stage 3 run during install, bringing a node level without reinstall. Config resolved by serial like install / update. Marker-gated and idempotent.

Example
.\NETSetup.exe apply-capabilities --hostname devRunner02 --reboot
6.1.9.8. enable-docker-autostart
NETSetup.exe enable-docker-autostart --user <account> --password <pw> [--template-profile <profile>]
  • Privilege: Elevated required (Windows only).

  • Options:

    • --user <account> (required): Account for interactive Autologon session.

    • --password <pw> (required): Password for Autologon account (stored in LSA secret via Sysinternals Autologon / Winlogon).

    • --template-profile <profile> (optional): Profile template used to seed Docker settings-store.json.

  • Behavior: Configure Windows Autologon plus Docker Desktop startup chain so Docker Linux engine come back after unattended reboot (Docker Desktop has no headless service mode). Prefer apply-capabilities, which read account and password from config instead of CLI arguments. Writes UTF-8 without BOM to %APPDATA%\Docker\settings-store.json and clears "credsStore": "" in %USERPROFILE%\.docker\config.json.

Example
.\NETSetup.exe enable-docker-autostart --user admin --password "<client-admin-pw>"

6.1.10. File & Serving Commands

Source domain: src/NETSetup/CLI/Commands/Files/

6.1.10.1. ensure-file
NETSetup.exe ensure-file --file <file> --target <directory>
  • Privilege: Standard (write access to <directory>).

  • Options:

    • --file <file> (required): File path relative to remote NETSetup boot directory (NETSetup/BOOT/…​).

    • --target <directory> (required): Local target directory.

  • Behavior: Ensure <file> exist in <directory>; download from configured remote source if missing or mismatched.

Example
.\NETSetup.exe ensure-file --file Images/nixos.iso.sha256 --target C:\NETSetupDEV\Images
6.1.10.2. ensure-folder
NETSetup.exe ensure-folder --source <folder> --target <directory>
  • Privilege: Standard (write access to <directory>).

  • Options:

    • --source <folder> (required): Folder path relative to remote NETSetup boot directory (NETSetup/BOOT/…​).

    • --target <directory> (required): Local target directory.

  • Behavior: Ensure <folder> and all its recursive contents exist in <directory>; download missing files from configured remote source.

Example
.\NETSetup.exe ensure-folder --source Drivers/Proxmox --target C:\NETSetupDEV\Drivers\Proxmox
6.1.10.3. serve-file
NETSetup.exe serve-file --file <file> [--port <port>]
  • Privilege: Standard (or elevated for low ports on some platforms).

  • Options:

    • --file <file> (required): Local file path to serve.

    • --port <port> (optional): TCP port to listen on (default 80).

  • Behavior: Start lightweight HTTP server serving single <file> on <port>.

Example
.\NETSetup.exe serve-file --file C:\NETSetupDEV\NETSetup.iso --port 8080
6.1.10.4. serve-folder
NETSetup.exe serve-folder [--path <path>] [--port <port>]
  • Privilege: Standard.

  • Options:

    • --path <path> (optional): Local directory to serve (default: current working directory).

    • --port <port> (optional): TCP port to listen on (default 80).

  • Behavior: Start lightweight HTTP server serving directory tree at <path> on <port>.

Example
.\NETSetup.exe serve-folder --path C:\NETSetup --port 8080

6.1.11. AI & Dev Commands

Source domain: src/NETSetup/CLI/Commands/Dev/

6.1.11.1. assistant
NETSetup.exe assistant [--prompt <prompt>] [--model <model>]
  • Privilege: Unelevated.

  • Options:

    • --prompt <prompt> (optional): Single question to send and exit; when omitted, start interactive REPL mode.

    • --model <model> (optional): Override default Claude model ID.

  • Behavior: Connect to Claude API for NETSetup-related questions, automatically supplying machine’s resolved NETSetup configuration as context.

Example
# One-shot question using machine's NETSetupConfig context
.\NETSetup.exe assistant --prompt "What software is configured for this machine?"

# Interactive REPL mode
.\NETSetup.exe assistant

6.2. How it works

6.2.1. Company → Config: how a customer become a NETSetupConfig

One customer describe ONCE, as a company: people, devices, tasks. A translator turn that human-shaped description into the machine-shaped NETSetupConfig. The serialised config is the ONLY thing production code ever read - no customer .cs ship anywhere, no translator run on a target box.

6.2.1.1. Whole chain at glance
docs (adoc / docx / M365 export)        <- human source, not machine read
        |
        v
Customers/<Name>.cs                     <- .NET 10 file-based app, ONE per customer
        |  Company.CreateBuilder(...)... .Build()
        v
Company  (osisa.Enterprise.Entities)    <- WHO the customer is: Employee / Task / Device / OU
        |  new LiberatorWithWinDCTranslator(company).Translate()
        v
NETSetupConfig (NETSetup.Entities)      <- WHAT gets installed: DirectoryService, Machines,
        |                                  Users, SoftwareList, subnets, gateway, mail, dns
        |  customer .cs override block (optional)
        v
JsonSerializer.Serialize(config, NETSetupConfig.JsonSerializerOptions)
        |
        +--> Customers/<Name>.json      <- committed, linked into NETSetup.Tests as content
        +--> Customers/#<ticket>.json   <- same bytes, ticket-numbered alias
        |
        |  netsetup publish-config
        v
Dropbox NETSetup/Config/                <- <Name>.json + one <serial>#<ticket>.map per computer
        |
        |  guest boot / netsetup update read it back
        v
ConfigMethods.GetConfig(...) -> NETSetupConfig on the target machine
Stage Owner Artifact

describe company

Customers/<Name>.cs (human edit)

Company

seed config

WindowsDCTranslator ctor

empty NETSetupConfig + domain + 3 service users + locale

translate

LiberatorWithWinDCTranslator.Translate()

fully populated NETSetupConfig

override

tail of the customer .cs

same config, customer specifics patched in

serialise

NETSetupConfig.JsonSerializerOptions

<Name>.json + #<ticket>.json

publish

netsetup publish-config → GenerateMapFiles

Dropbox Config/.json + .map

consume

ConfigMethods.GetConfig / EnsureCustomerVms / stages

installed machines

6.2.1.2. Stage 1 - Company: who the customer IS

Company come from netbase (osisa.Enterprise.Entities), built by fluent builder. Vocabulary is small and it is NOT a hierarchy - it is an assignment graph. Measured shape plus the survey of organisation standards live in Company model: what we have today, and which standards exist.

Verb Meaning

Company.CreateBuilder(name, country, city)

company identity. name also drive the AD domain / config name

AddDevice(name, DeviceTypes.X)

a thing: Server, RouterFirewall, Desktop, Laptop, AccessPoint, Software

.WithSerialNumber("…​")

THE match key. Hosted form <vmid>@<hostSerial> mean "VM on that host"

.WithDescription("…​")

sub-type discriminator for servers (see device table below). Also carry the MikroTik admin password

AddTask(name) + .WithDevice("Foo", DeviceTypes.Software)

a task BUNDLES software. Task is the organising principle, not the job title

AddOrganizationalUnit("NAME")

AD container AND the unit that get its own 10.X.0.0/24

AddEmployee(name) + .HasTask(…​) / .UsesDevice(…​)

what the person DO and what they HAVE

.Build()

freeze the graph

Two netsetup-side stashes exist because the netbase Company has no slot for them. Both are process-static, keyed by company name, and both are LIFTED into the config by the translator:

Call Lifted to

company.WithPublicIP("203.0.113.5") (post-Build())

config.DirectoryService.PublicIP - single source for NixDns, firewall port-forwards

builder.AddDomainProvider(…​)

config.DomainProvider + a DomainProvider user carrying the secret (ddns-updater on the Dns VM read both)

Note
process-static mean a test that build two companies with the same name see the same stash. CompanyDomainProviderExtensions.Clear() exist for exactly that.
6.2.1.3. Stage 2 - Translator ctor: seed the config

WindowsDCTranslator (abstract base) run BEFORE any device is looked at:

  1. Domain name = company.Name when it already valid, else NetbiosNameProvider.TranslateToDomainName(company.Name).

  2. NETSetupConfig.CreateBuilder(domainName).AddDirectoryService(domainName) - config name AND directory service key both derive from that one name.

  3. Three service users seeded, all carrying NetsetupCredentials.DefaultAdminPassword:

    Username Role Used for

    admin

    ClientAdministrator

    local admin on every Windows client

    root

    ServerAdministrator

    Proxmox root; every Nix VM admin inherit its password + email

    osisaadmin

    DomainAdministrator

    AD domain admin, DC join

  4. Locale + timezone from country: CH / schweiz / switzerland → de-CH + W. Europe Standard Time, else en-US + UTC.

Everything after this point only ADD to that seed. Config stay a public property on the translator, which is what let the customer .cs patch it afterwards.

6.2.1.4. Stage 3 - Translate(): seven steps, in this order

LiberatorWithWinDCTranslator.Translate():

# Step What it does

1

AssignOuSubnets()

every OU get 10.X.0.0/24, X = enumeration order 1..254. ouSubnetIndexOverrides force X for a named OU; the auto counter skip claimed X. X outside 1..254 → throw

2

new NetworkPlan(Config.OuSubnets)

per-OU IP allocator. No OU at all → throw

2b

Company.GetSoftwareCatalog()

whole SoftwareCatalog.Production list copied into config.SoftwareList (name, choco package, version, description)

3

DeviceTranslator per company device

device → typed machine entity, IP popped from the plan

3b

public IP lift

CompanyPublicIpExtensions.TryGetPublicIp → config.DirectoryService.PublicIP

3c

LiftDomainProviderIfPresent()

config.DomainProvider + the DomainProvider user

4

InjectImplicitLiberatorStack(plan)

add the 5 canonical Liberator VMs (table below)

5

EmployeeTranslator per employee

user account, device association, task → software rollup

6

derive config fields

config.Subnet = default OU third octet, config.DhcpIP = default OU gateway

7

SeedWifiUserIfMikrotikPresent()

only when a MikroTik AccessPoint device exist: user wifi, PSK = directory key + current year

Order matter. Devices before employees, because EmployeeTranslator.EnsureAssociatedComputer expect the machine to exist already (and translate the device on the fly when the customer forgot the AddDevice). Stack injection before employees, so a name collision fail before any user wiring start.

6.2.1.4.1. IP plan

Per OU one /24, three fixed tiers (NetworkPlan + IpAllocator):

Tier Range Popped by

server

.200 .. .240

DC, Proxmox / Liberator hosts, Nix VMs, firewall. 41 addresses; exhaust → throw

client / DHCP

.1 .. .199

Windows desktops + laptops

gateway

.254

router / OPNsense. Also become config.DhcpIP

Devices with no OU binding (servers, routers, stack VMs) allocate from the DEFAULT OU = first OU registered. AllocateServerIpAt(lastOctet) pin a specific octet (the stack VMs use it) and remove that octet from BOTH free lists, so nothing else can take it.

6.2.1.4.2. Device → machine mapping

DeviceTranslator dispatch on DeviceTypes, and for servers on the Description string:

DeviceType Description Entity created IP tier

Desktop / Laptop / Client / Default

-

WindowsComputer (Windows 11) + admin user

client

Server

empty, Liberator, Proxmox, Power Station

Server (OS Proxmox, port 8006) + root

server

Server

Windows Server

NETSetupDC (WindowsServer2019) + domain admin + DSRM restore password

server

Server

Windows

WindowsComputer

server

Server

Nextcloud / Mail / Dns / PMG

Server / NETSetupDnsServer with the matching Nix OS marker + nextadmin / mailadmin / dnsadmin / pmgadmin

server

Server

OPNSense / Firewall

NETSetupFirewall

server

Server

anything else

NotImplementedException

-

Router

-

Router

gateway

RouterFirewall

-

Router with public IP = its own IP

gateway

AccessPoint

(Description = admin password)

NETSetupMikrotik, FIXED identity: name Mikrotik, serial mt, IP 10.0.0.1

fixed

Printer / PrintScanner

-

NotImplementedException

-

The MikroTik identity is deliberately constant so flash-mikrotik and the converge helpers find a factory-reset box without per-customer name or serial drift.

6.2.1.4.3. Implicit Liberator stack

The customer declare ONE device with WithDescription("Liberator"). Zero or two → throw. The translator then add these five VMs itself - do NOT AddDevice them, a name collision throw with a "strip the explicit AddDevice block" message:

Name VMID Last octet OS Admin user

OPNSense

1001

.254 (gateway slot)

OPNSense

root

Dns

1002

.2

NixDns

dnsadmin

Nextcloud

1020

.20

NixNextcloud

nextadmin

Mail

1090

.90

NixMail

mailadmin

PMG

1091

.91

PmgLxc

pmgadmin

Load-bearing details:

  • Serial = <vmid>@<liberatorSerial>. That hosted form is what pin the VM to its host and what the map-file key is built from.

  • VMIDs sit in the 10xx range because Proxmox reject anything below 100.

  • Every stack admin reuse the seeded ServerAdministrator password + email; only the username differ. Config-driven credentials: a template read that user, never a literal.

  • OPNsense own DHCP for every OU, so each OU client range is added to its Ranges.

  • The Mail VM name seed config.MailHostname when the customer did not set one.

6.2.1.4.4. Employee → user + software

EmployeeTranslator, per employee:

  1. Username = employee name trimmed, lowercased, spaces → dots (Alice Wasserfallen → alice.wasserfallen).

  2. Email = <username>@<PublicUri authority>, or @local when no public URI is set.

  3. Role CommonRoles.Default - the MARKER that separate real employees from service accounts. EnsureNextcloudUsers and the Windows stage filter on it to pick exactly the employee set.

  4. For each UsesDevice: ensure the computer exist (translate the device on the fly if needed), then add the user to that computer.

  5. Software rollup: for each HasTask, walk the task and its subtasks, collect the DeviceTypes.Software entries, match them against config.SoftwareList by NAME (case-insensitive, NOT by choco package name), add the hits to every computer that employee use. A task the employee has but the company never declared → throw.

Warning
the employee password is currently the hardcoded literal SecretString.From("Coca2026.") in EmployeeTranslator (with a TODO to switch to PasswordHelper.GenerateSecureRandomPassword). Every customer JSON therefore carry the same employee password. Fix belong in the translator, not in a template or a stage.
6.2.1.5. Stage 4 - customer overrides

translator.Config is public, so the tail of the customer .cs patch what a generic translation cannot know: the real osisaadmin password, PublicUri, GatewayIP (phase 1 = upstream port), real BIOS serials, pinned IPs, MoveVm(…​) to re-pin an implicit VM onto another host, mail / dns provider options. Customers/README.md carry ten worked examples.

Rule: an override SET a config value. It never introduce a literal that downstream code then re-invents. Value missing? Add it here or in the translator, and let the templates read it.

6.2.1.6. Stage 5 - JSON out

JsonSerializer.Serialize(config, NETSetupConfig.JsonSerializerOptions). Those options add the netbase system-management + windows converters and register the netsetup-derived machine types (NETSetupDC, NETSetupFirewall, NETSetupDnsServer, NETSetupMikrotik) as Machine subtypes. Without that registration a firewall or DC deserialise back as a plain machine and every GetSingle<IRouter>() style lookup miss it.

Each customer app write the JSON twice, same bytes: <Name>.json and #<ticket>.json. Both are committed. NETSetup.Tests link Customers/*.json as content and load them through CustomerConfig.LoadStaudt() / .Load("<name>") - so a stale JSON mean the tests assert against a config that no longer match the .cs. Re-run the app after every edit.

Regenerate
dotnet run Customers/Staudt_AG.cs      # one customer -> print JSON + write both files
Customers\_rebuild-all.cmd             # all customers
6.2.1.7. Stage 6 - who read the JSON
  • netsetup publish-config deserialise the JSON, stage it, and call GenerateMapFiles(stagingOp, "<ticket>"). One .map per computer, named <mapKey><ticket>.map, where mapKey = <hostedId>@<serial> for a hosted serial else the raw serial. Content = "<machineName>"><mapKey>:#<ticket>><operatingSystem>.

  • A booting guest compute its OWN serial and look for the single matching map file (MapFileMethods.CheckForSingleMatchingMapFile) - that is how a bare machine learn WHICH config and WHICH hostname it is.

  • ConfigMethods.GetConfig / ReadAndParseConfig deserialise the config on the target machine, offline from the ISO or online from the config repos (ConfigRepo1..3).

  • Everything downstream read the config only: EnsureCustomerVms (see Proxmox VM provisioning during update), NixTemplateFactory, the OPNsense / MikroTik desired-state builders, unattend XML, Proxmox answer TOML.

6.2.1.8. Rules that bite
  • Exactly ONE WithDescription("Liberator") device per Liberator customer. Zero or many → throw.

  • Do NOT AddDevice the OPNSense / Dns / Nextcloud / Mail / PMG VMs - the translator inject them.

  • At least one AddOrganizationalUnit, else NetworkPlan throw.

  • Server tier is 41 addresses per OU, client tier 199. Bigger customer → more OUs, not a wider range.

  • Software match by NAME against SoftwareCatalog.Production. A task naming software that is not in the catalog install NOTHING and say nothing - add the catalog entry (see the update-software-catalog skill).

  • A HasTask the company never declared → throw, by design.

  • Serial numbers are the identity of everything. Placeholder serials ("a", "0") are fine while planning, but a machine only ever match its config through the real one.

  • Config value belong in the config. No template, translator or stage may invent a literal for something the config could carry - see Secrets & credentials.

The config is not only an input file: it is also readable AS A DATABASE, which is what the next section is about - see Netconfig as a database: installation IS synchronisation.

6.2.2. Netconfig as a database: installation IS synchronisation

The logical model NETSetup is built on, in one sentence: a company is translated into a desired target infrastructure, and installing that infrastructure is a database synchronisation between two stores of the same shape.

company (people, tasks, devices)
      |  translate                       <- see <<company-to-config>>
      v
NETSetupConfig  ==  SOURCE data store    <- netconfig, backing store = the customer JSON
      |
      |  read records / insert + update into the target / commit
      v
Hyper-V scope | Proxmox node  ==  TARGET data store

Three stores, ONE contract. A virtual machine is a record; an environment is a data store; a change is buffered until commit:

Store Backing store Reference type Registered by

netconfig (source)

the translated customer JSON

NETSetup.DataStore.NETConfig

NETConfig.DBBuilder

Hyper-V (target)

a WMI management scope

HyperVVmDataStoreReference5

its own Builder

Proxmox (target)

a Proxmox node

ProxmoxVmDataStoreReference5

its own Builder

All three serve the SAME IVirtualizationUnitOfWork, hand out the same IVirtualMachineRepository, and yield the same VirtualMachineSettings rows. Nothing above that seam has to know which kind of thing it is talking to - that is the whole point, and it is what make the sentence "installation is a synchronisation" true rather than a metaphor.

6.2.2.1. Why a database, and not "a config plus an installer"

A config plus an installer is a one-way, write-only story: code read fields off a config object and call provisioning routines. Every question a database answer for free has to be hand written - which machines differ, what would change, what changed, is it idempotent, did it fail per machine or per batch.

Model both sides as data stores and those answers come from the repository instead:

Database operation What it MEAN in NETSetup

ReadAll() on the netconfig store

the desired machines of the customer

ReadAll() on a hypervisor store

the machines that actually exist right now

Insert + Commit

create the missing VM

Update + Commit

stop → change → restart the VM whose sizing drifted (restart ONLY if it was running)

Delete + Commit

MARK a VM the desired state no longer declare - stop it and rename it #<name>. Nothing is destroyed

IPermanentDeletions<T>.ExecutePermanentDeletionsAsync()

destroy what was marked, disks included. The only irreversible verb, and a commit can never reach it

IsDirty

there is drift buffered but not yet applied

LastCommit

the proof of what was done: every operation in order, counts, and per-record failures

A reconciliation is then the ordinary set difference between two ReadAll() results, and "idempotent" stop being a property somebody has to remember to implement: a commit with nothing buffered do nothing, and an update whose applicable fields already match records nothing (VirtualMachineSettings.DescribesSameMachineAs).

6.2.2.2. The netconfig store

NETSetup.DataStore.NETConfig is a V5 data store reference over ONE NETSetupConfig. Where a normal source database would sit on sqlite, this one sit on the customer JSON the translator produced.

Type Role

NETConfig

the reference. Derive VmDataStoreReferenceBase<TSelf>, so repository, unit of work and commit semantics are inherited, not re-implemented

NETConfig.DBBuilder

the builder DataStoreReferenceProvider3.Builder.AddReference3<T> demand: one IKeyedServiceProvider ctor parameter, IBuilder<ICanGetAnyUnitOfWork>

NETConfigVmOperations

the six primitive operations against the config: read, insert, stop, change, start, delete

NETConfigHardware

desired cores / memory / disk as an IHardware, so a config machine can carry sizing the way a scanned machine does

Registration is one call, same as any other reference:

var provider = new DataStoreReferenceProvider3.Builder()
    .AddReference3<NETConfig.DBBuilder>(b => b.WithConfig(config))
    .Build();

var reference = provider.GetReference(DataStoreName.From(config.ConfigName.Value));
using var dataStore = reference.OpenDataStore3<IVirtualizationUnitOfWork>();
var unitOfWork = dataStore.GetUnitOfWork();

foreach (var machine in unitOfWork.VirtualMachines.ReadAll())
{
    // machine.Name, machine.OperatingSystem, machine.Key, machine.VmId, ...
}

The store is keyed by config.ConfigName unless WithDataStoreName say otherwise - the customer name is already that config’s identity everywhere else, and a second name would only be a way for the two to disagree.

6.2.2.2.1. How a config machine project onto a record
Record field Comes from

Key

VirtualMachineKey.FromSerial(machine.SerialNumber) - serial IS the identity in NETSetup: it is what a booting guest match its map file on. Only a machine with no usable serial fall back to the environment-qualified name

Name

machine.MachineName

OperatingSystem

machine.OperatingSystem - the config is the ONLY side that knows this; a hypervisor cannot report it

VmId

the numeric part of a hosted serial <vmid>@<host>, else VmId.None

Memory / Cores / Disk

machine.Hardware when the config carry one, else "not specified"

PowerState

always Unknown

Three asymmetries are deliberate, and each one is a lie avoided:

  • PowerState is always Unknown. A desired state does not know whether a machine is running. Reporting Running would make every target look drifted; reporting Stopped would invite a pointless start.

  • StopAsync / StartAsync are no-ops. A config declare machines, it never run them.

  • ChangeAsync refuse to overwrite a known OS with OS.Unknown. A record read back from a hypervisor carry no OS, and writing that into the config would destroy the one place that knows what the machine is supposed to run.

Only Computer machines project. A config also carry printers and other machine kinds; putting those in the source set would mean rows no target could ever satisfy.

6.2.2.3. Deletion is two steps

A delete that destroy immediately is unrecoverable, and the record that would tell you WHAT was destroyed die with it. So the two halves are separated, and only the second one is irreversible:

Step What happen

Delete + Commit

soft delete: stop the guest, rename it to #<name>. Guest and disks survive, it is visible in every hypervisor UI, and a rename back undo it

ExecutePermanentDeletionsAsync()

real delete: destroy every marked machine. Its disks go with it

The contract live in NETBASE, not here. IPermanentDeletions<TEntity> (osisa.Repository.Contracts.Repositories, derive IRepositoryBase<TEntity>, two member - ReadSoftDeleted() + ExecutePermanentDeletionsAsync(ct)) MOVED out of osisa.Virtualization. A two-step deletion is a REPOSITORY capability, not a hypervisor quirk: any store that can mark a row instead of destroying it want the same two verbs, and a netbase test base can only gate on a netbase interface. NETSetup consumer only repoint the using; the semantics above are unchanged.

Two things the move cost, both worth knowing before touching the file:

  • Doc crefs must be FULLY QUALIFIED. osisa.Repository.Contracts export a MachineName of its own, so a bare <see cref="MachineName"/> in osisa.Virtualization now resolve ambiguously.

  • The interface is deliberately INVARIANT. Covariance (out TEntity) would read fine today and foreclose a future PermanentlyDeleteAsync(TEntity) - a single-row destroy - forever.

Why the marker live in the NAME: it survive a restart, a reconnect and a different process, it is visible without any tooling, and it need no side table - which a marker held anywhere else would. Marking an already marked machine change nothing, so a synchronisation run twice never produce ##name.

Why a commit must not destroy: a synchronisation against a half-reachable environment would otherwise destroy the machines it merely failed to see. Marking is safe to do unattended; destroying is an act somebody perform on purpose.

Drive files. A soft-deleted machine KEEP its disks - that is what make it recoverable, and it is also what still occupy the storage. Reclaiming that storage is exactly what ExecuteDeletions is for: the hard delete take the disks with it (Hyper-V RemoveVirtualMachineAsync shut the guest down, delete its VHDX and destroy the machine). Anything worth keeping must be copied off BEFORE that call. A retention rule - how long a # machine may sit there before its storage is reclaimed - is a policy decision that does not exist yet.

Per provider:

Store CanSoftDelete Note

netconfig

yes

rename the entry in the config (remove + re-add, the name IS the collection key)

Hyper-V

yes

stop, then rename through the netbase RenameVirtualMachine

Proxmox

yes

stop, then rename through the netbase guest config write

The marker has TWO spellings, and both are recognised everywhere:

Form Written by Why

#name

Hyper-V, netconfig

the osisa convention - a leading # mean "ignored"

x-deleted-name

Proxmox

Proxmox validate a guest name as a DNS NAME and refuse # outright: name : invalid format - value does not look like a valid DNS name. Proven on the bench node

A marker only one side can write would leave marked guests invisible to the reader, so SoftDeleteMarker.IsMarked and .Unmark accept both, and only .Mark / .MarkDnsSafe differ.

Note
the netbase RenameVirtualMachine extension used to pass a ComputerSystem parameter the v2 Msvm_VirtualSystemManagementService does not declare, so every rename died with a bare Not found ManagementException - which is why this code carried its own copy of the call. That is fixed at the source (netbase 0.19.0-ModelFeature.301), and the copy is gone.
6.2.2.4. What the synchroniser select on

VirtualMachineSettings.IsVirtualMachine. True by default - a row read from a hypervisor IS a virtual machine - and CALCULATED on the source side: a config machine is a virtual machine exactly when its serial is hosted, <vmid>@<hostSerial>.

That is not cosmetic. A NETSetupConfig hold physical machines too - domain controller, clients, MikroTik, printers - and no hypervisor can create any of them. Feeding them to a synchroniser would produce rows no target could ever satisfy, and every cycle would fail on the same machines. Of the 11 machines in ZH-Test, 5 are virtual (the implicit Liberator stack) and 6 are physical.

6.2.2.4.1. Scope: one host, one slice

A synchronisation run on ONE host compare that host against ITS OWN SLICE of the config, never against the whole config. The slice is decided by the same hosted serial: <vmid>@<hostSerial> name the host a machine live on.

var mine = unitOfWork.VirtualMachines.ReadAll().VirtualMachinesOn(host.SerialNumber);

Compare a host against the FULL config instead and every guest belonging to another host look MISSING, so the run would try to create foreign machines on the wrong node - every cycle, forever, because they will never appear there. ZH-Test make the point: the five Liberator-stack guests are @lib, so a run on Power-1 get an EMPTY slice rather than five machines to wrongly create.

NETConfigHostSlice.VirtualMachinesOn apply both filters at once - the host part AND IsVirtualMachine - so a physical box can never enter a slice either.

6.2.2.5. Decisions
Question Decision

May a sync delete?

Yes, but only as a soft delete. The real deletion is a separate, explicit act

Where does the synchroniser live?

In update. netsetup update IS the synchronisation - there is deliberately NO separate netsetup sync verb, because two entry points would drift apart

What selects the rows?

IsVirtualMachine, AND the host slice - see above

What does one run compare?

One host against the config rows hosted on THAT host (<vmid>@<hostSerial>), never the whole config

Where does VM sizing come from?

Config Hardware when present, templates otherwise. Filling the config with real HW details is planned, not done

When is a machine "in sync"?

When the VM EXISTS. Whether it run is a RUNTIME property, not a configuration one - which is why the config never report a power state

Who wins on drift?

The netconfig. It is the desired state; the hypervisor is the observed one

6.2.2.6. Proof

NETSetup.Tests.DataStoreTests.NETConfigDataStoreTests - no hypervisor touched, no environment gate, runs anywhere:

Test Proves

AddReference3_ShouldRegisterTheConfigAsADataStore

a translated config register as a data store reference under its own name

ReadAll_ShouldIterateEveryMachineOfTheConfig

every machine of the config come back as a record, by name

GetRepository_ShouldReturnTheSameInstanceAsVirtualMachines

the config serve the same unit-of-work contract as a hypervisor store

ReadAll_ShouldNotMakeTheUnitOfWorkDirty

reading is not writing

ReadAll_ShouldKeyRecordsBySerialAndCarryTheHostedVmId

the record identity is the NETSetup serial, and a hosted serial surface its vm id

ReadAll_ShouldFlagOnlyHostedMachinesAsVirtualMachines

only hosted machines are flagged as virtual machines - the physical boxes are not

TranslatedCompany_ThatNeedsAnIpfsNode_ShouldNotDeclareOneYet

a company that NEED an IPFS node translate without one - the gap the insert close

Insert_NoCommit_ShouldChangeNothing

an uncommitted insert leave the config untouched

Insert_Commit_ShouldAddTheIpfsNodeTheCompanyNeeds

the node land in the config, hosted on the Liberator, and inside that host slice

Insert_Commit_Twice_ShouldFailRatherThanDuplicate

a name that already exist is refused, never silently duplicated

Delete_Commit_ShouldSoftDelete_AndExecuteDeletions_ShouldRemoveIt

both halves of a deletion, in order: commit MARK, ExecuteDeletions remove

Delete_Commit_Twice_ShouldMarkOnlyOnce

a second delete of a marked machine do not produce ##name

VirtualMachinesOn_ShouldSelectOnlyTheMachinesOfThatHost

a host see only its own guests

VirtualMachinesOn_ForAHostWithoutGuests_ShouldSelectNothing

a host without guests get an empty slice, not the other host’s machines

NETConfigDataStoreTests2 put the same store through the NETBASE base UnitOfWorkRepositoryTestsBase3<VirtualMachineSettings, IVirtualMachineRepository, IVirtualizationUnitOfWork>, so the netconfig is held to the generic osisa repository contract rather than only to hand-written claims. Three of its cases are OVERRIDDEN, and each override is a real divergence worth knowing:

Case Why it differ

Insert_NoCommit

the base expect a pending insert to be visible to the unit of work that buffered it. This repository never let a read see pending changes - a read go straight to the environment

Delete_Commit

the base expect one row LESS. Here a commit only MARK; the count drop when the deletion is EXECUTED

Update_Commit / Update_NoCommit

the base MUTATE the entity in place. VirtualMachineSettings is immutable, so the update is a changed COPY (with)

The same base run against the REAL environments, wiring only, in NETSetup.Tests.DataStoreTests:

Suite Environment

HyperVVirtualMachineRepositoryCrudTests2

the local Hyper-V host

ProxmoxVirtualMachineRepositoryCrudTests2

the bench Proxmox node

Neither suite override a case. They contribute setup and assertion hooks only - which machines are theirs, what a created row look like, what a read-back guest must carry - and every case is the netbase one, unchanged. Both environment can now apply a field change and mark a guest as deleted, so neither suite declare an exception any more.

Both suites moved from the v3 base to UnitOfWorkRepositoryTestsBase4 (through the shared VirtualMachineRepositoryCrudTestBase2). v4 is v3 plus SIX soft-delete cases - 8 → 14 cases per provider suite - and they only run when the repository under test implement the netbase IPermanentDeletions<TEntity>: the base gate on RepositorySupportsPermanentDeletions, the same way it already gate insert / update / delete on their capability interface. A store without the capability simply run the v3 set.

Only these two suites moved. NETConfigDataStoreTests2 above STAY on v3 on purpose: the netconfig store is JSON-backed, its "soft delete" is a renamed entry in a config file, and the six cases are about MARKING A REAL GUEST and then destroying it. Running them against a config file would prove nothing about the thing that can actually be lost.

Two of the v4 hooks are virtual for exactly this repo’s situation: ReadSoftDeletedCount() and ExecutePermanentDeletions(). A live hypervisor is a SHARED store - other people’s guests live there, some of them already marked - so a base that count every soft-deleted row, or destroy every one it find, would fail on a foreign machine or destroy it. VirtualMachineRepositoryCrudTestBase2 override both and filter them through IsOwn, so the count see only the suite’s own NETSetupCrudTest* guests and the destroy route through the existing ExecuteOwnDeletions guard - which go Assert.Inconclusive rather than destroy a machine the suite did not create. An inconclusive run is a cheap thing to fix; a destroyed foreign guest is not. That is the same rule as the delete/update victim selector below, applied to the second half of the deletion.

Both are destructive and [TestCategory("local")]. Every machine they touch is one they created, named NETSetupCrudTest*: the netbase base pick its delete and update victim with entities.Last(), which on a live host would be a real machine, so both selectors are overridden to the own set and the destroy step refuse to run while a foreign soft-deleted guest is present.

The guests each suite create are shaped per provider, and the shape is a virtual property rather than a constant:

Suite Shape

Proxmox

the NixOS IPFS node - NixIPFS, 8 GiB, 2 cores, 64 GiB, nixos.iso on local. That configuration is already PROVEN on this node by ProxmoxIpfsNodeInsertConnectTests, and a suite about the repository contract should not also be the first thing to try an invented spec: a create that failed for a reason of its own would read as a repository failure

Hyper-V

a Windows 11 shaped row, 64 GB, and it BOOTS. The OS is what the provider read to pick the firmware (see Hyper-V firmware is decided by the OS, and set AT CREATION), so a suite that declared no OS would only ever prove the Linux half - and a guest that never boot would never exercise the firmware it was given. The old shape was the smallest thing that can exist (512 MiB, 1 core, 4 GiB, never booted)

The Hyper-V suite is GATED on its boot image: no image on this box, no run (Assert.Inconclusive), because the netbase VirtualMachine ctor refuse a guest with no ISO - a missing fixture would otherwise read as a repository failure. Set NETSETUP_KEEP_CRUD_GUESTS to keep the created guests after the run for inspection; unset (the default) they are destroyed, which is what keep the host at the same guest count it started with.

Another shape is one override per provider, added when it is known to work on that environment.

Cost: an on-purpose run, never something to hang off every build - Hyper-V pay for a real VHDX per guest and a slow WMI destroy. No suite time is quoted here on purpose. The Hyper-V guests are now Windows 11 shaped, 64 GB and BOOTING, and the suite grew from 8 to 14 cases, so it is materially slower than any figure measured on the old Minimal shape. MEASURE BEFORE QUOTING. Both suites still leave the host exactly as they found it - every guest they create, they destroy.

Warning
the live run also found a real DEFECT, which is the reason the insert case now assert more than a row count. The Hyper-V projection reported 4 GiB / 4 cores / 64 GiB for every guest on the host, whatever the guest actually had: the netbase read-back constructor VirtualMachine(ComputerSystem) hardcode RandomAccessModule.Default, Processor.Default and DataSize.Default, and HyperVVmOperations.Project trusted them. A guest created with 512 MiB / 1 core / 4 GiB read back as the defaults. Nothing NOTICED, because reading a VM was only ever asserted on names and counts - and a record that lie about sizing make every drift comparison meaningless, which is precisely what a synchronisation is built on. Project now read the guest own Msvm_MemorySettingData, Msvm_ProcessorSettingData and (through the image management service) the virtual size of its vhdx.

Proxmox turned out to carry the SAME class of defect, one field wide: netbase VMHardware(VmConfigQemu) construct RandomAccessModules as an empty list and never fill it, so every Proxmox guest read back as 0 B of memory - disks and processors are mapped, memory is not. ProxmoxVmOperations.Project now read Configuration.Memory, which Proxmox keep in mebibytes.

Both defects were invisible to the fake environment and to every count-based assertion. They only appeared once a test asked a real hypervisor what it had ACTUALLY created - which is the whole argument for running the contract against live providers.

Three constraints only a LIVE run could have found, all now encoded in the wiring:

  • Hyper-V refuse a guest with no boot image - the netbase VirtualMachine constructor run Precondition.NotNullOrWhiteSpace(iso) - so the test guests carry nixos.iso, attached, never booted.

  • Proxmox answer 500 InternalServerError with an EMPTY message when the cdrom line is empty, and the same messageless 500 when the vmid already exist. The wiring therefore name image plus storage and PICK a free vmid rather than compute one.

  • Hyper-V reported CanApplyChanges false as well - it was assumed to be a Proxmox-only gap. Both are wired now: Hyper-V through the netbase ApplyHardware (one ModifyResourceSettings call carrying memory and cores), Proxmox through the netbase guest config write.

6.2.2.6.1. Hyper-V firmware is decided by the OS, and set AT CREATION

HyperVVmOperations no longer create every guest with one firmware shape. The row own OperatingSystem decide it, and it is passed into the netbase VirtualMachine constructor as the VirtualBIOS argument - so the guest is BORN with it:

Row OS Secure Boot vTPM Why

Windows family (OperatingSystemFamily.Windows)

ON

ON

Windows 11 demand both. A guest created without them is not a Windows 11 the installer will accept

anything else (NixOS, OPNsense, Linux, unknown)

OFF

OFF

an unsigned ISO is REJECTED by the Microsoft UEFI CA, so a NixOS guest with Secure Boot on never boot at all

The family test is the new OperatingSystemExtensions.Is(this OperatingSystem, OperatingSystemFamily) in osisa.Virtualization/Contracts - one place that answer "is this row a Windows row", instead of a string match per call site.

Warning
a vTPM must be enabled BEFORE the guest exist, never after. Measured on the bench this session: the vTPM seal guest state to itself, so switching it on for a guest that is already installed seal state that can no longer be decrypted, and the guest then refuse to start with 0xC000A002 (STATUS_AUTH_TAG_MISMATCH). There is no "turn it on later" path - the only recovery is a new guest. That is the whole reason the firmware moved into the CREATE call instead of being an Update a synchronisation could apply.

The same contract is proven against the fakes in osisa.Virtualization.Tests (Delete_Commit_ShouldSoftDeleteTheMachine, ExecuteDeletions_ShouldDestroyTheSoftDeletedMachine, Delete_Commit_WhenEnvironmentCannotSoftDelete_ShouldDestroyTheMachine, ReadSoftDeleted_…​) and against a LIVE Hyper-V host in NETSetup.Tests.DataStoreTests.HyperVIpfsNodeDeleteTests.Delete_Commit_ShouldSoftDelete_AndExecuteDeletions_ShouldDestroyTheMachine, which mark a real guest, verify the rename through the native API, then destroy it. That live test REFUSE to execute deletions when the host carry any soft-deleted guest that is not its own - the irreversible verb must never touch a machine somebody else marked.

Walking the ZH-Test config yield its 11 machines, implicit Liberator stack included:

Liberator      [Proxmox]             key=lib        vmid=0
Power-1        [Proxmox]             key=power-1    vmid=0
Mikrotik       [RouterOS]            key=mt         vmid=0
DC             [Windows Server 2019] key=dc         vmid=0
DefaultClient1 [Windows 11]          key=client1    vmid=0
DefaultClient2 [Windows 11]          key=client2    vmid=0
OPNSense       [OPNSense]            key=1001@lib   vmid=1001
Dns            [NixDns]              key=1002@lib   vmid=1002
Nextcloud      [NixNextcloud]        key=1020@lib   vmid=1020
Mail           [NixMail]             key=1090@lib   vmid=1090
PMG            [PmgLxc]              key=1091@lib   vmid=1091
6.2.2.7. Inserting what the company model cannot express

NETSetup.Tests.TestInfrastructure.TestCompanyConfigBuilder build a test-only customer the normal way - a fluent company builder (devices, a Distributed Storage task, an employee who has it) that run the SAME LiberatorWithWinDCTranslator a customer app run. The company NEED an IPFS node, and the translation produce none: DeviceTranslator dispatch servers on the Description string and has no IPFS branch, so the config come out with the usual Liberator stack and nothing else.

Treating the config as a database close that gap without touching the translator:

var record = VirtualMachineBuilder.Create()
    .WithKey(VirtualMachineKey.FromSerial(SerialNumber.From($"{IpfsVmId}@{LiberatorSerial}")))
    .WithName(MachineName.From("Ipfs"))
    .WithOperatingSystem(NetsetupOS.NixIPFS)
    .WithVmId(VmId.From(IpfsVmId))
    .WithMemory(DataSize.Parse("8 G")).WithCores(4).WithDisk(DataSize.Parse("64 G"))
    .Build();

unitOfWork.VirtualMachines.Insert(record);
unitOfWork.Commit();

The inserted row is a first-class machine afterwards: read back through the repository, carried by config.DirectoryService.Machines with the hosted serial 1030@ipfslib, flagged IsVirtualMachine, and selected by VirtualMachinesOn(liberatorSerial) - so the host that will have to create it already see it.

The KEY is what make that work. A netconfig machine IS its serial, and VirtualMachineKey.FromSerial reassemble the hosted form, so keying the row pin it to a host. A keyless row would land as a machine hosted nowhere.

Note
this is the additive path, not a licence to bypass the translator. A node a REAL customer need belong in the company model and the translator - the insert is for what the model cannot express yet, and for what a synchronisation discover at runtime.
6.2.2.8. Status and open points

The source store exist and is proven; the provisioning path is untouched - EnsureCustomerVms and the stages still read the config directly. What is not wired yet:

  • No sync driver. Reading source + target and turning the difference into buffered insert / update / soft-delete is the next step. It belong INSIDE update, in one place, never as a second command.

  • No retention policy. Nothing decide when the storage of a marked machine is reclaimed.

  • Disk is create-only. VirtualMachineSettings.Disk is get-only, so with { Disk = …​ } does not compile and DescribesSameMachineAs ignore it. Neither provider can resize a live guest - growing a volume is a resize against a named disk plus partition work INSIDE the guest, shrinking one is refused outright - so the type say so rather than accepting a value and dropping it. When a provider grow the ability, this become an init property again and the comparison gain it back.

  • No IPFS branch in the translator. A company can express the NEED for an IPFS node (a task and its software) but not the node, so the row has to be inserted into the config. Same for any other role DeviceTranslator do not dispatch on.

  • Sizing come from the config only when a machine carry Hardware, which most customer machines do not - today the VM sizing decision still live in the templates.

  • A config machine carry more than a hypervisor row can hold (users, software list, IP, ports). The record is the intersection; the rest still travel the existing path.

  • Write-back direction (target → netconfig) is implemented but unused. It is what would let a discovered machine be recorded into the desired state.

6.2.3. Windows node capabilities: declare it, do not fix it by hand

A plain NETSetup install give a domain-joined Windows node with its software. It do NOT give WinRM, it do NOT give the WSL2 runtime, it do NOT put an AD group into a local group, and it drop the computer object in the domain default container. For a normal client that is fine. For a node that must serve as a deployment target it is not - devBuilder04 installed cleanly and was still unusable, and every one of those gaps had to be closed by hand afterwards.

NETSetupConfig.WindowsCapabilities close them in the config instead. Key = machine name, value = one block per machine:

Field Type What it do

EnableWinRm

bool

Service + HTTP listener + firewall (-Name, not -DisplayName) + LocalAccountTokenFilterPolicy. See enable-winrm.

DockerAutoStart

bool

Autologon + Docker Desktop startup chain, so the engine exist after a reboot with nobody logged on. Needs AutoLogonUser.

AutoLogonUser

string

Machine User that Windows log on at boot. Password read from THAT entity. Writes the password in clear text to the Winlogon key - lab/build nodes only.

InstallWsl

bool

DISM-first WSL2 feature staging + runtime. Reboot required. See install-wsl.

WslInstallerUrl

string?

URL of the WSL2 release MSI. REQUIRED in practice - see gotcha 5 below. Null falls back to wsl --install, which cannot work under SYSTEM.

DomainOuPath

string?

OU distinguished name the computer object is CREATED in at join time. Null = the domain default CN=Computers. Switch the join from the WMI path to Add-Computer -OUPath.

LocalGroupMembers

map

Local group → principals. Built-ins resolved by well-known SID. See add-group-member.

Every field is a primitive or a plain string collection ON PURPOSE, so the block round-trips through System.Text.Json. Machine.Hardware already prove what an interface-typed field cost: it is typed IHardware, System.Text.Json throw "Deserialization of interface or abstract types is not supported", and the config become unloadable for EVERY guest that read it. Nothing in WindowsCapabilities may become an interface.

Two entry points, ONE implementation (WindowsCapabilitiesApplier):

  • Stage3Windows apply the block during install, right after the domain join (a domain principal cannot join a local group while the machine is still in a workgroup).

  • NETSetup.exe apply-capabilities apply the same block to a node already up.

Each step is gated by its own log-file marker, the same idempotency mechanism the rest of Stage 3 use, so the reboot the WSL feature flip need re-enter and skip what already happened.

6.2.3.1. Sizing a node: RAM and disk come from the config

ProxmoxWindowsVmFactory read Machine.Hardware for BOTH the system disk and the RAM, and only fall back to its reference default (64 G / 4 G) when the config is silent. Existing customers are unaffected.

Sizing is applied IN MEMORY, never persisted - same IHardware reason as above:

config.SetMemory("devBuilder05", "16 GB");   // ConfigExtensions
config.SetDisk("devBuilder05", "250 GB");

Call them in the provisioning test / deploy path, on a config you are about to provision from - NEVER on one you serialize. Only the disk SIZE is taken from the config; the rest of the disk shape (bus, cache, discard) stay VMDisk.Default because it is Proxmox-specific.

Why the disk matter: devBuilder04 had 18.3 GB free of 63 GB against ~48 GB of Docker data measured on devBuilder02. That is not short by a rounding error, it is short by a factor, and no software config could fix it after the fact.

6.2.3.2. Rebuilding and shipping the shared reusable ISO

Every reusable node boot local:iso/NETSetup.iso, and the binary INSIDE it is the one that run the install. So a capability added in C# reach new nodes only after this ISO is rebuilt - until then every node need the same manual finish-up (WinRM, groups, WSL, Docker autostart) that devBuilder05 and devBuilder06 both needed.

Build it
# elevated. Images + drivers are CACHED in C:\NETSetupDEV and re-sync only on hash mismatch,
# so a rebuild is minutes, not hours.
dotnet run --project src\NETSetup\NETSetup.csproj -- create-iso --reusable --images "Windows 11" --drivers "Proxmox"

--images / --drivers MUST match what the current ISO carry, or nodes lose the Windows image they install from. Read them off the live ISO rather than guessing: mount -o loop,ro /var/lib/vz/template/iso/NETSetup.iso /mnt/x && ls /mnt/x/NETSetup/Images /mnt/x/NETSetup/Drivers. Config/ MUST come out EMPTY - that is what make it reusable.

Ship it, without ever letting a node boot a half-written file
  1. upload as NETSetup.iso.new, never straight over the live name

  2. sha256sum it on the target and compare against the source

  3. only then mv NETSetup.iso NETSetup.iso.bak-<date> and mv NETSetup.iso.new NETSetup.iso

Keep the .bak - it is the rollback. Swapping by mv is safe even while a VM has the ISO attached: the rename keep the inode, so a running guest go on reading the old image until it is powered off.

For the second host, do NOT upload twice. Serve from the first host and pull host-to-host - measured 2026-09-02: 11.9 GB in 90 seconds at ~120 MB/s between hosts, against ~20 minutes from the dev box.

Rebuilt 2026-09-02 (sha256 fc2b5132…​92bd87, 11'946'061'824 bytes) onto BOTH osisaLib03 and Power-1, carrying WindowsCapabilities + DockerAutoStart. Verify a shipped ISO by size: the embedded NETSetup/NETSetup.exe must match src/NETSetup/publish/NETSetup.exe byte for byte. strings on it prove nothing - it is a compressed single-file bundle.

6.2.3.3. Account passwords come from NETSetup.kdbx, never from a literal

Customer configs used to carry "changeme".ToSecretString() for every domain and client administrator. That is a bad failure shape: the literal is committed, it is WRONG, and NOTHING complain - the config generate happily and the damage only surface hours later as a domain join failing 1355 on an unattended node.

Now every one of them call CustomerSecrets.PasswordFor("<username>"), which read NETSETUP_PW_<username> from the environment and throw CustomerSecretNotFoundException when it is missing. Failing to generate is strictly safer than generating something that cannot work, and the exception name the account, the variable, the KeePass title and the command to fix it.

# add a secret once (title convention: NETSetup_PW_<username>)
osisa.KeePass.Cli add -d src\NETSetup.kdbx -m $env:NETSETUP_Masterpassword -t "NETSetup_PW_aoser" -s "<pw>"

# generate through the wrapper - it unlock the store, hydrate the env, run the generator,
# and DELETE the decrypted .env in a finally block
scripts\Generate-CustomerConfig.ps1 -Config Customers\OSISA_DevLab06.cs

Applied to #915, #9153, #9154, #9155. The other customer configs (Carrera, Saner, Schnell, Staudt) still carry literals - same treatment when someone touch them next.

6.2.3.4. Docker Desktop do NOT survive a reboot unless you make it

Symptom: node come back up, docker CLI answer, engine do not. docker version show a Client block and nothing else, or docker desktop status say "Could not retrieve status."

Cause: Docker Desktop is a per-USER desktop application, not a service. Measured on devBuilder05 - EVERY com.docker.backend / Docker Desktop process sit in the interactive session (SessionId 2, the RDP logon) and die with it. com.docker.service being Stopped/Manual is NORMAL and is not the engine; it is only the privileged helper. And settings-store.json ship AutoStart=False, so Docker do not even start when somebody DO log on.

There is no supported headless mode. The engine need a session, so NETSetup make one. Declare it:

DockerAutoStart = true,
AutoLogonUser   = clientAdmin.Username.ToString(),   // derived, never a literal

WindowsCapabilitiesApplier then read the password off that same User entity and DockerAutoStartHelper configure five things. Every one of them was needed - drop any and the node come back without an engine:

Piece Why

autologon

create the session at boot. Password come from the config’s User, never a literal.

lock task

rundll32 user32.dll,LockWorkStation 3 min after logon. Locking do NOT end the session, so the engine keep running behind the lock screen.

AutoStart=True in settings-store.json

it ship False. Seeded from a profile that already finished first run, so the accept-terms dialog do not block an unattended start.

logon task → Docker Desktop.exe

the deterministic launcher next to AutoStart.

one dummy credential

see below. Not optional.

6.2.3.4.1. Two traps that cost hours here

1. A UTF-8 BOM in settings-store.json kill the backend. Docker parse that file with Go’s encoding/json, which reject a BOM: backend crashed …​ invalid character 'i' looking for beginning of value. PowerShell’s Set-Content -Encoding UTF8 WRITE a BOM. Use [System.IO.File]::WriteAllText($p, $json, (New-Object System.Text.UTF8Encoding($false))).

2. An EMPTY Windows Credential Manager break docker pull. On a store with no entries at all, docker-credential-wincred / -desktop answer ERROR_NO_SUCH_LOGON_SESSION (1312) instead of "not found", the helper exit 1, and the CLI abort with error getting credentials - even for an ANONYMOUS pull. Editing credsStore out of config.json do NOT help; the CLI pick the native store anyway. Proven on devBuilder05: with an empty store docker pull hello-world fail, cmdkey /generic:…​ one dummy entry, and the SAME pull succeed. That is why a seed task run at every logon.

Known limit: a plain WinRM connection is a NETWORK logon and Windows refuse it the credential store ("credentials cannot be saved from this logon session"), so docker pull driven straight over WinRM still fail. Drive builds from the interactive session (scheduled task, or an agent running there), or authenticate the WinRM session with CredSSP. The ENGINE is reachable over WinRM either way - only the credential-helper path is not.

Verb: NETSetup.exe enable-docker-autostart --user <account> --password <pw> [--template-profile <p>] for a node already up. Prefer apply-capabilities, which read both off the config.

6.2.3.5. Lab nodes: #9153 (devBuilder03), #9154 (devBuilder05), #9155 (devBuilder06), #9156 (devRunner01), #9157 (devRunner02), #9158 (devRunner03), #9159 (devRunner04)

Three parallel lab configs, all derived from production #915, all on the real 192.168.3.x lab net (#915 pin DNS to a DC at 10.1.0.201 that do not answer here, so a #915 guest lose name resolution and fail the domain join with 1355). Do NOT edit #915 for lab runs - it drive the production site.

Config Node vmid Serial What it prove

#9153

devBuilder03

300

BC241100012C

Reusable-ISO node, 16 GB, lab addressing.

#9154

devBuilder05

305

BC2411000131

The same, plus 250 GB disk and a WindowsCapabilities block - the devBuilder04 gap, declared.

#9155

devBuilder06

306

BC2411000132

Identical declaration to #9154, on a SECOND Proxmox host (Power-1, 192.168.3.217). Prove the node shape is host-independent.

#9156

devRunner01

307

BC2411000133

Identical declaration to #9155 and the SECOND node on Power-1. Prove the vmid-derived identity stay distinct when one host carry two nodes.

#9157

devRunner02

308

BC2411000134

Identical declaration to #9156, back on osisaLib03 (192.168.3.233) so the lab carry a runner on EACH host. 16 GB (osisaLib03 has 31 GB physical and other guests), 250 GB disk, address .80.

#9158

devRunner03

-

HVDEVRUNNER03

Local Hyper-V on maskeLT1, NOT Proxmox. Plain BIOS serial, no vmid. Set up through the DataStore V5 insert path (HyperVDevRunner03ReinstallTests).

#9159

devRunner04

309

BC2411000135

Generator Customers/OSISA_DevLab10.cs (copy of OSISA_DevLab09). Back on Power-1 (192.168.3.217), 16 GB, 250 GB, 8 cores, reusable ISO, MAC BC:24:11:00:01:35, address .82 (ping + ARP checked free 2026-09-25). Published with publish-config. Set up through the Proxmox V5 insert path. devRunner03 deliberately NOT declared in #9159 - its plain BIOS serial HVDEVRUNNER03 would get a competing map file.

Serials are derived as OUI + vmid, and nothing else - see ProxmoxNodeIdentity.DeriveSerialNumber. The host is NOT part of it, so two nodes sharing a vmid on two different Proxmox hosts compute the SAME serial and fight over one map file. Allocate lab vmids GLOBALLY, not per host.

Each is driven by a Prod-category test: ProxmoxDevBuilder03ReinstallTests, ProxmoxDevBuilder05ReinstallTests, ProxmoxDevBuilder06ReinstallTests, ProxmoxDevRunner01ReinstallTests, ProxmoxDevRunner02ReinstallTests, HyperVDevRunner03ReinstallTests, ProxmoxDevRunner04ReinstallTests. They are [TestCategory("Prod")], so the default local.runsettings filter (TestCategory!=Prod) hide them and dotnet test --filter alone match NOTHING - the CLI filter is ANDed with the runsettings one, not substituted for it. Run them with --settings src\prod.runsettings, which carry no filter. The 05/06 tests assert the DECLARATION before they spend three hours installing against it - a config that forgot a capability produce the same dead end devBuilder04 did.

devRunner02 carry a SECOND test beside its reinstall one: ProxmoxDevRunner02DataStoreTests set the same node up the V5 way - read the node as a RECORD out of the netconfig store (AddReference3<NETConfig.DBBuilder> → OpenDataStore3<IVirtualizationUnitOfWork>), then one Insert plus one CommitAsync against ProxmoxVmDataStoreReference5, then GetByKey to read it back. An existing node is REUSED, never replaced. Watch the vmid: a reusable-ISO node carry the MAC-derived serial (BC2411000134), NOT <vmid>@<host>, so the store report VmId.None and the vmid is recovered with ProxmoxNodeIdentity.TryGetVmIdFromSerial - which only accept the UPPERCASE hex shape. A config declare RAM and disk but never a core count, and VirtualMachineBuilder.WithCores refuse a zero.

6.2.3.5.1. V5 insert path: devRunner03 (Hyper-V) + devRunner04 (Proxmox) - 2026-09-25

Both nodes set up with NO reinstall flow - read node as record from netconfig V5 store, ONE Insert into the host’s VM store, CommitAsync, then poll. Both green 2026-09-25.

devRunner04 on Power-1. ProxmoxDevRunner04ReinstallTests (src/NETSetup.Tests/VirtualTests/OnProxmox/ProxmoxDevRunner04ReinstallTests.cs) read devRunner04 out of #9159 through the netconfig V5 store, insert ONE row into the Power-1 ProxmoxVmDataStoreReference5 store (helper src/NETSetup.Tests/DataStoreTests/PowerOne.cs, twin of OsisaLib03.cs), commit, then poll a FRESH store until Running - start is async, commit return before guest run.

# elevated
dotnet test src\NETSetup.Tests --settings src\prod.runsettings --filter ProxmoxDevRunner04ReinstallTests

osisa.Virtualization change that make it work:

  • VirtualMachineSettings gain optional init property MacAddress (MAC?); VirtualMachineBuilder.WithMacAddress set it.

  • ProxmoxVmOperations.InsertAsync NO LONGER throw on SerialNumber. It now set OperatingSystem, Secure Boot ON for Windows family (same shape as ProxmoxWindowsVmFactory), smbios1 serial, and net0 MAC.

  • Why MAC: reusable-ISO Proxmox guest see EMPTY smbios serial in WinPE and compute its serial from NIC MAC. So MAC IS the identity - insert without the declared MAC = node match no map file.

Warning
TRAP - pin storage on Power-1. Proxmox V5 insert WITHOUT WithStorage auto-pick disk storage. On Power-1 that picked tmpfs ramstore (RAM-backed, LOST on host reboot, only ~97 GB) for a 250 GB disk. ALWAYS WithStorage("local-btrfs") on Power-1.

devRunner03 on local Hyper-V. HyperVDevRunner03ReinstallTests green from scratch 2026-09-25 after two fixes:

  • Read-back through a FRESH data store. The commit’s own unit of work hand back the INSERTED row (PowerState Stopped), so test failed although guest ran. Never read back from the committing store.

  • LocalHyperV.LabSwitch = External_WIFI, not NETSetup_External - maskeLT1 wired NIC unplugged. Both switches land on 192.168.3.0/24.

Hyper-V boot loop FIXED. HyperVVmOperations used to put DVD FIRST in boot order. After WinPE apply image + reboot, guest boot installer ISO again → WinPE wipe disk → reinstall forever (~15 min cycle). Now BuildDiskThenDvdBootOrder: hard disk, then DVD, then rest (network last). Empty VHDX not bootable → fall through to DVD, so first install still work. Proxmox already right (scsi0;ide0).

Hyper-V InsertAsync REJECT MAC. Non-null MacAddress now throw VirtualizationException instead of silent drop - caller must not believe MAC was set. Unit tests in osisa.Virtualization.Tests (HyperVVmOperationsTests.cs).

Outcome 2026-09-25. Both nodes Windows 11 Pro 26200.

Step Result

Stage3 config-driven join

Attempted on both (DNS pinned to DC, OU STATIC). FAILED "Benutzername oder Kennwort falsch" on both - stale domain-admin password, see "Known failure, still open" in domain-join section.

Out-of-band finish

djoin /requestODJ with blob delivered via PowerShell Direct (Hyper-V, devRunner03) and via qemu-guest-agent file-write (Proxmox, devRunner04). Then write marker C:\NETSetup\Logs\DomainJoin.log (else Stage3 repin DNS every boot) + COLD restart.

Result

Both in OU=STATIC, secure channel OK against osisaDC06.

Still open

Static IPs .81 / .82 NOT applied (sit on DHCP .18 / .34). apply-capabilities (WinRM / WSL / groups) NOT run.

Warning
GOTCHA - PowerShell Direct hang. powershell.exe 5.1 spawned from pwsh inherit pwsh PSModulePath → ConvertTo-SecureString fail → credential empty → PS Direct (-VMName) wait forever on invisible credential prompt. No error, just hang. Launch PS Direct from pwsh directly, never via powershell.exe child.
6.2.3.5.2. Adding a lab node - the repeatable method

Full runbook plus every trap live in the netsetup-lab-node-build skill. The short form, in order:

  1. Allocate. vmid GLOBALLY (300, 305, 306, 307, 308, 309 taken), serial DERIVED from it, address PINGED and ARP-checked first (.75 and .77 on the lab LAN are real hardware), next free #915x ticket.

  2. Declare. Copy the newest sibling generator in Customers/ and retarget device + serial, the IP override, the software block, LabVmId, SetWindowsCapabilities, and both output names. Sizing belong in the deploy TEST, never the config - Machine.Hardware is typed IHardware and a config that persist it become unloadable for every guest that read it.

  3. Generate. See "Generating a config when the store have no account secrets" below.

  4. Publish. NETSetup.exe publish-config --ticket <n> --config <ROOTED path> write <n>.json plus one <serial><n>.map per machine into the synced Dropbox root. The path MUST be rooted or Nuke throw "must be rooted". Skip this step and the node install fine and then match no config.

  5. Prepare the host. pvesm set local --disable 0 and stage the shared NETSetup.iso host-to-host.

  6. Install. dotnet test src\NETSetup.Tests --settings src\prod.runsettings --filter <test>, from an ELEVATED session. ~3 h.

  7. Finish out of band. EXPECT Stage 3’s domain join to fail with "Der Benutzername oder das Kennwort ist falsch" - the domain-admin password in the lab configs is stale, and hydrating a new config from a sibling copy that stale password rather than fix it (re-confirmed on devRunner01, 2026-09-05). djoin /provision …​ /savefile <blob> /reuse on a domain box, base64 the blob into the guest, djoin /requestODJ, WRITE C:\NETSetup\Logs\DomainJoin.log, then a FULL STOP/START (a pending qm set -cores only land on a cold boot), then NETSetup.exe apply-capabilities --hostname <node> in the guest, then one more reboot for the Docker autologon session.

  8. Verify by CONNECTING. A green reinstall test prove nothing about the node: it create the VM and return in seconds while Windows install unattended for hours afterwards. Run the verification test - ProxmoxDevRunner01VerificationTests is the model - which reach the node BY HOSTNAME over WinRM (so join + DNS + listener are proven by the connection itself) and assert, from one remote snapshot: the vmid-derived serial, MapFile.log naming <serial>#<ticket>.map (proof the config was resolved ONLINE), domain + healthy secure channel, disk and RAM, the lab AD group in Administrators / Remote Management Users / docker-users (built-ins resolved by SID, the names are localized), wsl.exe present, docker run hello-world answering, and the dotnet + git toolchain.

    A node is NOT done until that test is green. devBuilder04 install cleanly and was unusable, which is the whole reason the capability block exist.

6.2.3.5.3. Generating a config when the store have no account secrets

src/NETSetup.kdbx currently hold only 4 entries (OPNsense key/secret, two Proxmox API tokens) and NO NETSetup_PW_* account secrets, so scripts/Generate-CustomerConfig.ps1 report "Hydrated 0 secret(s)" and CustomerSecrets.PasswordFor throw CustomerSecretNotFoundException. The .kpscratch stores use a different master key.

Do NOT ask for the passwords and do NOT reintroduce a literal. Hydrate them out of an existing config for the SAME company:

dotnet run scripts/Hydrate-CustomerSecrets.cs "#9155.json" OSISA_DevLab07.cs

scripts/Hydrate-CustomerSecrets.cs deserialize the source config, read each account password back with SecretString.ToDecodedString(), put them into the child process environment as NETSETUP_PW_<user>, and run the generator. Nothing is printed and nothing is written to disk.

It work because SecretStringJsonConverter store an AES blob under a pass phrase COMPILED INTO osisa.Security.Contracts - which is also why a generated customer JSON is itself secret material, not a safe thing to paste around.

6.2.3.5.4. Putting a lab node on a second Proxmox host

#9155 / devBuilder06 is a 1:1 copy of the #9154 pair with the target host swapped. Only four things actually differ: the DataRow (Power-1 / 192.168.3.217), the node name, the vmid, and the config it load. Everything else - capabilities, disk, RAM, reusable-ISO identity - is the same declaration.

What is NOT the same is the HOST. Power-1 is a btrfs-default install and needed two things osisaLib03 already had:

  1. dir-storage local ships DISABLED. ProvisionWindowsVmOnProxmox pass isoStorage: "local" literally, because the ISO live in local’s /var/lib/vz/template/iso while GetISOStorage would happily pick local-btrfs (both carry content=iso) and then fail the volume lookup. Enable it with pvesm set local --disable 0 - idempotent, and exactly what UpdateMethods.EnsureLocalStorageEnabled do during a normal update.

  2. the shared NETSetup.iso must already be staged in /var/lib/vz/template/iso. The reusable path never build one - that is the entire point of it. It is 11.06 GB, so copy it host-to-host over the LAN rather than through a dev box: serve it from the source host (systemd-run --unit=isoserve --working-directory=/var/lib/vz/template/iso /usr/bin/python3 -m http.server 8899 --bind <src-ip>), pull it on the target with curl -C -, then sha256sum BOTH ends before trusting it. Measured 2026-09-01: 11'877'529'600 bytes at ~120 MB/s, about 100 seconds.

Neither is asserted by the test, on purpose - preparing a host is a host job, not a test job.

Three more things bit while finishing devBuilder06 off:

  • PVE reject every POST without a CSRF token - with an EMPTY body. Authenticating with a ticket cookie alone, agent/ping answer HTTP 401, zero bytes, which read exactly like "the guest agent is not answering". A watcher built that way stay blind while the node is actually up and running. Send -H "CSRFPreventionToken: <token>" on POST/PUT/DELETE, and treat an empty body as an AUTH failure, never as "not ready yet". GET (network-get-interfaces, exec-status) need only the cookie.

  • qmreboot can leave the VM STOPPED. It report VM quit/powerdown failed when the guest is too busy to answer ACPI - a Windows guest with pending servicing after a WSL install is exactly that. Prefer an in-guest shutdown /r /t 5, and ALWAYS re-read status/current after a reboot instead of assuming.

  • djoin /provision /machineou <OU> succeed as an ordinary domain user, even though MOVING an existing computer object is Access Denied. So create the object in the right OU up front. devBuilder06 came up with Administratoren already containing osisa\LOCALADMINS on the FIRST pass, straight from the GPO - which is the thing devBuilder05 never got, sitting in CN=Computers.

6.2.3.5.5. From "node installed" to "node that BUILDS" - the complete checklist

A lab node that pass Proxmox<Node>VerificationTests can still be useless as CI: it run jobs but cannot BUILD. Everything below was measured on devRunner02 between 2026-09-10 and 2026-09-11. Do it in THIS order and the node is usable on the first pass.

0. Proxmox: turn ballooning OFF before anything else

memory 16384 + balloon 1024 let the host reclaim guest RAM. Windows see a SHRINKING machine and raise the Memory Diagnostic prompt ("Computer auf Speicherprobleme ueberpruefen") - it look like failing hardware and it is not. A compiler that lose memory mid-build fail in ways nobody can reproduce.

qm set <vmid> --balloon 0
1. The autologon session is load-bearing - and fragile

The GitHub Actions listener AND the Docker engine both live in the interactive session of the machine’s ClientAdministrator (the AutoLogonUser capability, admin on the lab nodes). Consequences:

  • a console logon by ANOTHER user DISCONNECTS that session and take the runner and Docker down with it;

  • (query user) showing admin on the console say NOTHING about domain membership - it is a LOCAL account (Winlogon\DefaultDomainName = .). Check membership properly instead:

    (Get-CimInstance Win32_ComputerSystem).PartOfDomain     # True
    nltest /sc_verify:corp.osisa.com                        # NERR_Success
    Get-ADComputer <node> -Properties PasswordLastSet       # from a domain box
2. THE recurring trap: the listener caches its environment

A process inherit environment variables when it STARTS. The listener start from a scheduled task in the autologon session, so anything installed or set afterwards is INVISIBLE to it. This bit three times in one session:

What was done What CI still reported

installed PowerShell 7

##[error]pwsh: command not found

set the feed token machine-wide

401 (Unauthorized) on every osisa package

dotnet tool install --global

netbase-build …​ wird nicht als Name eines Cmdlets erkannt

Rules that make it go away:

  1. install tools MACHINE-WIDE - --tool-path C:\dotnet-tools plus that path on the machine PATH, never --global, which land in the CALLING user’s profile (yours over WinRM, not `admin’s);

  2. set variables at MACHINE scope, never User;

  3. RESTART GitHubActionsRunner-Interactive afterwards, and only THEN judge a CI result;

  4. confirm who owns the listener before assuming a fix landed for the right account:

    $l = Get-CimInstance Win32_Process -Filter "Name='Runner.Listener.exe'"
    Invoke-CimMethod -InputObject $l -MethodName GetOwner

A manual dotnet restore in your own fresh WinRM session proves NOTHING about what the runner sees.

3. PowerShell 7 is mandatory on a CI node

GitHub Actions run every Windows run: step through pwsh BY DEFAULT. Windows PowerShell 5.1 does not satisfy it. Declare powershell-core in the node’s software list - it is now in OSISA_DevLab08.cs.

4. Both halves of the package setup, or restore die

NuGet.Config declare two sources a fresh node cannot satisfy. They fail INDEPENDENTLY, and the first one abort the restore before authentication is even attempted:

Half Symptom and fix

local feed

NU1301 …​ Die lokale Quelle "C:\_gh\packages" ist nicht vorhanden. The directory must EXIST; empty is fine, it simply match nothing. Declare WindowsCapabilities.LocalPackageFeedPath.

credentials

401 (Unauthorized) on every osisa.* and on AsciiDocSharp. NuGet.Config read %GITHUB_PACKAGES_USER% / %GITHUB_PACKAGES_TOKEN%; a workflow-level GITHUB_TOKEN does NOT substitute. Declare WindowsCapabilities.PackageFeedCredentialUser naming a User on the machine.

Take the credential from an EXISTING build machine (devBuilder01 / devBuilder02 carry it as machine environment variables) - nothing new has to be issued for a lab node.

5. Docker over WinRM cannot use the credential helper

docker pull over WinRM die with error getting credentials …​ Eine angegebene Anmeldesitzung ist nicht vorhanden: the CLI always call wincred, and CredRead has no usable logon session under a NETWORK logon. Emptying config.json does NOT help. Pull as SYSTEM through the QEMU guest agent, or pre-pull the image - once it is local, docker run need no registry auth from any session.

Also: com.docker.service can be left Stopped/Manual after the capability run, and the engine then answer 500 on _ping. Set it Automatic and start it.

6. Changing the node’s IP strand the WSL network

After a static address is applied, Windows itself reach the registry fine while docker pull fail with TLS handshake timeout - the engine live in the WSL2 VM, whose NAT and DNS were built from the OLD config. A REBOOT rebuild it. Set the static address BEFORE relying on the engine.

Do not read wsl.exe -l -v over WinRM as evidence: distributions are registered PER USER, so your session correctly report "keine installierten Distributionen" while admin own the docker-desktop distro.

7. Runner registration: REPO scope vs ORG scope

An ORG runner serve every repository; a REPO runner serve one. Minting an ORG token need the admin:org scope AND org ownership:

gh auth refresh -h github.com -s "admin:org"

NOTE the COLON. admin.org with a dot is rejected as invalid_scope - that typo cost a full round trip.

Then use the canonical tool, never a hand-rolled script:

dotnet run scripts/Deploy-ActionsRunner.cs -- <node> ^
  --scope https://github.com/osisa ^
  --labels netbuild,netbase,netsetup,docker,<node> --version <pkg version>

It mint both tokens, deregister, reinstall with /Interactive, register, and verify both sides. An ORG runner appear at https://github.com/organizations/osisa/settings/actions/runners; a REPO one does NOT - it sit under the repository’s own Actions settings, which is the usual "I cannot see it" confusion.

If a config.cmd remove was ever started by hand it sit at Enter runner remove token: and hold the runner offline. Kill that Runner.Listener.exe first, then run the deployer.

8. What is NOT the node - do not chase these
  • netbase-build Push fail with The type initializer for 'Nuke.Common.NukeBuild' threw an exception. Reproduce on maskeLT1 with a different tool version and checkout. The workflow last succeeded 2026-07-08 and has failed EVERY run since 2026-07-11.

  • develop need a netbase package GitHub Packages does not serve, so a machine without a hand-copied C:\_gh\packages fail to COMPILE (IPermanentDeletions<> not found) even after restore succeed. devBuilder01 / devBuilder02 have no local feed at all and fail the same way. Mirroring the feed is a WORKAROUND; publishing the package is the fix.

  • the stale domain-admin password - see 9. The domain join will fail until the secret store is fixed.

9. The domain join will fail until the secret store is fixed

Verified 2026-09-10: all twelve osisa configs carry the SAME domain-admin password across all four admin accounts, and ValidateCredentials against the DC return FALSE. The account is healthy - enabled, not locked, member of Domain Admins - with PasswordLastSet 2025-09-08, AFTER those configs were generated. So EXPECT the join to fail and plan the djoin route, until a working password reach NETSetup.kdbx.

6.2.3.5.6. Six things that bite when you build one of these

Learned building devBuilder05 on 2026-08-31 and devBuilder06 on 2026-09-01. Most are silent - nothing fails loudly.

0. Disks.Single() throw exactly when the VM is RIGHT. A Secure Boot + vTPM VM carry THREE volumes on Proxmox - the system disk plus efidisk0 and tpmstate0 - and the read-back report all three. So created.Hardware.Disks.Single() fail with "Sequence contains more than one element" on a correctly built VM. Assert the system disk is PRESENT at the configured size instead (Disks.Should().Contain(d ⇒ d.Size == …​)); the firmware and TPM volumes are megabytes and cannot be confused with a 250 GB system disk. Found on devBuilder06 and fixed in the devBuilder05 test too - it was latent there, not introduced by the copy.

1. The reusable ISO is older than the code. local:iso/NETSetup.iso on osisaLib03 dates from 2026-08-22. Its baked binary parse a config carrying WindowsCapabilities perfectly well - System.Text.Json skip unmapped members by default (no UnmappedMemberHandling is set anywhere, so the install is NOT at risk) - and then ignore the block completely. Until the reusable ISO or the Chocolatey package is rebuilt with a binary that know the block, a fresh node come up WITHOUT WinRM, WSL2 or the group membership. Apply it out of band with apply-capabilities, or rebuild the ISO. This is the durable fix and it is not done.

2. The address in the config may already be taken. #9154 first pinned devBuilder05 to 192.168.3.75, and .75 already answer on the lab LAN (MAC f0:1d:bc:a8:82:ad, nothing to do with NETSetup). A static IP pinned onto an occupied address collide the moment Stage3 apply it. PING THE ADDRESS before putting it in a config. devBuilder05 moved to .76.

3. The domain-admin credential in the lab configs is stale. #915, #9153 and #9154 all carry changeme for the domain admins. A single ValidateCredentials bind against osisaDC06 (192.168.3.201) return false, so DomainJoinMethods.JoinDomain fail with 1326 and the node stay in a WORKGROUP - which is exactly the state devBuilder03 is in. Two ways out:

+

  • put a working credential in the config (then DomainOuPath also starts working), or

  • offline-join with djoin: djoin /provision /domain corp.osisa.com /machine <name> /savefile <blob> /reuse on a box already authenticated to the domain, then djoin /requestODJ /loadfile <blob> /windowspath C:\Windows /localos on the guest. No password anywhere. An ordinary domain user can do this - the "add workstations to domain" right is enough.

5. wsl --install CANNOT work during a NETSetup install. It deploy an MSIX, and Windows refuse MSIX deployment under LOCAL SYSTEM - which is exactly the account NETSetup run as. This is the same wall the winget-via-Chocolatey attempt hit (HRESULT 0x80073CF9 …​ mit dem lokalen Systemkonto nicht ausgefuehrt werden darf). And it is SILENT in the worst way: DISM report both optional features Enabled, so everything look fine, while wsl.exe still answer "Der Windows-Subsystem fuer Linux ist nicht installiert" and Docker Desktop sit there with a CLI and no Linux engine. Measured on devBuilder05 2026-08-31 - and it is almost certainly what devBuilder04 was actually suffering from.

+ The way through is the MSI Microsoft publish on github.com/microsoft/WSL/releases: an MSI install fine as SYSTEM. WslHelper take that URL from WindowsCapabilities.WslInstallerUrl (a config value, never a literal in the helper) and run msiexec /i /qn /norestart; with no URL configured it fall back to the old wsl --install, which only succeed when a human run it interactively. #9154 pin wsl.2.7.12.0.x64.msi - pinned on purpose, so two identical runs cannot get different runtimes.

+ Rule of thumb for this whole class: anything MSIX/AppX is unreachable from a NETSetup install. Find the MSI, or install it in a logged-on user session afterwards.

+ Two follow-on traps once you go the MSI route:

+

  • The download get truncated. The 247 MB MSI arrived as 64.7 MB with NO error raised, and msiexec then had nothing valid to install - the known osisaLib03 guest-egress truncation (the PVE host itself is reliable). WslHelper therefore download with curl.exe -C - in a resume loop, not Invoke-WebRequest. It resumed from byte 67'870'012 and completed. Do not simplify that back to a single-shot download.

  • "WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED" mean it WORKED. After the MSI, probing wsl.exe over qm guest exec answer Das Ausfuehren von WSL als lokales System wird nicht unterstuetzt. That is not a failure - it is WSL declining to RUN as SYSTEM, which is correct per-user behaviour, and it proves the runtime is present. The failure text to watch for is the other one: "ist nicht installiert".

6.2.3.5.7. How devBuilder05 actually got finished (2026-08-31)

The node was built by `ProxmoxDevBuilder05ReinstallTests’ provisioning path and then completed out of band, because the reusable ISO predate the capability code (gotcha 1). Order matter:

  1. djoin /requestODJ + write C:\NETSetup\Logs\DomainJoin.log, then reboot. The marker MUST be written or Stage3 re-pin DNS and retry the impossible join every boot.

  2. WinRM, local-group membership, and the WSL2 MSI. Groups have to come AFTER the join - a domain principal cannot join a local group from a workgroup.

  3. Reboot, then verify.

Group membership came back as Administratoren and Remoteverwaltungsbenutzer - this node is German. That is why LocalGroupHelper resolve built-ins by well-known SID; a literal Administrators would have matched nothing and said so quietly.

LocalAccountTokenFilterPolicy need reg.exe add. New-Item -Force on that key return UnauthorizedAccess even under SYSTEM, and the Set-ItemProperty behind it then never run.

WinRM read Stopped right after a reboot and that is FINE - it is Automatic (Delayed Start) (DelayedAutoStart=1), so it come up a minute or two late. Do not "fix" it. Verified from a box on another subnet: TCP 5985 reachable, Test-WSMan identify OK, Invoke-Command returning DEVBUILDER05 / corp.osisa.com.

Also note qga itself: file-write and exec-status return 500 got timeout while the guest is busy (right after boot, or during Stage3’s update cycle). That is a busy guest, not a failed command - retry.

4. The OU is OU=STATIC,OU=CLIENTS,OU=MACHINES,OU=OSISA,DC=corp,DC=osisa,DC=com. Verified by LDAP query against the live DC: devBuilder01, devBuilder02 and devBuilder04 all sit there; devBuilder03 sit in CN=Computers, which is precisely why it is outside the GPO scope granting osisa\LOCALADMINS local admin. #9154 now declare that DN as DomainOuPath. Note that djoin /provision land the object in CN=Computers unless /machineou is passed, and MOVING an existing object into that OU need AD rights an ordinary domain user do not have. The LocalGroupMembers block is the belt-and-braces: it grant local admin directly, without waiting for a GPO.

6.2.4. Proxmox VM provisioning during update

Feature ship. netsetup update on Proxmox host now spin customer VMs auto.

Three flow. Pick by Machine.OperatingSystem:

  • NixOS flow - os match NixNextcloud, NixIPFS, NixOS. Boot generic nixos.iso (qemu-guest-agent on, master pubkey bake in). Wait guest-agent report ipv4. Pick template via NixTemplateFactory.ForMachine(machine). Emit config.nix + hardware.nix + sops secrets.yaml + .default.key to /NETSetup/generated-nix/<vm> (no flake emit - base flake reuse). scp config to VM /nixos-config/, ssh install.sh -h default -d <disk> via master key. install.sh decrypt the sops luks_password to /tmp/luks.key so disko-install run non-interactive (no stdin prompt over ssh).

  • Windows flow - os match Windows10/Windows11/WindowsServer201x/202x. Build per-VM offline NETSetup-{ticket}-{host}.iso via existing create-iso pipeline. Upload to Proxmox iso storage. Stealth hardware iff Win11. Boot VM from ISO, unattended install run normal.

  • OPNsense flow (Liberator-gated) - os match OPNSense. Only runs when host.Description == "Liberator". Prebuilt raw disk image at /var/lib/vz/template/iso/opnsense.img (synced via EnsureOpnsenseImage.EnsureOpnsenseImageOnProxmoxHost from Dropbox NETSetup/BOOT/Images/Opnsense.7z). qm create SeaBIOS, 5 vNICs Option Y (net0 untagged WAN, net1 tag=10 server tier, net2 tag=20 LAN, net3 tag=250 MGMT, net4 tag=254 Guest), qm importdisk raw image, qm set --scsi0 …​ --boot order=scsi0, qm start. First-boot OpnsenseInstall SSHes in with master key, scps config.xml (5 if assignments, DHCP per VLAN, static maps from config.Computers[].MAC, DNS forwarder → NixDns, FW deny-default inter-VLAN), reloads services, persists api key. Order first in GetOrderedVmsForHost. See Liberator.adoc for full topology + role matrix.

Paths / keys / cadence:

  • Proxmox iso store: /var/lib/vz/template/iso/ (both nixos.iso and NETSetup-*.iso).

  • Generated nix configs: /NETSetup/generated-nix/<vm>. Rewrite each update cycle.

  • Master SSH key: pub bake into nixos.iso at src/NETSetup/nixos/netsetup-master.pub. Priv extract at runtime to /NETSetup/.ssh/netsetup-master.key (mode 0600).

  • Dropbox source of truth for nixos.iso: NETSetup/ISOs/nixos.iso.7z + nixos.iso.sha256. EnsureNixosIsoOnProxmoxHost check sha, download+7z-extract if miss/mismatch.

  • Per-VM sops secrets: secrets.yaml always carry luks_password (random 32-byte hex, encrypt to the single installer age pubkey). NixIPFS VM ALSO carry ipfs/swarm_key (block scalar: /key/swarm/psk/1.0.0/ + /base16/ + 64-lowercase-hex) and ipfs/cluster_secret (CLUSTER_SECRET=<64-lowercase-hex> EnvironmentFile line), both nest under one ipfs: map. These two are cluster-shared: mint ONCE per customer by IpfsClusterSecretsGenerator.EnsureOn(config), stash on NETSetupConfig.IpfsCluster, stay STABLE across re-provision (regen would split the cluster). SecretsYamlBuilder render flat single keys (luks back-compat) and nested a/b keys; templates declare needs via NixHostTemplate.GetRequiredSecrets(config).

  • Non-interactive LUKS: disko-install on encrypted layout read the passphrase from passwordFile = "/tmp/luks.key" (set in disk-layouts.nix encrypted + dual-boot blocks). install.sh write the decrypted sops luks_password there BEFORE disko-install, delete it after TPM enroll. WHY needed: without passwordFile disko prompt on stdin, get nothing over the ssh pipe, luksFormat abort, and the closure copy into the still-unmounted /mnt/disko-install-root RAM tmpfs → No space left on device.

  • TPM enroll run in the INSTALLER, not in a chroot, and not via the optional wrapper (fixed 2026-08-16, install.sh). The old line was nixos-enter --root /mnt -c "nix-enroll-tpm-unlock /tmp/luks.key" and it carried TWO independent defects, either of which alone kill the node:

    1. Wrong path. nixos-enter CHROOT into /mnt and bind-mount only /dev /sys /proc (+ resolv.conf) - never /tmp. So /tmp/luks.key inside the chroot mean /mnt/tmp/luks.key, which nobody ever create.

    2. Command does not exist. nix-enroll-tpm-unlock only get installed when host.system.nixCommands.enable = true. That is a mkEnableOption, default false, and NO vm template set it. So the chroot exit 127 and print NOTHING - which is why the log show the enroll banner immediately followed by the failure, with no output between.

    Both failures were swallowed by || echo Warning, the install still exit 0, and the installed system halt forever at Please enter passphrase for disk disk-main-luks (cryptroot). Broken since commit 4028be54 (2026-05-02) - it NEVER worked, and every ENCRYPTED nix layout was affected, not just IPFS.

    There was a THIRD layer underneath those two. Once the enroll actually ran, it still did not unlock: --tpm2-pcrs=0+2+7 produce a valid systemd-tpm2 token (confirmed by reading the LUKS2 JSON header off the raw disk), the initrd crypttab correctly say cryptroot /dev/disk/by-partlabel/disk-main-luks - tpm2-device=auto, /dev/tpm0 IS found at boot - and the node STILL prompt. Reason: the PCR values get captured in the INSTALLER, booted from the ISO with the CD-ROM attached, while the installed system boot from disk with the ISO detached and boot order changed. Measured state differ → policy hash mismatch → systemd-cryptsetup fall back to the passphrase silently. An installer fundamentally CANNOT know the PCR values the installed system will boot with.

    Fix: call systemd-cryptenroll --unlock-key-file=/tmp/luks.key --tpm2-device=auto --tpm2-pcrs= <luks-part> directly from the installer - no chroot, no optional wrapper, no key staging, and no PCR binding. systemd-cryptenroll ship with systemd so the installer always have it on PATH, and the TPM is reachable because the installer own /dev. LUKS device come from the LUKS_UUID already detected after partitioning, else blkid -t TYPE=crypto_LUKS. Failure now report LOUD and install.sh exit non-zero, so an unbootable node can never report success again.

    Warning

    Security trade-off, read before reusing. Without PCR binding the TPM release the key to ANY system state. For a VM that mean: whoever hold the raw disk AND its tpmstate disk (which sit right next to it on the Proxmox host) can unlock. Accepted here because an unattended VM must boot with no human present, and what the encryption actually buy in this setup is data-at-rest protection on a copied or decommissioned disk WITHOUT the tpmstate.

    Do NOT copy this to a laptop or any physical machine. There, enrol PCRs on FIRST BOOT of the installed system, where the measurements are the real ones.

  • Config + age key never reached the installed system (fixed 2026-08-16, install.sh). disko-install UNMOUNT the target when it finish. Everything after it wrote to /mnt, i.e. into the installer’s RAM - mkdir, cp, git init, git commit all "succeed" and nothing warn. The installed system therefore boot with an EMPTY /etc/nixos. Fix: remount with disko --mode mount --flake "$WORKDIR#$HOST" --root-mountpoint /mnt when /mnt is not a mountpoint, then HARD exit 1 rather than write config into RAM. Note: do NOT pass --disk - that is a disko-install flag; the plain disko CLI reject it, print its usage and silently do nothing.

  • sops could not decrypt anything (fixed 2026-08-16). Per-VM secrets.yaml is encrypted to the INSTALLER age public key, but host.security.secrets default to deriving an age key from the host ssh key (ageKeyPaths = ["/etc/ssh/ssh_host_ed25519_key"]), which never encrypted these secrets → activation die with Error getting data key: 0 successful groups required, got 0, and every sops-backed service with it. Fix: install.sh write the installer age private key to /var/lib/sops-nix/key.txt (mode 600, deliberately OUTSIDE the nix store - a store path is world readable and this key is shared across every bundle), and the templates + NixIpfsRole set ageKeyFile = "/var/lib/sops-nix/key.txt".

  • kubo >= 0.38 refuse to start on a private swarm (fixed 2026-08-16, ipfs-private-cluster.nix). AutoConf fetch network config from a mainnet URL and hard-exit 1 when swarm.key / LIBP2P_FORCE_PNET is present, so ipfs.service crash-loop until systemd give up with Start request repeated too quickly. The node boot perfectly; only the ROLE was dead. Fix: AutoConf.Enabled = false in the module’s kubo settings, next to the existing MDNS/AutoNAT disables. Module-level, so BOTH the hand written template and the entity-model role get it.

  • Why nobody noticed - and the two gates that stop it recurring: every one of the failures above was SILENT, and the install exit 0 regardless. Both IPFS tests asserted only the ip discovered during the INSTALLER-ISO phase, which say nothing about the installed system. Now:

    1. NixVmBootGate.WaitForInstalledHostName - guest agent must report the installed networking.hostName, so a node that never boot FAIL.

    2. NixVmBootGate.WaitForServiceActive - systemctl is-active <unit> over ssh, so a node that boot but whose ROLE is dead FAIL. On timeout it pull the journal tail into the failure message.

    Gate implementation traps, both hit for real: compare on stdout only (ssh write its known-hosts notice to stderr, which otherwise drown the value), and the bundled master key must be copied to TEMP with icacls /inheritance:r first - in the test bin dir it inherit normal Windows ACLs and OpenSSH refuse it outright with WARNING: UNPROTECTED PRIVATE KEY FILE!, returning EMPTY stdout that look exactly like a dead service.

  • NixIPFS VM sizing: 8 GiB RAM (installer build the closure in a RAM-backed tmpfs store) + 64 GiB disk (IPFS repo live on system disk). Contrast: NixHostTemplate default 2 GiB / 32 GiB; NixNextcloudTemplate 8 GiB / 256 GiB.

  • Cadence: every netsetup update call hit this path. Idempotent (skip existing VMs, skip ISO sync when sha match).

  • Fail mode: per-VM error log + continue. Aggregate throw at end list all fail VM name.

  • Dev note: end-to-end test ISO uploader connect to the Proxmox host by LAN IP, NOT the osisaLib03 hostname (DNS not resolvable from every dev box).

NixOS is the base of every NETSetup Linux install - roles, install chain, install.sh anatomy, the guards that must stay, and the revertable change log live in docs/nixos.adoc.

Entry point: UpdateMethods.RunProxmoxUpdate append EnsureCustomerVms.Run(host, config, services) after RunBtrfsScrub.

VM order: OPNsense first (router boots before downstream), then DC (WindowsServer), then everything else (CreateVirtualMachines.GetOrderedVmsForHost).

Gateway model: NETSetupConfig.GatewayIP (separate from IRouter device). Phase 1 = upstream port (today’s flash-mikrotik baseline). Phase 2 (shipped) = OPNsense LAN takeover via EnsureGatewayTakeover (rewrites /etc/network/interfaces gateway line + flips in-memory config.GatewayIP so NixOS VMs follow next cycle). Idempotent, best-effort, gated on OPNsense LAN ping success. Triggered on Liberator hosts only. See firewall.adoc for code map + Windows-DC follow-up. See Liberator.adoc for topology, VLAN map, cutover sequence, role gate.

Sequence diagram: see Diagrams section.

Host network converge (Phase 3e in NETSetup.puml) lives in 4 idempotent helpers, all gated on host.Description == "Liberator" && hasOpnsenseVm:

  • ConfigureOpnsenseVlans - REST API push (DHCP, DNS-forwarder, FW aliases + filter, apply)

  • ConfigureMikrotikVlans - REST API push (bridge VLAN-aware, LACP bond, VLANs 10/20/250/254, WiFi SSIDs, strip flat NAT + DHCP)

  • ConfigureProxmoxBridges - /etc/network/interfaces rewrite (bond0 LACP, vmbr0 VLAN-aware, vmbr0v250 host mgmt) with ifupdown2 safe-mode commit/rollback

  • RewireVmNicsToVlans - qm set <vmid> -netN …​,tag=<vlan> drift correction for existing VMs

Non-Liberator hosts (Power Station, vanilla Proxmox, other Description values) skip all 4 helpers + OPNsense provisioning entirely. VMs still provisioned on flat vmbr0 untagged via same GetOrderedVmsForHost serial-matching path.

Full design + topology + cutover sequence: Liberator.adoc.

6.2.4.1. Rules a created guest must obey (measured 2026-08-30)

Five rules that change how a provisioning run behave, or how much you may TRUST it. All measured on osisaLib03 (192.168.3.233) plus the local Hyper-V bench. Full fingerprints + the command recipes: docs/winpe.adoc section "Deploy Blockers" and the netsetup-windows-node-proxmox skill reference references/deploy-blockers.md.

  • No ballooning, no dynamic memory, on an INSTALL guest. WinPE ship no balloon driver, so the hypervisor squeeze the guest below what a DISM apply need and the apply HANG at zero bytes with a black console - it never fail, so it read as a stuck test. Proxmox create with balloon: 1024, Hyper-V with Dynamic Memory that collapse to ~1.3 GB against a 4 GB startup. balloon=0 / -DynamicMemoryEnabled $false BEFORE first boot. It apply to WINDOWS guests too: the OS-based gate in VirtualHyperVTestBase (disable only for non-Windows) is the wrong gate, because every Windows guest install THROUGH WinPE.

  • A Hyper-V vTPM is set at creation only. Adding one to a guest that already have state seal that state undecryptably and the guest refuse to start with 0xC000A002 STATUS_AUTH_TAG_MISMATCH. Set-VMKeyProtector + Enable-VMTPM do not repair it - the only recovery is a new guest. This is the same reason firmware moved into the create call, see Hyper-V firmware is decided by the OS, and set AT CREATION.

  • DEFECT: the default cpu=x86-64-v3 cannot start on a host without AVX2. Proxmox get cpu=x86-64-v3,hidden=1,hv-vendor-id=GenuineIntel,flags=+pcid;+aes; osisaLib03 lack avx2/bmi1/bmi2/fma/movbe/abm, so QEMU exit code 1 and the guest is created but never run. cpu=host work. ProxmoxWindowsVmFactory, ProvisionNixosVm and ProxmoxVmOperations already override to host - every NEW creation path must, until the default itself fall back.

  • CreateVirtualMachine can return true while qmcreate failed. Never trust the return value. The failure surface later and misleadingly as Failed to find VirtualMachine …​ Sequence contains no matching element from the round-trip read. Real reason lives in /nodes/<node>/tasks.

  • A guest match its config BY SERIAL, and a hypervisor invent one per guest. No match and the install stop at IdentifyHost with "The specified config doesn’t include a configuration for the specified machine with ID". Proxmox solve it with smbios {vmid}@{hostSerial}. On Hyper-V the BIOS serial IS settable (ModifySystemSettings / BIOSSerialNumber) - which contradict the assumption in VirtualHyperVTestBase.EnsureVirtualMachineSerialNumber, that the guest serial can only be READ and then written back into the config. Pinning the guest serial is cheap; rewriting the config force an 11 GB ISO rebuild.

6.2.4.2. Which live suite LEAVE state on the host

Most live suites are round-trips: they create, assert, destroy, and the host end at the guest count it started with. One deliberately does not, and mixing the two up is how a "leftover" guest get deleted by somebody tidying up.

Suite What it leave behind

ProxmoxOfflineWin11Tests

NOTHING but the ISO. It create with autoStart: false, round-trip read the guest, then EnsureRemove it. The uploaded/referenced ISO stay on the host on purpose so a re-run skip the upload. This suite prove the CREATE contract - it cannot leave a deployed instance behind, because a guest that never boot install nothing

ProxmoxOfflineWin11Tests2

A RUNNING GUEST, on purpose. Copy of the above changed in exactly TWO ways: autoStart: true, and the closing EnsureRemove replaced by a LEFT RUNNING: log line. The point of the copy is to DEPLOY - the guest stay up and install Windows from the ISO it was created with. Current instance: devBuilder03, vmid 9099, on osisaLib03. The original suite is UNTOUCHED

HyperVVirtualMachineRepositoryCrudTests2 / ProxmoxVirtualMachineRepositoryCrudTests2

Nothing - every NETSetupCrudTest* guest they create, they destroy. Set NETSETUP_KEEP_CRUD_GUESTS to keep them for inspection. See the "Proof" section above

Both Win11 suites EnsureRemove the machine name BEFORE provisioning, so Tests2 REPLACE its guest rather than accumulate them.

Caution
that pre-clean is weaker against a RUNNING guest than it look. A netbase defect recorded in an earlier session - EnsureRemove issue the stop asynchronously and discard the destroy result, so a running guest can survive it, fingerprint qmstop in the task log with no following qmdestroy. Not re-measured on 2026-08-30. Since Tests2 is precisely the suite that leave a guest RUNNING, check /nodes/<node>/tasks for the qmdestroy before assuming a re-run replaced anything.

Dependency worth knowing before a Proxmox Windows test run: the Proxmox storage devboxiso is a CIFS mount of the dev box NETSetupISO share, whose template\iso is a junction to C:\NETSetupDEV. That indirection is why the offline Win11 test take ~5 s - the host REFERENCE the 11.9 GB ISO instead of receiving it. When it go offline the guest cannot be created at all, and uploading is not a fallback: measured 0.15 MB/s, about 20 h for 11.9 GB.

6.2.5. NixOS + Proxmox: three ways to stack

Combine possible. Stack order decide: stable setup or maintenance pain.

Option How Plus Minus

1. Proxmox host, NixOS as VMs (standard, best)

Stock Proxmox VE on bare metal. NixOS images via nixos-generators (nixos-generate -f proxmox …​), uploaded as templates. VM or LXC.

Proxmox web UI, live migration, snapshots. NixOS redeploy over SSH with nixos-rebuild --target-host.

Host itself Debian, configured imperatively via GUI, not Git.

2. Proxmox on NixOS (community port Proxmox-NixOS)

PVE stack (QEMU, Corosync, PVE daemons, web GUI) packaged as NixOS module.

Whole hypervisor host (bridges, kernel modules, firewall) in one configuration.nix in Git.

Experimental. Proxmox expect Debian layout + rewrite network files itself → clash with read-only Nix store, two sources of truth. Lab toy, fragile daily.

3. NixOS bare metal + MicroVM.nix / NixVirt (no Proxmox)

MicroVM.nix: QEMU / Cloud-Hypervisor VMs declared in host flake, boot in ms. NixVirt: KVM/QEMU/libvirt VMs in configuration.nix, GUI via virt-manager from desktop.

100% declarative, no Debian baggage.

No Proxmox UI / migration tooling. Build it yourself.

Recommendation:

  • Bulletproof daily homelab → option 1.

  • Fully declarative self-built machine, no Proxmox → option 3.

NETSetup = option 1: Proxmox host, NixOS guests (Nextcloud / Mail / Dns) provisioned by netsetup update from own nixos.iso + config (see NixOS entity model (describe host as data)), not nixos-generators templates.

6.2.6. NixOS entity model (describe host as data)

Second way to describe NixOS host. Old way still work, unchanged.

Old way: new role = new C# subclass of NixHostTemplate carry big hardcoded nix string (NixIpfsTemplate, NixDnsTemplate, NixNextcloudTemplate). Role only exist as text; cannot inspect it, cannot diff it, cannot test it without provision a VM.

New way: role = DATA. Build it, inspect it, diff it, unit-test it. Renderer turn it into same nix.

6.2.6.1. Why the model look like this

Model mirror the NixOS module system, not invent parallel vocabulary. NixOS module = three parts (imports, options, config). Every option is a PATH in one uniform tree, has a TYPE, and values MERGE by that type. Consequence: one path → value pair express everything - services., boot., security., vendor host., all identical shape. RFC-42 (the settings freeform attrset convention in nixpkgs) push same idea inside nix itself: stop minting one option per setting, carry structured value.

So the model carry a generic Options list plus a RECURSIVE NixValue - not 50 named C# properties. That is the whole design decision.

6.2.6.2. The types
Type Maps to Note

NixHostModel

one host

the data. no nix syntax inside

NixHostModelBuilder

fluent build

same shape as customer Company.CreateBuilder(…​) chain

NixValue

any nix value

recursive: String / Bool / Int / Raw / List / Set

NixPackage

environment.systemPackages entry

renders pkgs.<attributePath>

NixService

services.<name>.* block

enable + ordered options

NixSecretEntry

sops.secrets."<path>"

isMultiline → YAML block scalar

NixModelTemplate

renderer

IS a NixHostTemplate, so pipeline unchanged

NixIpfsRole

reusable role

Config/Nix/Model/Roles/

All under src/NETSetup/Config/Nix/Model/.

6.2.6.3. Packaging vs technical configuration

Two axes, two slots. Do not mix.

Axis Question Model slot Renders

Packaging

what is INSTALLED

AddPackage / AddPackages

environment.systemPackages = [ pkgs.x ];

Technical config

what is RUNNING / how

AddService + SetOption

services.x., host., boot.*

Do NOT fake packaging via SetOption("environment.systemPackages", …​). AddPackage exist so packages stay first-class list you can query and diff.

6.2.6.4. NixValue.Raw - the one that bite

Raw emit verbatim, NO quotes. Nix distinguish string from PATH from expression:

NixValue.String("./secrets.yaml")   // sopsFile = "./secrets.yaml";   <- WRONG, that is a string
NixValue.Raw("./secrets.yaml")      // sopsFile = ./secrets.yaml;     <- right, path literal
NixValue.Raw("pkgs.htop")           // package reference, not a name
NixValue.Raw("lib.mkForce true")    // priority override

Get wrong → nix eval-error, or worse, silently wrong config.

6.2.6.5. Usage
var model = new NixHostModelBuilder("nixipfs02")
    .WithUser("admin")
    .WithStaticIp("192.168.3.71")
    .WithResources(cpuCores: 2, memoryGib: 8, diskGib: 64)
    .AddPackages("htop", "ripgrep")
    .SetOption("security.tpm2.enable", true)
    .AddService("ipfs")
    .AddSecret("ipfs/cluster_secret", $"CLUSTER_SECRET={hex}")
    .Build();

InstallNixosVm.Run(pveHost, machine, config, localHost, qm,
    templateOverride: new NixModelTemplate(model));

templateOverride is the ONLY production seam added - additive, defaults null, threaded through InstallNixosVm.Run → ProvisionNixosVm.CreateVmOnProxmox + EmitConfigBundle. When null, everything resolve via NixTemplateFactory.ForMachine(…​) exactly as before. No existing caller change. ISO build, SCP upload, bundle emit, sops encryption, disko-install over ssh: all unchanged. The model is a different way to DESCRIBE a host, not a different way to install one.

6.2.6.6. Rules
  • Secrets are data, passed IN. Model carry secret VALUES, so mint first (IpfsClusterSecretsGenerator.EnsureOn(config, machine)), THEN build model. Keep role a pure function of its inputs → unit-testable with no config.

  • Model define disk → default disk block suppress. NixModelTemplate skip its own host.system.disk when model set that path. Two definitions of same attribute in one body is a nix eval ERROR, not a merge.

  • networking.hostName MUST equal machine name. InstallNixosVm already-installed gate compare guest hostname to machine.Name. Mismatch → reinstall forever.

  • Unit-test the render before provisioning. Deterministic, milliseconds. Cheap gate in front of a 20 minute install.

  • Parse-check for real: wsl -d NixOS — nix-instantiate --parse <file>. Catch syntax the string assertions miss.

6.2.6.7. Validation status

NixIpfsRole reproduce the FULL hand written NixIpfsTemplate: encrypted host.system.disk with nested btrfs subvolumes + retention, boot.initrd.systemd.enable, security.tpm2.enable, boot.initrd.luks.devices."cryptroot".crypttabExtraOpts, host.security.secrets with unquoted sopsFile path, host.services.ipfsPrivateCluster, both sops entries, 8 GiB / 64 GiB sizing.

  • NixHostModelIpfsRoleTests - 6 render assertions, non-Prod, ~60 ms. Green.

  • Generated nix accepted by real nix-instantiate --parse. Green.

  • Normalised render diff model vs hand written NixIpfsTemplate: NO semantic difference. Only formatting (multi-line vs single-line lists) and the time zone passed in. The model is faithful.

  • ProxmoxNixosIpfsModelTests.NixIpfsVmFromModel_OnProxmox_ProvisionViaRun_ShouldCreateAndDiscoverIp - live provision on osisaLib03, Prod + LongRunning, VM name TestNixIpfsModel (distinct from the hand written test’s TestNixIpfsProxmox so both node coexist).

Final state (2026-08-17): BOTH paths green, verified end to end.

entity model hand written

VM

TestNixIpfsModel (101)

TestNixIpfsProxmox (102)

Boot unattended (TPM unlock)

yes

yes

systemctl is-active ipfs

active

active

Failed units

0

0

Private swarm key present

yes

yes

Wall clock

15.8 min

12.1 min

Reaching that took SIX pre-existing defects out of the install path, each one masked by the previous: chroot path → missing wrapper command → PCR binding → unmounted target → missing age key → kubo AutoConf. All six are listed under Proxmox VM provisioning during update. NONE were caused by the entity model - the normalised render diff prove the model emit semantically identical nix, and the hand written template failed identically at every step. Reproducing a real role end to end is simply a far harder test than anything that existed before, which is what surfaced them.

Skill: /nixos-config carry the same guidance for module-level work.

6.2.7. Wake-on-LAN integration

NETSetup can integrate with Wake-on-LAN (WOL) to remotely trigger machine setup or maintenance. The netsetup CLI exposes wake-machine, wake-machines, enable-wol, and query-wol-status (see the Wake-on-LAN commands in CLI Command Specification). Network + hardware must support WOL: BIOS/UEFI WOL enabled for the adapter, NIC supports WOL, and the network passes WOL packets.

Full setup guide - required packages, enabling WOL on Linux targets with ethtool, and a persistent systemd unit - lives in WOL.adoc.

6.2.8. Linux PE notes

NETSetup installs Linux-based operating systems (Proxmox, NixOS) from a Void Linux musl "Pre-Execution Environment" (UnixPE) - a squashfs image booted from the target disk’s ESP. WinPE (Phase 1) partitions the disk and lays down the Void squashfs + GRUB + the NETSetup.musl binary; on reboot Void boots, rc.local finds autoexecute.sh, and runs NETSetup.musl install to install the target OS via GRUB chainload.

Full architecture, boot chain, image build/modify procedures, and troubleshooting: void-linux-pe.adoc.

6.2.9. Diagrams

6.2.9.1. Proxmox VM Provisioning sequence
Proxmox VM provisioning sequence
6.2.9.2. Liberator network topology
Liberator network topology
6.2.9.3. Liberator network push (cutover)
Liberator network push (cutover)
6.2.9.4. Public mail stack
Public mail stack
6.2.9.5. How an MSI is built (netbase-build + MSIBuild/WixSharp)

The Chocolatey packages NETSetup installs are produced upstream by netbase-build’s MSI deployment method: netbase-build Push publishes the project, then shells out to the MSIBuild.exe tool (WixSharp), which generates the WiX authoring (.wxs) and drives the WiX toolset (wix build) to compile it into the .msi; an optional step wraps that .msi into the Chocolatey .nupkg pushed to netsetupprod.

How an MSI is built (netbase-build + MSIBuild/WixSharp)

6.2.10. How the domain join actually run

Stage 3 join the node to Active Directory. Two DIFFERENT code path exist and they do NOT behave the same - which one run is decided by ONE config value. Everything below is read off src/NETSetup/Extensions/DomainJoinMethods.cs and src/NETSetup/Stages/3-NETSetupWindows/Stage3Windows.cs.

How NETSetup joins a Windows node to the domain (Stage 3)
6.2.10.1. The two gates before anything happen
Gate Rule

is this a domain node

DirectoryService.Get<IActiveDirectoryServer>().Any(). A NETSetupDC in the config is the SINGLE source of truth. No DC declared → standalone / workgroup node, the whole join is skipped. Without this gate the join hard-fail on a standalone config, because there is no DomainAdministrator either.

already joined

Logger.LogFileExists("DomainJoin") → marker C:\NETSetup\Logs\DomainJoin.log. Present → skip. This is why an out-of-band djoin MUST write that marker by hand - otherwise Stage 3 re-pin DNS and retry the impossible join on EVERY boot.

6.2.10.2. DNS get pinned to the DC first - both path do it

SetStaticDnsToDc run before either join. It read DirectoryService.GetSingle<NETSetupDC>().IP, grab the adapter named Ethernet (fallback Ethernet Instance 0) and call SetDNSServerSearchOrder. A DHCP-provided public DNS hide _ldap._tcp.dc._msdcs.<domain>, and the join then fail with 1355. A failure HERE is logged as a warning only and does NOT abort - so a 1355 later is the symptom, not the cause.

6.2.10.3. Path A - Add-Computer -OUPath (taken when DomainOuPath is declared)

The ONLY path that can create the computer object directly in an OU. It run as an ENCODED PowerShell command (PowershellCommand.Builder().UseEncoded()), so the password never land in console history - but it IS in process memory, because Add-Computer want a PSCredential.

The exact script, with placeholders filled from the config:

$ErrorActionPreference = 'Stop'
$sec = ConvertTo-SecureString '{password}' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('{netbios}\{domainAdminUser}', $sec)
Add-Computer -DomainName '{domainName}' -Credential $cred -OUPath '{ouPath}' -Force -ErrorAction Stop
'JOINED ' + '{domainName}' + ' OU=' + '{ouPath}'

Where the values come from - none is a literal in code:

Placeholder Source in the config

{netbios}

DirectoryService.Key.Value

{domainName}

DirectoryService.Name.Value

{domainAdminUser}

GetFirstUser(CommonRoles.DomainAdministrator).Username

{password}

that same User’s Password.ToDecodedString() (a SecretString in the config)

{ouPath}

WindowsCapabilities.DomainOuPath for THIS machine

Success is an EMPTY return string. Anything else is stdout + stderr, and Stage 3 call ExitFailure.

Before either path run, Stage 3 also execute nltest /dsgetdc:{netbios} /force purely as diagnostics and write the output to the DomainConnection marker.

6.2.10.4. Path B - WMI JoinDomainOrWorkgroup (no OU declared)
live.GetSystem().JoinDomainOrWorkgroup(null, 23u, domainName, password, admin);

23 = JOIN_DOMAIN(1) + ACCT_CREATE(2) + ACCT_DELETE(4) + DOMAIN_JOIN_IF_JOINED(16).

This path CANNOT target an OU - the object always land in the default container CN=Computers, which is outside the GPO scope that grant osisa\LOCALADMINS local admin. That is exactly how devBuilder04 end up unusable and had to be moved by hand. Declare DomainOuPath and path A run instead.

Return codes are mapped to readable text:

Code Meaning

0

success (empty string returned)

5

access denied for the user - the password WAS correct

1326

bad credentials. The message echo the last 3 characters of the password, the rest starred

1355

could not contact a domain controller for the domain (see the DNS pin above)

2697

disparity between the AD entry and the machine - usually need a restart

other

run net helpmsg <code> for the text

6.2.10.5. What happen AFTER a successful join
  1. marker DomainJoin is written;

  2. host.ExitReboot() - Stage 3 end the run and reboot;

  3. after the reboot Stage 3 re-enter, skip the marked step, and Grant-DomainAdminsRDP run;

  4. THEN the WindowsCapabilities block is applied. Order is load-bearing: a domain principal cannot be added to a local group while the machine is still in a workgroup.

6.2.10.6. Known failure, still open

The join FAIL on the lab nodes with Der Benutzername oder das Kennwort ist falsch (code 1326). The mechanism above is correct and the wiring is right - only the stored secret is wrong. Verified 2026-09-10: all twelve osisa configs carry the SAME domain-admin password across all four admin accounts, and a single ValidateCredentials call against the DC return FALSE. The account itself is healthy - enabled, not locked, member of Domain Admins, Enterprise Admins, Schema Admins - with PasswordLastSet 2025-09-08, AFTER those configs were generated. Until a working password is put in the secret store, every new lab node need the out-of-band djoin route and DomainOuPath never take effect.

Re-verified 2026-09-25: EVERY domain credential in #9157 and #9158 - repo copies AND published Dropbox copies - fail LogonUser with win32 1326. Config-driven join still fail. djoin blobs for devRunner03 and devRunner04 were provisioned into OU=STATIC,OU=CLIENTS,OU=MACHINES,OU=OSISA,DC=corp,DC=osisa,DC=com as ordinary user loser. Durable fix unchanged: put a WORKING domain-admin secret into NETSetup.kdbx.

6.2.11. Company model: what we have today, and which standards exist

Note
Research notes, 2026-08-16. This section only RECORDS findings and options. No decision, no recommendation. Nothing here is implemented.

The IMPLEMENTED flow - how the company model actually become a NETSetupConfig - is Company → Config: how a customer become a NETSetupConfig.

6.2.11.1. What the customer template actually is

We describe a customer twice, in two different shapes.

1. The document template - docs/Templates/Kunden-Netzwerkdokumentation.adoc. Its own header says "Schema wie Staudt AG", so src/NETSetup/Config/Dokumentation_Staudt_AG.adoc (2535 lines) is the reference implementation and the template is that document with the values stripped out.

Flat, seven part asset register:

Section Contains

identity block

Kunde, Anschrift, Strasse, PLZ/Ort, Tel, Haupt-E-Mail, Ansprechperson Auftrag, Ansprechperson Technik, Vertragstyp, Abschluss-/Lieferdatum, Freshdesk-Ticket

== Kontakte

Nr, Name, Tel, Mobil, E-Mail, Rolle

== Remote-Zugriff

device / IP / user / password, plus VPN (SSTP, IPSEC)

== Netzwerk

Netzwerkskizze, Modem/Router, Internetzugang, Firewall (+ Portweiterleitungen), Domains

== E-Mail Service

Geteilte Mailboxen, Microsoft 365 Gruppen, Verteilerlisten

== Drucker/Scanner

Drucker, Druckerfreigaben

== Server und Computer

one block per device

The only organisational construct in the document is the device heading, which cross references a person from == Kontakte:

=== StaudtAGDC,  <<osisaadmin>>
=== Staudt18,    <<alice.wasserfallen>>

The older instance docs/offer_process/Netzwerkdokumentation/2022-09-05_LuigicarsDokumentation.adoc additionally carried Lizenzen, Freigaben, Gruppen, Benutzer und Gruppenzugehoerigkeit, Passwortliste and Serverlogin. The template was narrowed since: passwords are now KeePass links only.

Only Staudt has an .adoc in this repo. Schnell and Carrera exist as code config only (Carrera doc lives in the separate osisa/CarreraMarble repo).

2. The code model - Customers/*.cs. Vocabulary measured across all 20 customer configs, with call counts:

HasTask 103 | AddDevice 81 | WithSerialNumber 81 | WithDevice 66 | UsesDevice 49
AddEmployee 49 | AddTask 36 | WithDescription 22 | AddOrganizationalUnit 6
WithHostname 3 | WithStaticIp 2 | WithGatewayIP 1

Shape:

Company (name, country, city)
  Device      (Server | RouterFirewall | Desktop | Laptop | Software) + serial + description
  Task        "Office", "Accounting", "ComputerAidedDesign", "BricsCAD 2020" ...
      WithDevice(<software>)          <- a Task BUNDLES software
  OrganizationalUnit                  ("CARRERAAG")
  Employee
      HasTask(<task>)                 <- what the person DOES
      UsesDevice(<device>)            <- what the person HAS

Three properties of this model, measured not assumed:

  • No hierarchy. No manager, no parent/child unit, no department tree.

  • AddOrganizationalUnit appears 6 times across 20 configs, roughly one per company, used as the AD domain container rather than as structure.

  • The organising principle is the Task, not the position. HasTask is the most used verb in the whole corpus (103 calls).

So the model is a capability / assignment graph (Employee <→ Task <→ Software and Employee <→ Device). It answers "who needs which software on which machine", not "who reports to whom".

6.2.11.2. Standards that exist for describing an organisation
Standard Format What it covers

W3C ORG, The Organization Ontology

RDF/OWL (serialisable as RDF/XML)

W3C Recommendation, 16 Jan 2014. Nine classes: Organization, FormalOrganization, OrganizationalUnit, Membership, Role, Post, Site, ChangeEvent, OrganizationalCollaboration, plus 25+ properties for hierarchy, membership, reporting, location and organisational history. Built to be extended per domain. Our Task corresponds to org:Role; we have no equivalent of org:Post, reportsTo or ChangeEvent.

OASIS CIQ v3.0

XML / XSD

xNL (names, party = Person or Organization, 36+ formats), xAL (addresses, 240+ countries, 130+ formats), xNAL (name + address), xPIL (Party Information), xPRL (Party Relationships). The mature XML answer for party master data. Our identity block maps closely onto xNAL/xPIL.

eCH-0097 / eCH-0098 (Switzerland)

XML / XSD

eCH-0097 Datenstandard Unternehmensidentifikation defines the identifying attributes of an enterprise; with eCH-0098 Unternehmensdaten it forms the exchange format between Swiss authorities. Current version 5.2.0 (2021), maintained by Verein eCH. Ties to UID/CHE numbers.

GLEIF LEI (ISO 17442) + RR-CDF 2.1

XML / XSD

LEI identifies the legal entity. The Relationship Record CDF expresses "who owns whom" (IS_DIRECTLY_CONSOLIDATED_BY, direct and ultimate parent). The standard for legal entity group hierarchy.

SCIM 2.0 (RFC 7643 / 7644)

JSON (XML capable)

IETF. Enterprise User extension carries employeeNumber, department, division, organization, manager. The operational identity view, closest to what netsetup provisioning actually does.

ISO/IEC 6523

identifier scheme

Organisation identification schemes (ICD codes).

UBL / ISO 20022 Party

XML

Organisation as trading or payment counterparty.

schema.org/Organization

JSON-LD / microdata

Publishing organisation data on the web.

LDAP / X.500 organizationalUnit

directory schema

The directory tree. This is what our AD OU actually is.

ArchiMate business layer

model / diagram

Modelling actors, roles and collaborations diagrammatically.

Gap worth recording: none of the above models "employee needs this software for this task on this device". That is CMDB / ITSM territory. The nearest normed neighbours are SCIM entitlements and W3C ORG’s documented extension points.

6.3. Changelog (NEWS)

6.3.1. 2026-05-05: public mail stack auto-prov per customer

Per-customer public mail stack. Three new hosted devices land on customer Proxmox host plus PBS on host itself. All wired into existing EnsureCustomerVms / EnsureProxmoxPostInstall pipeline.

Reference docs
  • FSD: Public Mail Stack — functional spec, decisions locked, deployment prereqs

  • Blueprint: Public Mail Stack — C# Implementation — file map, class signatures, build order

  • Mail Domain Setup — standalone vs smarthost runbook, per-provider account/token steps, DNS records, ISP gotchas, customer-builder code examples

  • netsetup-mail.puml — component + flow diagram (PBS, PMG LXC, mail VM, DNS VM, DDNS, ACME, smarthost)

    1. Mail VM, DNS VM, PMG LXC — one bundle. Mail VM (NixOS, OS NixMail): postfix submission 587 + smtpd 25 + internal LMTP-from-PMG on 26, dovecot imap 143/993 + lmtp unix socket, nginx + roundcube on 443. DNS VM (NixOS, OS NixDns): coredns auth zone + step-ca internal CA + lego LE DNS-01
      ddns-updater. PMG LXC (Debian + proxmox-mailgateway, OS PmgLxc): inbound filter on 25, relays to mail.<domain>:26. PBS installed apt-style directly on Proxmox host (no extra VM, no extra container) with datastore auto-created at /var/lib/proxmox-backup/datastore.

    2. Provider-pluggable DNS + ACME. New NETSetupConfig.DnsProvider POCO (Provider, CredentialsFile, CredentialsAttrs) feeds both lego (cert issuance) and ddns-updater (A-record sync) on the DNS VM. Supported provider list (intersection of nixpkgs services.ddns-updater and lego): cloudflare, duckdns, namecheap, porkbun, gandi, hetzner, godaddy, ovh, dyn, noip. Same provider + credentials drive both tools 95% of the time — one knob in customer config, two services configured downstream. Cloudflare is example, NOT hardcoded.

    3. Optional smarthost relay. New NETSetupConfig.MailRelay POCO (Host, Port default 587, User, PassFile). When set, postfix on the mail VM routes outbound through that smarthost (Mailgun, Brevo, SES, M365, whatever) with STARTTLS + SASL. When null, postfix does direct MX delivery. PassFile is a SOPS-encrypted secret path on the mail VM; NETSetup never reads its content.

    4. DNS records auto-derived from customer config. NixDnsTemplate scans every public-service VM hosted on the customer Proxmox host (NixNextcloud, NixMail, NixDns, NixIPFS, NixDocker, PmgLxc) and emits one A record per hostname under <CustomerDomain> zone, plus the PMG mx alias pointing at pmg.<domain>. Public hostnames (e.g. mail.<domain>, pmg.<domain>) become SANs on the LE certificate lego pulls via DNS-01. Adding a new hosted device means it shows up in DNS + cert SANs without touching the DNS template.

    5. New OS constants in NetsetupOS. NixMail, NixDns, PmgLxc. The two Nix-prefixed names satisfy IsNixBacked automatically and are routed by EnsureCustomerVms → NixHostTemplateFactory to the new NixMailTemplate / NixDnsTemplate (subclasses of NixHostTemplate). PmgLxc does NOT satisfy IsNixBacked — intentional, so EnsureCustomerVms skips it — and is routed by the new EnsureCustomerLxcs → ProvisionPmgLxc instead.

    6. LXC provisioning infrastructure — all new. LxcCommand.Builder wraps /usr/sbin/pct (subcommands: create, start, stop, destroy, exec, set, status) using the same AddNamed / AddBool pattern as every other NETCommand builder — never raw Add($"--flag {value}"). ProvisionPmgLxc is the LXC equivalent of ProvisionNixosVm (static class, Run + RunCore, delegate seams): pct create with --features nesting=1 --unprivileged 1, pct start, pct exec installs PMG apt repo + key + apt install proxmox-mailgateway + writes basic relay config pointing to mail.<CustomerDomain>:26.

    7. Wired into RunProxmoxUpdate. EnsureCustomerLxcs.Run runs after EnsureCustomerVms.Run — mandatory ordering, because the PMG relay config inside the LXC references mail.<domain>:26, so the mail VM must exist first. EnsurePbsInstalled is appended to EnsureProxmoxPostInstall after EnsureLocalStorageEnabled (apt install proxmox-backup-server, idempotent dpkg check, datastore auto-create). Both new steps are individual NETCommand calls so failures stay scoped — same per-step pattern as the rest of the post-install pipeline.

    8. TestCompany fixture additions. Three new hosted devices on the Liberator test host (host serial lib), inserted after LibWin11: mail@lib (VMID 102, NixMail), dns@lib (VMID 103, NixDns), pmg@lib (CTID 200, PmgLxc). CustomerDomain = "test01.iam.free" set on the test config; DnsProvider = cloudflare placeholder (provider swap is a one-line config change, not a code change).

    9. Manual e2e via info@osisa.com. After the automated test leaves the stack live, operator sends a real mail from info@osisa.com to <user>@test01.iam.free and verifies arrival in roundcube on https://mail.test01.iam.free/. Reply path back to info@osisa.com exercises the smarthost relay (or direct MX, depending on MailRelay). Automated e2e covers provisioning + service-up + DNS resolution: new test class ProxmoxPublicMailTests.Liberator ([Ignore("Local Tests")]) asserts PBS active on host, pct list shows pmg@lib, qm list shows mail+dns, dovecot/postfix/ nginx/coredns/step-ca/lego/ddns-updater all is-active, dig @localhost mail.test01.iam.free resolves, and a raw HttpClient GET of https://mail.test01.iam.free/ returns the roundcube login.

    10. DDNS provider hook. ddns-updater on the DNS VM polls the customer site’s current public IP every 60 s (whatismyip / opendns), compares with the last-pushed value, and on change PATCHes the configured DNS provider’s API for every configured A record (mail.<domain>, pmg.<domain>, mx.<domain>). MX record TTL pinned at 60 s for fast failover when the customer-edge dynamic IP rolls. Lego DNS-01 talks to the same provider for cert issuance + renewal, so a single provider account covers both record sync and ACME challenges.

6.3.2. 2026-04-15: Proxmox host self-update via GitHub Releases

netsetup update on a Proxmox host now upgrades itself end-to-end without leaving the binary stale. The flow:

  1. First-boot is USB-only. netsetup-first-boot.sh copies the linux-x64 binary from the SYSTEM partition (label SYSTEM on USB #1) and cp -p preserves its mtime. No Dropbox / Pages fallback. No self-update.sh. No ExecStartPre - the systemd unit simply runs /NETSetup/NETSetup update.

  2. Self-update via GitHub Releases. UpdateMethods.EnsureLatestBinaryOrReexec runs as the first step of every netsetup update cycle. It decrypts an embedded fine-grained PAT (Contents:Read on osisa/netsetup only, AES-256-CBC via osisa.Security.Contracts.EncryptionHelper so strings(1) only sees a base64 blob), GETs /repos/osisa/netsetup/releases/latest, parses the response with ParseReleaseInfo, and compares versions with CompareSemVer. When the remote SemVer is strictly greater, it downloads the asset by id, swaps the binary atomically (mv -f), chmod 755, spawns the new binary as NETSetup update, waits, and `Environment.Exit`s with the child’s exit code.

  3. SemVer comparator with build-counter precedence. Same major.minor.patch? The trailing dot-segment of the pre-release tail (the GitVersion build counter, e.g. 249 in 0.9.0-proxmoxvms.249) is compared numerically - channel rename in CI does not invert the ordering. Per SemVer 2.0 a missing pre-release still outranks any present one (1.0.0 > 1.0.0-x).

  4. Post-install ran step-by-step in C#. EnsureProxmoxPostInstall no longer writes a giant bash script. Each side-effect (disable enterprise repos, write pve-no-subscription.sources, install nag-removal hook, configure banners) is a discrete NETCommand or IFileOperator call so failures stay scoped.

  5. Customer VM provisioning (e.g. Nextcloud + Win11). EnsureCustomerVms.Run is wired into RunProxmoxUpdate after apt full-upgrade. Machines whose serial format is <VMID>@<HostSerial> and whose OS is NixOS-backed (or Win10/11/Server) get provisioned on the local Proxmox host. The OfflineCompany test fixture has a Nextcloud server (100@lib) and a Win11 client (201@lib) hosted on the Liberator; test ProxmoxWithUSBStickTests.Liberator end-to-end (PASSED 2026-05-03, ~26 min) confirms post-install ran, both VMs were created, Nextcloud’s HTTPS endpoint serves on its DHCP IP (raw HttpClient probe - accepts the 400 "Untrusted domain" Nextcloud returns when reached by IP), and the LibWin11 VM has the expected qm config (bios=ovmf, efidisk0, agent=1, ide-iso, scsi-btrfs).

    LibWin11 ISO pre-stage. MainISO.CreateOfflineProductionISO requires the netsetup source tree + dotnet SDK and so cannot run on a Liberator/Proxmox host. Build the per-VM Win11 ISO on the customer’s NETSetup build machine and ship it to the Proxmox host’s iso storage at /var/lib/vz/template/iso/NETSetup-{ticketId}-{hostname}.iso (where ticketId is the int parse of ConfigName or its FNV-1a hash for non-numeric ConfigName`s). `ProvisionWindowsVmOnProxmox detects an existing file at that path and skips the source-dependent rebuild. The Liberator test follows the same pattern: PreStageLibWin11IsoOnLiberator builds locally + SCPs to Liberator before NETSetup runs.

    NixOS install on tmpfs-limited VMs. ProvisionNixosVm attaches a 16 G scsi1 swap disk pre-create and NixRemoteInstallCommand runs mkswap /dev/sdb && swapon /dev/sdb && mount -o remount,size=20G /nix/.rw-store before invoking install.sh. Without this the Nextcloud closure overruns the live-ISO tmpfs (RAM/2, ~4 G at 8 G RAM) with error: writing to file: No space left on device. Liberator host RAM (15 G) cannot fit a 16 G nested VM, so swap is the workaround. Post-install uses qm stop+qm start (not qm reboot) because the live-ISO installer doesn’t honor ACPI shutdown.

  6. GitHub Actions publishes the linux-x64 binary as a release asset on every push to main/develop (gh release upload v$SemVer NETSetup --clobber), so the self-update path always has a fresh build to pull.

6.3.3. 2025-07-03

Updated the Flow to include the future Flow for Liberators.

6.4. Legacy Reference & Scratch Notes

6.4.1. Direct download (advanced / fallback)

For environments without Chocolatey or for building an install ISO locally:

Sign in to GitHub with access to private osisa/NETSetup repository first. Anonymous HTTP 404 does not prove release asset missing.

NETSetup install ISO is no longer published as a release asset. To build it: download NETSetup.exe above, run NETSetup.exe create-iso (see CLI Command Specification). Flash resulting NETSetup.iso to USB.

Important
Direct exe need appsettings.json next to it

Choco install drop appsettings.json for you. Direct download = bare exe, no settings. Must put appsettings.json in same folder as NETSetup.exe (exe dir, not current dir). Without it, remote source = NullFileProvider → create-iso / update cannot reach Dropbox.

Needs DropboxOptions (Dropbox API secrets) to fetch boot assets + images from Dropbox NETSETUP/BOOT:

{
  "DropboxOptions": {
    "RefreshToken": "<token>",
    "AppKey": "<key>",
    "AppSecret": "<secret>",
    "UseTeamRoot": true,
    "Root": "/NETSETUP/BOOT"
  }
}

Grab the file from a choco install (C:\ProgramData\chocolatey\lib\netsetup\tools\appsettings.json) or the repo (src/NETSetup/appsettings.json).

create-iso boot files (WinPE/linux) resolve in order: local NETSetup source repo → local osisa Dropbox/NETSetup/BOOT sync folder (any Windows box with Dropbox app synced, no secrets needed) → Dropbox API remote (needs appsettings.json + network). None work → clear BootSourceNotFoundException. So on a box with the Dropbox app synced, create-iso work even without appsettings.json; on a bare box you need it.

6.4.2. Dev machine setup (legacy scratch)

Note
Legacy scratch notes. Prefer the maintained Setup osisa NETBase dev machine and Verify dev-machine setup docs. Kept here for reference only. Any token below is a placeholder - use your own credentials, never a shared secret.
setup using netsetup

(you might need mermaid-cli aka mmdc, npm install -g @mermaid-js/mermaid-cli, and add "%USERPROFILE%\AppData\Roaming\npm" to path)

set-executionpolicy -executionpolicy bypass -scope localmachine -force

Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('http://netsetupprod.osisa.com:8080/install.ps1'))

choco source add --name=netsetup --source=http://netsetupprod.osisa.com:8080/chocolatey

cinst -y firefox git git-lfs vscode chocolateygui 7zip dotnetcore dotnet dropbox googlechrome infoniqaonestart2023_00 kmuline netsetupdb notepadplusplus office365netsetup teamviewer teams vlc windirstat asciidocfx putty ruby

2-machine
firewall
rdp
wallpaper
vpnToConfiguredVPNServer(connection to domain controller)

install-language en-us

set-winuilanguageoverride en-us

$LanguageList = Get-WinUserLanguageList
; $LanguageList.Add("en-US")
; $firstLang = $LanguageList[0].LanguageTag
; $LanguageList[0] = $LanguageList[-1]
; $LanguageList[-1] = $firstLang
; Set-WinUserLanguageList $LanguageList -force

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "dontdisplaylastusername" -Value 1

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "PromptOnSecureDesktop" -Value 0

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "shutdownwithoutlogin" -Value 0

set-ItemProperty -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "ConsentPromptBehaviorAdmin" -Value "0"

Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name "LocalAccountTokenFilterPolicy" -Value 1

Get-NetConnectionProfile | ForEach-Object {
    Set-NetConnectionProfile -NetworkCategory Private -Name $_.Name
    Set-NetConnectionProfile -NetworkDiscoveryEnabled true -Name $_.Name
}

[System.Environment]::SetEnvironmentVariable('DOTNET_ENVIRONMENT','Development',[System.EnvironmentVariableTarget]::Machine)

[System.Environment]::SetEnvironmentVariable('MSBuildEnableWorkloadResolver','true',[System.EnvironmentVariableTarget]::Machine)

[System.Environment]::SetEnvironmentVariable('MSDOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLE','false',[System.EnvironmentVariableTarget]::Machine)

git config --global --add safe.directory /git/

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All

reboot

domain join

login as your user (must be an admin)

3-user

[OSISA NUGET PACKAGE TODO]

dotnet nuget remove source github

dotnet nuget add source --name github "https://nuget.pkg.github.com/osisa/index.json" -u <your-email> -p <github-pat> --store-password-in-clear-text

"./nuget.exe setapikey <github-pat> -Source github"

dotnet tool install --global GitVersion.Tool

dotnet tool install --global gpr

dotnet tool install --global netbase.build --prerelease --no-cache

dotnet tool update --global netbase.build --prerelease --no-cache

gem install asciidoctor

gem install asciidoctor-diagram

asciidoctor -r asciidoctor-diagram -a diagrams docs/ghpages.adoc

[TODO INSTALL VS FROM CONFIG + COPILOT & RESHARPER]

Windows SDK

https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/

add newest win to path, eg
C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64

Windows ADK
Windows EDK

Install-Module -Name OpenSSL

6.4.3. Domain model notes (legacy)

Note
Legacy design brain-dump of the planned contracts / entity model. Kept for reference.
systemmanagement.contracts
	-> isoftware
	-> icomputer
	-> iuser

usermanagement.contracts
	-> irole
	-> igroup
	-> imembership
	-> idomain

human.contracts
	-> icontact
	-> inaturalperson
	-> iperson

localization.contracts
	-> ilocale
	-> itimezone

enterprise.contracts
	-> icompany
	-> iorganizationalunit
	-> iemployee
	-> icustomer
	-> isupplier
	-> idevice
	-> itask
	-> iservice

netsetup.contracts
	-> inetsetupsoftware [new: packagename]
	-> inetsetupcontact [new: naturalperson]
	-> inetsetupcustomer [new: conclusionDate, documentationLink]
	-> inetsetupmanageduser [new: associated computers]
	-> inetsetupdomain [new: groups replaced with functionality groups,
						computer list,
						contact list replaces person list]
	-> iconfig has domain, contacts, software

BSP
	funcionality = accounting
	accounting.softwareversion[0] = infoniqa v2023
	accounting.softwareversion[1] = office 365
	accounting.membership[0] = remo.oser [role: head]