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.isoon Proxmox ISO storage. Dropbox holdsnixos.iso.7z+nixos.iso.sha256(ArchiveSync + SevenZipCommand pattern — see Serenacompressed_image_driver_sync). -
Bootstrap SSH: single master NETSetup keypair. Pubkey committed at
src/NETSetup/nixos/netsetup-master.pub, baked intoiso/iso.nixrootauthorized_keys. Privkey shipped with NETSetup binary undersrc/NETSetup/Resources/netsetup-master.key(embedded resource, extracted to/NETSetup/.ssh/netsetup-master.keychmod 0600 on use). -
OS trigger:
Machine.OperatingSystem.Name.StartsWith("Nix")→ nix-backed.OS.NixOS(contracts) routes toNixHostTemplate. New NETSetup-local OS constantsNixNextcloud,NixIPFSdefined inNETSetup.Config.Nix.NetsetupOS(NOT in contracts lib). -
Templates: abstract
NixHostTemplatebase + concreteNixNextcloudTemplate,NixIpfsTemplate. Templates exposeGenerateDefaultNix()(host config body) andGenerateHardwareNix(). Content derived fromsrc/NETSetup/nixos/hosts/default-nextcloud/default.nixanddefault-ipfs/default.nix.GenerateFlakeNix()exists but is no longer called byProvisionNixosVm— see post-implementation pivot below. -
Per-VM emit shape (post-pivot, 2026-04-30): flat
config.nix+hardware.nix+ sopssecrets.yaml+.default.keyat the per-VM generated dir root. No emittedflake.nix— the base nix tree at/iso/nixos-config/flake.nixalready exposesnixosConfigurations.default = mkHost "default" ./config.nix(nixos/hosts.nix:113). Overlaying our flake strippeddisko + lanzaboote + sopsmodules fromsharedHostModules, causingdisko-installto crash withartifacts[1]: unbound variable(nodiskoScriptartifact when disko module isn’t loaded). Reusing the base flake keeps every shared module in scope. -
End-to-end test ISO bootstrap:
ProxmoxNixosNextcloudTestsself-bootstraps every prerequisite in[TestInitialize]plusnixos.isoper run:-
EnsureChocoPackageCommand(nested NETCommand-style wrapper aroundchoco install <pkg> -y, idempotent) ensuressopsis on PATH forSopsCommand. -
EnsureWslDistroCommand(nested NETCommand-style wrapper aroundwsl --import) ensures theNixOSWSL distro is registered.IsDistroRegisteredshort-circuits onwsl -l -qso already-imported distros skip the Dropbox fetch. When absent, the rootfs tarball is pulled fromImages/NixOSWsl.tar.gzon 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 .#isoagainst the localsrc/NETSetup/nixosflake →C:\NETSetupDEV\nixos.iso) builds the ISO, thenpveHost.UploadISOships it sha-driven so unchanged builds skip the wire transfer.
-
-
Why
wsl --importinstead ofwsl --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.shinvoked with-h default(not the per-VM hostname). Per-VM OS hostname identity preserved vianetworking.hostNameset inside the emittedconfig.nixbyNixHostTemplate. -
Installer SOPS age key: single committed pair at
src/NETSetup/nixos/.sops/installer.{key,pub}. Public key pinned asProvisionNixosVm.InstallerAgePublicKeyconstant. Private key shipped via csproj<Content CopyToOutputDirectory="Always" CopyToPublishDirectory="Always">, deployed to/NETSetup/.sops/installer.key(700/600) bynetsetup-first-boot.shsection 4c.ProvisionNixosVm.EmitConfigBundlegenerates a random 32-byte hex LUKS password, encrypts it viaSopsCommandto a per-VMsecrets.yaml, and copies the installer private key into.default.keysoinstall.shL156 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.NixHostTemplatetakes device param. -
Entry point: extend
UpdateMethods.RunProxmoxUpdate(appendEnsureCustomerVms(host, config)at end).InstallCommand.InstallStage.ProxmoxVMsstays a stub. -
New commands live in
NETSetup/NETCommand/— no changes to sharedosisa.NETCommand.NixRemoteInstallCommandcomposesLinuxCommandBaseforssh/scp.QmGuestCommandwraps Proxmox-APInodes/<node>/qemu/<vmid>/agentvia corsinvest client (reuse existingProxmox/Hostwrappers fromosisa.SystemManagement.Proxmox). -
No
System.IO: useAbsolutePath+PhysicalFileOperator. No rawProcess: 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 ofluks_password. Per-VMsecrets.yamlalso carryipfs/swarm_key(multiline psk block/key/swarm/psk/1.0.0/+/base16/+ 64-lowercase-hex) andipfs/cluster_secret(single lineCLUSTER_SECRET=<64-lowercase-hex>, a systemd EnvironmentFile line), both nested under oneipfs:map. Those two are cluster-shared:IpfsClusterSecretsGenerator.EnsureOn(config)mint them ONCE per customer/cluster (RandomNumberGenerator.Fill(byte[32])+Convert.ToHexStringLower), stash on new persistedNETSetupConfig.IpfsCluster(IpfsClusterOptionsholdingIpfsSwarmKey+IpfsClusterSecretVogen VOs), STABLE across re-provision (regen would partition the cluster). Key stringsipfs/swarm_key/ipfs/cluster_secretare consts, MUST matchipfs-private-cluster.nix:297,304.NixIpfsTemplate.GetRequiredSecrets(config)throwMissingIpfsClusterSecretsException : NETSetupExceptionwhen a NixIPFS host emit withconfig.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/bkeys nest (so bothipfs/*group under oneipfs:map). Multiline value → YAML block scalar|(content indent = key indent + 2); single-line → inline-quoted (\+"escaped).SopsCommandnow holdIReadOnlyList<NixSecretEntry> Entries(was flat dictPayload);RenderedYamlInputdelegate to the builder. Templates declare their needs via new virtualNixHostTemplate.GetRequiredSecrets(config)(base =Array.Empty);EmitConfigBundlecallEnsureOn(config)then loopGetRequiredSecretsinto the same builder.InstallNixosVm.Runalso callEnsureOn(config)at top (inline-built configs). -
UPDATE (2026-07-13, non-interactive LUKS): encrypted
disko-installis NOW non-interactive viapasswordFile = "/tmp/luks.key"indisk-layouts.nix(both encrypted + dual-boot luks blocks; alsodisk/encrypted.nix+disk/dual-boot.nix).install.sh(L195-196) write the decrypted sopsluks_passwordto/tmp/luks.key(chmod 600) BEFOREdisko-install, delete it after TPM enroll (L306). WHY: withoutpasswordFiledisko prompt for the passphrase on stdin, get NOTHING over the ssh pipe,luksFormatabort, and the nix closure copy into the still-unmounted/mnt/disko-install-rootRAM 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:
NixHostTemplatedefault 2 GiB / 32 GiB;NixNextcloudTemplate8 GiB / 256 GiB. -
UPDATE (2026-07-13): the
ProxmoxNixosNextcloudTestsISO uploader connect to the Proxmox host by LAN IP, NOT theosisaLib03hostname (DNS not resolvable from every dev box).-
✓ Add NETSetup-local
OSconstants and nix-detection extension: createsrc/NETSetup/Config/Nix/NetsetupOS.cswith public static readonlyOperatingSystem NixNextcloud = OperatingSystem.From("NixNextcloud", OperatingSystemFamily.Linux_GNU)andNixIPFS = OperatingSystem.From("NixIPFS", OperatingSystemFamily.Linux_GNU). Createsrc/NETSetup/Extensions/NixOsExtensions.cswithpublic static bool IsNixBacked(this OperatingSystem os)returning true whenos == OS.NixOSORos.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.nixwith qemu-guest-agent + master pubkey: addservices.qemuGuest.enable = true;andusers.users.root.openssh.authorizedKeys.keys = [ (lib.fileContents ../netsetup-master.pub) ];(createsrc/NETSetup/nixos/netsetup-master.pubplaceholder committed with a real ed25519 pubkey). Keepservices.openssh.enable = true, ensurePermitRootLogin = "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-imageskill + standalone script: add.claude/skills/build-nixos-image/SKILL.md(description: "Build NixOS installer ISO, 7z, sha256, upload to Dropbox") plussrc/NETSetup/nixos/scripts/build-nixos-image.shthat (1)cdto 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)cpboth 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 distronixos(modeled afterupdate-nixos-imageskill). 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/: abstractNixHostTemplatewith propertiesHostname(MachineName),DiskDevice(string, default/dev/sda),UserName(string, defaultadmin),TimeZone(string, defaultUTC),AuthorizedKeys(IReadOnlyList<string>) and virtual methodsGenerateFlakeNix(),GenerateHardwareNix(), abstractGenerateDefaultNix(). ConcreteNixNextcloudTemplateandNixIpfsTemplateoverrideGenerateDefaultNix()with content ported fromsrc/NETSetup/nixos/hosts/default-nextcloud/default.nixanddefault-ipfs/default.nix(literal string). FactoryNixTemplateFactory.ForMachine(Machine machine)returns concrete template based onmachine.OperatingSystem:NixNextcloud→ NixNextcloudTemplate,NixIPFS→ NixIpfsTemplate,NixOS→NixHostTemplategeneric subclassNixGenericTemplate, other → throwsNETSetupException. 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.cswithpublic 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 JSONdata.result[].ip-addresses[]filteringip-address-type == "ipv4", excluding127.0.0.0/8and169.254.0.0/16. Polls every 2s until first match or deadline. ThrowsNETSetupExceptionwithvmIdin 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
NixRemoteInstallCommandundersrc/NETSetup/NETCommand/NixRemoteInstallCommand.csusing NETCommand builder pattern. Builder exposesWithIp(IPAddress),WithHostname(string),WithMasterKey(AbsolutePath keyPath),WithConfigDir(AbsolutePath localGeneratedDir),WithDiskDevice(string)(default/dev/sda),CopyHostSshKeys(bool).Execute()runs: (1)ssh-keyscantarget → 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 aLinuxCommandBaseinternally. ReturnsNixRemoteInstallResultwithSuccess,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.isoexistence AND sha256 match against DropboxNETSetup/ISOs/nixos.iso.sha256. If missing or mismatch: downloadsnixos.iso.7zto temp, verifies sha256, extracts viaSevenZipCommandto/var/lib/vz/template/iso/nixos.iso, logs action. Idempotent — returns silently if match. Lives atsrc/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)insrc/NETSetup/Stages/5-NETSetupLinux/ProvisionNixosVm.cs: (1) buildVirtualMachineentity fromMachine(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) callproxmoxHost.CreateVirtualMachine(vm, autoStart: true), (4)DiscoverVmIpViaQmAgent(vm.Id, TimeSpan.FromMinutes(5)), (5) pick template viaNixTemplateFactory.ForMachine(machine), emit files toAbsolutePath("/NETSetup/generated-nix") / machine.Name, (6) runNixRemoteInstallCommandbuilder with generated dir + master key path, (7) log result, throwNETSetupExceptionon 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)insrc/NETSetup/Stages/5-NETSetupLinux/ProvisionWindowsVmOnProxmox.cs: (1) skip if VM exists, (2) derive ticket + hostname frommachine.Name+ config, (3) invoke existingCreateIsoCommandoffline path (MainISO.CreateOfflineProductionISO+CopyToProxmoxStorageIfApplicable) to produceNETSetup-{ticket}-{host}.isoin/var/lib/vz/template/iso/, (4) buildVirtualMachinewith that ISO as main, stealth hardware iffShouldUseStealthHardware(machine), (5)proxmoxHost.CreateVirtualMachine(vm, autoStart: true). Reuses existingCreateVirtualMachines.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 methodEnsureCustomerVms(ISystemHost host, NETSetupConfig config, IServiceProvider services)called at end ofRunProxmoxUpdate(afterRunBtrfsScrub). Method: (1) connect to localProxmoxvia DI, (2)GetHostAsync(host.MachineName), (3) callEnsureNixosIsoOnProxmoxHostiff 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 diagramsrc/NETSetup/docs/NETSetupProvisionVms.pumlshowing: netsetup-update → EnsureNixosIso → CreateVirtualMachine → qm guest-agent → scp config → ssh install.sh → reboot. Include innetsetup.adocviainclude::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 tosrc/NETSetup.sln -
OPNsense-aligned repo method names: GetAllAsync, GetAsync, AddAsync, SetAsync, DeleteAsync
-
WiFi v7+ only (
/interface/wifipackage, 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(*Nformat) andName(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 intosrc/NETSetup.sln.osisa.Mikrotik.csprojtargets$(DefaultNetTargetFramework), pullsMicrosoft.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*Specrecords (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(carriesStatusCode + ErrorMessage + Detail?, message format"MikroTik {status}: {message}"or"MikroTik {status}: {message} ({detail})"). -
Transport (
Client/MikrotikHttpClient, internal sealedIDisposable): real impl. Basic auth"Basic " + base64(user + ":" + pw)exposed on internalAuthorizationHeadertest 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 keepsReasonPhraseas message. Self-signed cert skip viaHttpClientHandler.ServerCertificateCustomValidationCallback(default on). SharedJsonSerializerOptionswithPropertyNamingPolicy=null+DefaultIgnoreCondition.WhenWritingNull+MikrotikStringConverter. Test-only constructor accepts a pre-wiredHttpClientforFakeHttpMessageHandler. -
JSON (
Json/MikrotikStringConverter, internal sealedJsonConverterFactory): real impl.CanConvertclaims exactlybool | int | long | string.CreateConverteryields 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 withInvariantCultureto avoid German-locale thousands-separator regressions. String converter coerces rawTrue/False/Numbertokens into"true"/"false"/decimal-string so MikroTik fields that occasionally come back as a raw number (e.g.pvid) still bind to astring?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 haveId == null, families without a name field leaveName == null. Concrete entities (task 4) inherit this base + add family-specificstring?/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 overrideabstract string ResourcePath(e.g."interface/bridge").GetAllAsyncdelegatesHttpClient.GetAsync<T>(ResourcePath, ct);GetAsync(id, ct)→HttpClient.GetSingleAsync<T>(ResourcePath + "/" + id, ct).Add/Set/DeletebufferPendingOperationrecords into aList<PendingOperation>;IsDirty == Pending.Count > 0.FlushAsync(ct)iterates pending ops in insertion order:Add → PutAsync(returns saved entity,Applied[]capturessaved.Id ?? ""),Set → PatchAsync(ResourcePath, op.Id, body),Delete → DeleteAsync(ResourcePath, op.Id). Per-optry/catchoverExceptioncaptures intoMikrotikFlushFailure(Id, Error);Pending.Clear()runs unconditionally after the loop. Result typeMikrotikFlushResult(IReadOnlyList<string> Applied, IReadOnlyList<MikrotikFlushFailure> Failed). Pending op carriesPendingOperationKindenum (Add | Set | Delete) + id (empty string for Add until device assigns) + nullableobject?body. No retry, no rollback - caller decides via Applied/Failed split.GetAllAsync+Add+Set+Deletearevirtualso singleton + read-only subclasses can override. -
Entities + repositories (17 resource families): all
internal sealed. Entities inheritMikrotikEntityand 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);DnsSettingsSINGLETON (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);SystemIdentitySINGLETON (system/identity, hostname via inheritedName);SystemResourceSINGLETON + READ-ONLY (system/resource,platform,board-name,version,uptime,free-memory : long?,total-memory : long?-Add/Set/DeletethrowNotSupportedException). Singleton repos overrideGetAllAsyncto wrap the single object in a one-entry array. Repository <→ Contracts spec name drift to bridge in the mapper (task 6):Bridge.Protocol → protocol-modevs specVlanFiltering(spec keeps only Name + VlanFiltering);BridgePort.Tagged(entity, literal wire key) vsBridgePortSpec.TaggedVlans(caller-friendly semantic). -
Unit of work (
UnitOfWork/IMikrotikUnitOfWork+MikrotikUnitOfWork, internal): real impl. Exposes oneIMikrotikRepository<T>getter per resource family (17 props), all backed by a singleMikrotikHttpClientinjected in the ctor.CommitAsync(ct)iterates the 17 repos in declaration order via a privateFlushIfDirtyAsync<T>helper: skips repos with!IsDirty, otherwise awaitsFlushAsyncand 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 isMikrotikCommitResult(IReadOnlyList<MikrotikFlushResult> Results)withHasFailures ⇒ Results.Any(r ⇒ r.Failed.Count > 0). EmptyResults(no repos were dirty) != failure. -
Data store + reference (
DataStore/, all internal): real impl.MikrotikDataStore : IMikrotikDataStore, IDisposableis the canonical HttpClient owner - production ctor(MikrotikConnection)builds a freshMikrotikHttpClient(connection, NullLogger.Instance)+ aMikrotikUnitOfWork(httpClient)and disposes the HttpClient onDispose()(idempotent via_disposedflag). Internal test-seam ctor accepts a pre-built HttpClient forFakeHttpMessageHandler.MikrotikDataStoreReference : IMikrotikDataStoreReferencecarries aMikrotikConnectionand aFunc<MikrotikConnection, MikrotikHttpClient>factory (defaults tonew MikrotikHttpClient(connection, NullLogger.Instance); internal ctor lets tests inject).ExistsAsync(ct)probesGET /rest/system/resourcevia a throw-away HttpClient (using var http = _httpFactory(_connection)), returnstrueon any successful deserialization,falseon anyException(auth failure, timeout, JSON parse, cancellation - all reduce to "not reachable").OpenDataStore()builds a fresh HttpClient +MikrotikDataStore(caller owns disposal).MikrotikDataStoreReferenceProvideris a stateless factory returning a new reference per call. Provider’sGetReferencereturn type tightened fromobjecttoIMikrotikDataStoreReferencein this commit. -
Entity mapper (
Mapping/MikrotikEntityMapper, internal static): real impl. 32 static methods - oneToEntity(XxxSpec)+ oneToSpec(Xxx entity)per family for 16 spec families (SystemResource excluded - read-only, noSpeccounterpart). Mechanical copy-property-to-property, no business logic.ToEntityprojects spec record positionals to entity nullable properties as-is.ToSpeccollapses entity nullables to spec record positionals viaentity.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.Protocolis NOT inBridgeSpecand 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-timeNotImplementedExceptionstub. Ctor takesIMikrotikDataStoreReferenceProvider + ILogger<MikrotikConverge>.RunAsyncflow: (1)Precondition.NotNullon connection + desired; (2) reachability gate viareference.ExistsAsync(ct)→ if false, log warning + returnMikrotikConvergeResult(Reachable=false, Deltas=Array.Empty); (3)using var store = reference.OpenDataStore()→ 16-repo UoW; (4) 16RunResourceAsync(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 thedeltaslist; (5)uow.CommitAsync(ct)wrapped in try/catch (warn + swallow on failure); (6) returnMikrotikConvergeResult(true, deltas.ToArray())with all 16 entries.RunResourceAsync<T>is the per-resource isolation seam: catches everyException, logs warning with the family label, returnsMikrotikResourceDelta(label, 0, 0, 0)so failures appear as zero-delta entries (3-layer failure pattern: per-op viaFlushAsync.Failed, per-family viaRunResourceAsync, global commit via the outer try/catch). Match keys per family:Namefor Bridge/Interface/Bonding/Wifi*/User/DhcpServer; compositeBridge+Interfacefor BridgePort; compositeBridge+VlanIdsfor BridgeVlan;Addressfor IpAddress; compositeDstAddress+Gatewayfor IpRoute;MacAddress(case-insensitive!) for DhcpLease; compositeChain+Commentfor Firewall*; FirstOrDefault for Dns/SystemIdentity singletons. Delete safety guard viaprivate const string NetsetupPrefix = "netsetup-"+IsNetsetupManaged(label): only 5 families have a delete loop (Bridge, Bonding, IpAddress, FirewallNatRule, FirewallFilterRule) and they only delete entries whoseNameorCommentstarts withnetsetup-- manually-created MikroTik resources NEVER get touched. Singleton converges (ConvergeDnsAsync+ConvergeSystemIdentityAsync) treatnulldesired spec as a no-op zero-delta (so non-Liberator hosts can leaveMikrotikDesiredState.Dns = nullandSystemIdentity = nulland the family is visited but does nothing). Interface and DhcpServer converges are Set-only (no Add path - device boots with a fixed set, converge togglesDisabled). 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 withOpnsenseConvergeBridge.Runat lines 177-182) instead of the adoc-spec "explicit Phase 3e step 4 inRunProxmoxUpdate`" - operationally equivalent today since `EnsureCustomerVmsruns insideRunProxmoxUpdate; revisit if/when explicitConfigureProxmoxBridges+RewireVmNicsToVlanshelpers 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); returnsnullif the file is missing.Parse(string)→MikrotikConnection?is a pure helper: empty/whitespace content returnsnull; per-line iteration overSplit('\n', RemoveEmptyEntries)withTrim()strips CRLF, skip-on-blank-or-#, split on first=, value getsTrim('"')to supportKEY="value"shell-style env files. Recognized keys:MIKROTIK_URL,MIKROTIK_USER,MIKROTIK_PASSWORD. Unknown keys silently ignored (forward-compat). Missing required key THROWSNETSetupException("<KEY> missing in mikrotik-api.env")- intentional divergence fromOpnsenseApiCredentials(returns null on incomplete) because a present-but-malformed file means flash-mikrotik wrote it wrong, which is a real bug to surface. The resultingMikrotikConnection.AllowSelfSignedCertificatekeeps its defaulttrue. NETSetup.csproj has aProjectReferencetoosisa.Mikrotik.csprojenabling Contracts visibility. 10/10 tests green inMikrotikApiCredentialsTestscovering 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()onIMikrotikConverge.RunAsync) to match the synchronousEnsureCustomerVmsflow. Best-effort: every failure path log + swallow, NEVER throws past the catch-allExceptionhandler. Phases: (1)ArgumentNullExceptionon null host/config; (2)CredentialsProvider()(defaults toMikrotikApiCredentials.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) privateIsEmptyState4-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-builtServiceCollectionwithAddLogging() + AddMikrotik()ifIMikrotikConvergenot registered); (6) inside try:converge.RunAsync(connection, desired).GetAwaiter().GetResult()→!result.Reachable→ warn "unreachable" + return, otherwise iterateresult.Deltasinfo-logging"MikroTik converge {Resource}: +{Added}/~{Updated}/-{Deleted}"; (7) catch-allException→ warn "converge threw - continuing" with exception attached. TheLiberatorGatecheck lives INSIDE the bridge body (caller minimalism -EnsureCustomerVmscalls unconditionally). Wired intosrc/NETSetup/Helpers/EnsureCustomerVms.csat lines 177-182, immediately afterOpnsenseConvergeBridge.Run, NOT insideRunProxmoxUpdateas the adoc spec suggested - the two REST-converge bridges paired side-by-side is more discoverable, and operational cadence is identical becauseEnsureCustomerVmsitself runs insideRunProxmoxUpdate. 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"withStringComparison.Ordinal) returnsnew 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);BridgePorts4 entries (ether2 + ether3 trunk withPvid=1+ tagged"10,20,250,254"; ether4 + ether5 access withPvid=20+ empty tagged);BridgeVlans4 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 + FirewallFilterRulesempty (OPNsense owns DHCP/NAT/filter);WifiConfigurations3 entries on wifi1/wifi2/wifi3 for SSIDsLiberator+Liberator-Guest+admin-oob-lib, countryCH, channel2ghz-onlyn,5ghz-ax/2ghz-onlyn/5ghz-ax, eachSecurityfield referencing aWifiSecuritySpec.Name;WifiSecurities3 entries with PSK profiles (wpa2-psk,wpa3-pskfor Liberator + open for Guest +wpa3-pskfor admin-oob), passwords fromconfig.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(bothpublic string?; latter doubles as admin user pw AND admin-oob-lib WPA3 PSK - single source of truth). Adoc-spec drift: spec mentionedMachineName.Value, impl uses the realNETSetupConfig.SelectedHostnamefield. 18/18 tests green. -
UPDATE (2026-05-29, dual-WAN inbound): the desired-state builder no longer leaves
FirewallNatRules + FirewallFilterRulesempty for inbound published services - it now emits per-ServedPortnetsetup-dnat-{port}dst-nat +netsetup-allow-{port}forward-accept rules. New constMikrotikDesiredStateBuilder.WanInterfaceList = "WAN". Those rules setInInterfaceList = "WAN"with EMPTYInInterface(NOT literalether1). Reason: behind an XGS-PON modem the WAN arrives tagged on VLAN 10 so ingress isether1-vlan10, notether1; an ether1-pinned port-forward would never match and inbound mail/nextcloud would be dead. Matching the WAN interface-list (which holds BOTHether1+ether1-vlan10) fires the rule on whichever iface carries the WAN. osisa.Mikrotik gainedInInterfaceListonFirewallNatRuleSpec+FirewallFilterRuleSpec(+ entities + mapper + NAT drift-check). DEPENDENCY: the converge REFERENCES theWANlist but does NOT create it - it relies on the flash-mikrotik.rsc(MikrotikRscBuilder, new constWanVlanId = "10") having created theWANinterface-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.MikrotikCommitResultTestscoversHasFailurescomputation across mixed inner outcomes.MikrotikDataStoreReferenceTestscoversExistsAsynctrue on 200, false on refused/401/timeout/non-JSON,OpenDataStorereturns a usable store, factory seam exercised.MikrotikServiceCollectionExtensionsTestsassertsAddMikrotikregistersIMikrotikConverge+IMikrotikDataStoreReferenceProvideras singletons (idempotent on multiple calls).MikrotikEntityMapperTests(+ in94c3e555) coversToEntity/ToSpecround-trip per family + null projection (string → "", bool → false, int → 0) + theBridgePortSpec.TaggedVlans<→BridgePort.Taggeddrift bridge.MikrotikConvergeTests(+ in94c3e555) 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,nulldesired 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.csroute-keyed (mirrorsosisa.OPNSense.Tests);TestInfrastructure/TestValues.cshosts shared constants. (Test agent renamed the auth-header property assertionAuthHeader→AuthorizationHeaderto match the shipped name.)-
✓ Create project scaffold +
MikrotikHttpClient+MikrotikStringConverter+MikrotikException: Createsrc/osisa.Mikrotik/osisa.Mikrotik.csproj(net10,<InternalsVisibleTo Include="osisa.Mikrotik.Tests" />) andsrc/osisa.Mikrotik.Tests/osisa.Mikrotik.Tests.csproj(MSTest + FluentAssertions). Add both tosrc/NETSetup.sln. CreateClient/MikrotikHttpClient.cs(internal) wrappingHttpClientwith: Basic auth header (Authorization: Basic base64(username:password)), self-signed cert skip viaHttpClientHandler.ServerCertificateCustomValidationCallback, typed methodsGetAsync<T>(string path)returningT(GET/rest/{path}),GetSingleAsync<T>(string path)for singleton resources,PutAsync<T>(string path, T entity)returningTwith.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)returningT(POST/rest/{path}). Error responses (HTTP 4xx/5xx) parsed from JSON{"error": N, "message": "…", "detail": "…"}intoMikrotikException. CreateJson/MikrotikStringConverter.cs- customSystem.Text.Json.Serialization.JsonConverterfactory handling MikroTik’s all-strings encoding: deserializes"true"/"false"tobool, numeric strings toint/long, preservesstringpassthrough. Registered on the sharedJsonSerializerOptionsused byMikrotikHttpClient. CreateExceptions/MikrotikException.cswithint StatusCode,string ErrorMessage,string? Detail. Createsrc/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 insrc/osisa.Mikrotik/with all public types.MikrotikConnectionrecord:string BaseUrl,string Username,string Password,bool AllowSelfSignedCertificate = true.IMikrotikConvergeinterface:Task<MikrotikConvergeResult> RunAsync(MikrotikConnection connection, MikrotikDesiredState desired, CancellationToken ct).MikrotikConvergeResultrecord:bool Reachable,MikrotikResourceDelta[] Deltas.MikrotikResourceDeltarecord:string ResourceType,int Added,int Updated,int Deleted. Desired state spec records (all inContracts/):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).MikrotikDesiredStaterecord: 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)registeringIMikrotikConverge+ 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
MikrotikEntitybase +MikrotikRepositoryBase<T>with batch buffering: CreateEntities/MikrotikEntity.cs(internal) - base class with[JsonPropertyName(".id")] string? Id { get; set; }andstring? Name { get; set; }. All MikroTik entity types will inherit from this. CreateRepositories/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. CreateRepositories/MikrotikRepositoryBase.cs(internal abstract): stores pending operations in aList<PendingOperation>(discriminated union of Add/Set/Delete),GetAllAsyncdelegates toMikrotikHttpClient.GetAsync<List<T>>(ResourcePath),GetAsyncdelegates toMikrotikHttpClient.GetSingleAsync<T>(ResourcePath + "/" + id), Add/Set/Delete append to pending list,FlushAsynciterates pending ops pushing via HttpClient (PUT for adds, PATCH for sets, DELETE for deletes) collectingApplied[]andFailed[]intoMikrotikFlushResult, clears pending on success. Abstractstring ResourcePath { get; }. CreateRepositories/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 inheritingMikrotikEntity, each with typed properties (viaMikrotikStringConverter) matching the MikroTik REST API JSON fields.Bridge.cs:string? VlanFiltering,string? Protocol.BridgePort.cs:string? Bridge,string? Interface,int? Pvid,string? TaggedVlans(mapped fromtagged).BridgeVlan.cs:string? Bridge,string? Tagged,string? Untagged,string? VlanIds(mapped fromvlan-ids).RouterInterface.cs:string? Type,string? DefaultName,bool? Disabled,string? Running,string? MacAddress.Bonding.cs:string? Slaves,string? Mode,string? TransmitHashPolicy(mapped fromtransmit-hash-policy),string? LacpRate(mapped fromlacp-rate).IpAddress.cs:string? Address,string? Network,string? Interface,bool? Disabled,string? Comment.IpRoute.cs:string? DstAddress(mapped fromdst-address),string? Gateway,string? Distance,string? Comment.DhcpServer.cs:string? Interface, bool? Disabled`,string? AddressPool.DhcpLease.cs:string? Address,string? MacAddress(mapped frommac-address),string? Comment,bool? Disabled.DnsSettings.cs:string? Servers,bool? AllowRemoteRequests(mapped fromallow-remote-requests).FirewallNatRule.cs:string? Chain,string? Action,string? OutInterface(mapped fromout-interface),bool? Disabled,string? Comment.FirewallFilterRule.cs:string? Chain,string? Action,string? Protocol,string? DstPort(mapped fromdst-port),string? InInterface(mapped fromin-interface),string? Comment,bool? Disabled.WifiConfiguration.cs:string? Ssid,string? Country,string? Channel,string? Security.WifiSecurity.cs:string? AuthenticationTypes(mapped fromauthentication-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. InRepositories/create concrete repos:BridgeRepository(pathinterface/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 throwNotSupportedException). 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-portmapped to DstPort property -
✓ Create UoW + DataStore + DataStoreReference + Provider + DI registration: In
UnitOfWork/createIMikrotikUnitOfWork.cs(internal): exposes all 17 repos as read-only properties (IMikrotikRepository<Bridge> Bridges,IMikrotikRepository<BridgePort> BridgePorts, etc.) +Task<MikrotikCommitResult> CommitAsync(CancellationToken ct). CreateMikrotikCommitResult.cs(internal):IReadOnlyList<MikrotikFlushResult> Results,bool HasFailures. CreateMikrotikUnitOfWork.cs(internal): constructor takesMikrotikHttpClient, instantiates all 17 concrete repos,CommitAsynciterates repos whereIsDirty == true, callsFlushAsyncon each, aggregates intoMikrotikCommitResult. InDataStore/createIMikrotikDataStore.cs(internal):IMikrotikUnitOfWork UnitOfWork { get; },IDisposable. CreateMikrotikDataStore.cs: createsMikrotikHttpClientfromMikrotikConnection, passes toMikrotikUnitOfWork. CreateIMikrotikDataStoreReference.cs(internal):Task<bool> ExistsAsync(CancellationToken ct),IMikrotikDataStore OpenDataStore(). CreateMikrotikDataStoreReference.cs:ExistsAsyncprobesGET /rest/system/resource- returns true on 200, false on any exception (connection refused, timeout, auth failure). CreateIMikrotikDataStoreReferenceProvider.cs(internal):IMikrotikDataStoreReference GetReference(MikrotikConnection connection). CreateMikrotikDataStoreReferenceProvider.cs. In root, updateMikrotikServiceCollectionExtensions.AddMikrotik: registerIMikrotikDataStoreReferenceProvider→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+MikrotikConvergeorchestrator: CreateMapping/MikrotikEntityMapper.cs(internal static) with bidirectional mapping methods between Contracts *Spec records and internal entity types. OneToEntity/ToSpecmethod pair per resource family (e.g.,Bridge ToEntity(BridgeSpec spec),BridgeSpec ToSpec(Bridge entity)). Handle nullable fields. CreateMikrotikConverge.cs(internal, implementsIMikrotikConverge):RunAsyncflow: (1)provider.GetReference(connection), (2)ExistsAsync- if false, returnMikrotikConvergeResult(Reachable: false, Deltas: []), (3)OpenDataStore()→ UoW, (4) per resource family:GetAllAsynccurrent 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), queueAddfor missing,Setfor changed (compare all properties),Deletefor 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) buildMikrotikConvergeResultwith per-resourceMikrotikResourceDelta, (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
MikrotikApiCredentialsenv file parser: Createsrc/NETSetup/Helpers/MikrotikApiCredentials.cswith staticParse(string content)returningMikrotikConnection. 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) throwsNETSetupExceptionwith field name in message. AddTryReadFromDisk(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 insrc/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
MikrotikDesiredStateBuilderfor Liberator VLAN cutover: Createsrc/NETSetup/Helpers/MikrotikDesiredStateBuilder.cswithBuild(NETSetupConfig config, string hostDescription)returningMikrotikDesiredState. ForhostDescription == "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 withconfig.MikrotikAdminPassword(from flash-mikrotik flashed password). For non-LiberatorhostDescription: returnMikrotikDesiredStatewith all empty arrays and null singletons (no-op converge). WiFi passwords sourced fromNETSetupConfigfields (existing WiFi password config or flash-mikrotik defaults). Test class insrc/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 intoRunProxmoxUpdate: Createsrc/NETSetup/Helpers/MikrotikConvergeBridge.cswithRun(ISystemHost host, NETSetupConfig config, IServiceProvider services, ILogger logger). Best-effort pattern (same asOpnsenseConvergeBridge): (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) resolveIMikrotikConvergefromservices(build one-shotServiceCollectionwithAddMikrotik()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 intosrc/NETSetup/Helpers/UpdateMethods.csRunProxmoxUpdateas step 4 from Liberator design (afterEnsureCustomerVms/OpnsenseConvergeBridge, beforeConfigureProxmoxBridges), gated onrunFullNetworkFlow(host.Description == "Liberator" && hasOpnsenseVm). Test class insrc/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 inosisa.SystemManagement.Windows, built onDataStoreReferenceBase5+DataStoreNameDependencyInjectionDataStoreReferenceBuilder5+CanGetSingleUnitOfWorkDataStoreReferenceBase) is already consumed in this repo - seeNETSetup.Tests/ExeTests/GetTests.csandNETSetup.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,
IDiskis the record,ReadAll()is the query. The VM layer is the same sentence with a Proxmox/Hyper-V data store andIVirtualMachineas the record.MachineScopeProvider3 : IDataStoreReferenceProvider3is the provider precedent to copy. -
Suggested UnitOfWork interface - deliberately mirrors
ISystemManagementUnitOfWorkso 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
VirtualMachinesproperty is the ergonomic accessor;unitOfWork.GetRepository<VirtualMachineSettings>()MUST resolve the same instance, so both idioms work and the genericISystemManagementUnitOfWorkhabit carries over unchanged. -
AddReference2→ the real API isAddReference3<TBuilder>(Action<TBuilder>)onDataStoreReferenceProvider3.Builder.osisa.Repositoryexposes exactly two:AddReference(Func<IServiceProvider, ICanGetAnyUnitOfWork>)(Shape A) andAddReference3<TBuilder>(Action<TBuilder>)(Shape B). There is noAddReference2. 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 sameOpenDataStore3<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 tosrc/NETSetup.sln. Target$(DefaultNetTargetFramework). Test project name is{Source}.Tests- capitalT, plurals- per netbase convention. Precedent to mirror for the library shape:osisa.Mikrotik(Contracts-only public surface, internal repositories/UoW/DataStore,InternalsVisibleTofor its.Tests). -
Provider = environment. Two real providers plus one in-memory provider:
-
HyperVVmDataStoreReference5- a Hyper-VManagementScope(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 netbaseUnitOfWorkRepositoryTestsBase3CRUD suite runs against.
-
-
Provider constants live in NETSetup, NOT in netbase:
WellKnownDataStoreProvidersshipsSqlite/SqlServer/Memory/Mock/Excel/Postgres/Restonly. AddNetsetupDataStoreProviders(public static class) withHyperV,Proxmox,MemoryVmstring constants, used by[DataStoreProvider(…)]on each reference. -
VirtualMachineSettingsis a projection, not a competitor, ofosisa.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 byMachineName. The serial is the already-existing primary key of the NETSetup VM model (CreateVirtualMachines.IsForHostparses it) and it survives a rename; a name change must read as anUpdate, notDelete+Insert.VirtualMachineKeyis 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}soReadAllnever drops a record it cannot name. -
Change buffering (batch-then-commit, same as
MikrotikUnitOfWork):Insert/Update/Deletemutate an in-memory pending list and flipIsDirty; nothing touches the hypervisor untilCommit().NoCommittherefore genuinely means "the VM was never touched" - that is the assertion that proves the transaction boundary is real.ReadAllis 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:-
VmOperationenum:Read,Insert,Stop,Change,Start,Delete. -
IVirtualMachineOperations(internal, one impl per provider) exposesRead,Insert,Stop,Change,Start,Deleteas six separate calls. -
Commit()expands one pendingUpdateinto exactly three recorded operations, in order:Stop,Change,Start. -
A VM that was already stopped before the update is NOT restarted -
Startis emitted only when the pre-updatePowerStatewasRunning. Restarting a deliberately-stopped VM would be a behaviour change smuggled in through the abstraction. -
An
Updatewhose diff is empty is a no-op: zero operations recorded, no stop, no restart. Idempotentnetsetup updatecycles must not bounce healthy VMs.
-
-
VmCommitResult(public record) carriesIReadOnlyList<VmOperationRecord> Operations(Key,Operation,Ordinal) plusInserted/Updated/Deletedcounts andFailedkeys. 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 - seeNETSetup.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(ShutdownVirtualMachineAsyncwhen the guest agent is up);Start→StartVirtualMachineAsync;Delete→RemoveVirtualMachineAsync. -
VMHelper.RebootVMandRebootVirtualMachineAsyncare deliberately NOT used to implementUpdate: reboot is one atomic guest operation and would hide theChangestep between stop and start.
-
-
Test targets, from the existing in-repo infrastructure - do not invent new ones:
-
Proxmox: node
osisaLib03, API192.168.3.233:8006, userroot, passworddefault4244$- same constantsProxmoxNixosNextcloudTestsandProxmoxStandaloneWin11NodeTestsalready use. Connect by LAN IP, not hostname:osisaLib03is not DNS-resolvable from every dev box. -
Hyper-V:
LiveMethods.ConnectToLiveLocalSystem(@"root\virtualization\v2"), exactly asVirtualHyperVTestBase.HyperVScopedoes.
-
-
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 abstractExpectedVmCounton 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) andTestContext.WriteLine`s every VM name so the run is diagnosable. Both live classes carry `[TestCategory("local")]andAssert.Inconclusivewhen their environment is absent - the suite must stay green on a build agent with no Hyper-V and no route to192.168.3.233. -
Commit()is fail-fast per record but aggregating across records: a failing record records its partial operations, is added toVmCommitResult.Failed, and the loop continues;Commit()throwsVmDataStoreExceptionat the end listing every failed key - same contract asEnsureCustomerVms.RunCore, so a future wire-in is a drop-in. -
VmDesiredStateBuilder(NETSetup side,public static, pure, zero IO) reusesCreateVirtualMachines.GetOrderedVmsForHostverbatim 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, mirroringMikrotikDesiredStateBuilder.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 inDeletes.VmChangePlanhas noApplyin this iteration - the planner is read-only by design, and that is what keeps the spec behaviour-neutral. -
No
System.IO, no rawProcess:AbsolutePath+PhysicalFileOperatorand 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:osisaRepositoryVersionis pinned at0.19.0-ModelFeature.191while the restoredosisa.Repository.TestInfrastructurecarryingDataStoreReference5TestBaseis0.19.0-ModelFeature.292+. If.191does not shipDataStoreReference5TestBase, bumposisaRepositoryVersionto the netbase-current0.19.0-ModelFeature.292in one isolated commit and re-run the full suite BEFORE starting task 9. -
Test values:
src/osisa.VirtualMachines.Tests/TestInfrastructure/TestValues.cswithSome*constants only (SomeVmName,SomeOtherVmName,SomeHostSerial,SomeVmSerial,SomeMemoryBytes,SomeUpdatedMemoryBytes,SomeCoreCount,SingleItemCount,TwoItemCount,SixOperationCount, …). NoValue, noInteger1.
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 netbaseosisa.Repository.Contracts/Repositories/, deriveIRepositoryBase<TEntity>, and keep the same two member (ReadSoftDeleted()+ExecutePermanentDeletionsAsync(ct)). The oldosisa.Virtualization/Repositories/IPermanentDeletions[TEntity].csis 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 rootNETSetup.adocsection "Deletion is two steps".-
Doc crefs in
osisa.Virtualizationmust now be FULLY QUALIFIED:osisa.Repository.Contractsexport aMachineNameof its own and a bare cref go ambiguous. -
The interface is INVARIANT on purpose. Covariance was tried and dropped -
out TEntitywould foreclose a laterPermanentlyDeleteAsync(TEntity)forever.
-
-
CRUD suites moved v3 → v4. Netbase gained
UnitOfWorkRepositoryTestsBase4[…]plus the[…,TKey]keyed variant: v3 plus SIX soft-delete case, gated onRepositorySupportsPermanentDeletions.VirtualMachineRepositoryCrudTestBase2(and with it the HyperV + Proxmox live suites) sit on v4 now - 8 → 14 case per provider suite.ReadSoftDeletedCount()andExecutePermanentDeletions()arevirtualbecause a live hypervisor is a SHARED store; the base override both and filter throughIsOwn, and the destroy route through the existingExecuteOwnDeletionsguard, which goAssert.Inconclusiverather than destroy a guest the suite did not create. New tests + a fake cover the contract inosisa.Repository.Contracts.Tests.NETConfigDataStoreTests2deliberately 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 changedHyperVVmOperations: a Windows-family row is created with Secure Boot ON and a vTPM, anything else with both OFF, passed as theVirtualBIOSconstructor 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. NewContracts/OperatingSystemExtensions.Is(this OperatingSystem, OperatingSystemFamily)is the one place that answer "is this a Windows row".Warningenabling 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 anUpdatea synchronisation could apply.HyperVVirtualMachineRepositoryCrudTests2therefore 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.Inconclusivewhen the image is absent - the netbaseVirtualMachinector refuse a guest with no ISO), and honourNETSETUP_KEEP_CRUD_GUESTSto 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_Commiton 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.csported. NetbaseUnitOfWorkBase3take anILoggerinstead of anITransaction3and declare an abstractOnCommit(); the transaction is HELD by the unit of work and flushed inOnCommit. 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 insrc/Directory.Packages.propsis 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.UpdateISOfixed. Two defects, both of which produced a SILENT bad outcome:-
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. NowTask.Run) ⇒ command.ExecuteAsync(.GetAwaiter().GetResult(). -
1-byte ISO. The result was logged at
Debugand 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.UpdateISOnow THROW on non-zero exit, and the new privateEnsureIsoBuiltverify 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.
VirtualHyperVTestBasetoday callDisableDynamicMemoryonly 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
0xC000A002STATUS_AUTH_TAG_MISMATCHand 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-v3cannot start on a host without AVX2. osisaLib03 lack avx2/bmi1/bmi2/fma/movbe/abm and QEMU exit code 1.ProxmoxWindowsVmFactory,ProvisionNixosVmandProxmoxVmOperationsalready override tohost; any NEW creation path must, or the guest is created and never start. -
CreateVirtualMachinecan return true whileqmcreatefailed. The failure surface later asFailed to find VirtualMachine … Sequence contains no matching elementfrom the round-trip read-
which read like a READ defect and is not. Read
/nodes/<node>/tasks.
-
-
devboxisois the reason a Proxmox Win11 test take seconds. It is a CIFS mount of the dev boxNETSetupISOshare whosetemplate\isois a junction toC:\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.propsforcelocal.runsettings, whoseTestCategory!=Prodfilter apply at DISCOVERY, so the method is never enumerated and an inherited base-class case pass instead. Use--settings src/prod.runsettings, andFullyQualifiedName~(notName=) 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 insrc/NETSetup.Tests/DataStoreTests/. NOT a separateosisa.VirtualMachinesproject - NETSetup already referenceosisa.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>(sharedVmDataStoreReferenceBase<TSelf>), NOTDataStoreReferenceBase5direct - a VM store serve exactly ONE unit of work type, which is the same reasonMachineScopeReference5use that base. -
Builders are plain nested
Builderclass, notDataStoreNameDependencyInjectionDataStoreReferenceBuilder5: that base demandSetDataStore+ DI plumbing this store does not need, and the live handle (management scope, Proxmox host) is injected, never serialised. -
VirtualizationDataStoreReferenceProvider3wrap each single-UoW reference in the netbaseSingleAsAnyUnitOfWorkDataStoreReference<IVirtualizationUnitOfWork>soGetReferences()satisfyIDataStoreReferenceProvider3.AddReference2does not exist inosisa.Repository- see the decision above. -
VirtualMachineKeyis a plainstringon 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")yieldValue == "osisaLib03"andHostedId == "9000"- the vmid is SPLIT OFF. Keying on.Valuecollide 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_ShouldReturnUniquelyKeyedRecordsis what caught it. -
Still open from the task list below: the in-memory provider (task 4), the cross-provider
GetReferences()walk (task 9), the netbaseDataStoreReference5TestBase/UnitOfWorkRepositoryTestsBase3suite (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 fromnetsetup 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/DeleteonlyEnqueueinto_pending. All five mutating seam call (InsertAsync,DeleteAsync,StopAsync,ChangeAsync,StartAsync) live insideFlushAsync/ApplyUpdateAsync, andFlushAsynchave exactly ONE caller repo-wide:CommitAsync.FlushAsyncandRollbackare deliberately NOT onIVirtualMachineRepository, so no consumer can flush without committing. Read is live and unbuffered by design - a pending insert is invisible toReadAlluntil commit, declared on the contract. -
NOT PROVED BY DERIVING netbase
UnitOfWorkRepositoryTestsBase3, which is what this repo normally use. Environment blocker, not a choice:osisaRepositoryVersionis pinned at0.19.0-ModelFeature.191, NO csproj in netsetup consumeosisa.Repository.TestInfrastructureso that version was never pulled into the local NuGet cache, and the GitHub Packages feed answer 403 without credential.VersionOverrideto.300fail too - its transitiveosisa.Json/osisa.Stringsat.300are equally absent. DO NOT fix this by bumpingosisaRepositoryVersion: this file record that.292change theUnitOfWorkBase3ctor and require portingosisa.OPNSense.Rest, and that the last attempt measured 188 build error and 0 runnable test. The real fix is aread:packagestoken plus a singlePackageReferenceat.191- every.191transitive dep is already cached.SUPERSEDED (2026-08-30): the hold is released.
osisa.OPNSense.RestIS ported to the newUnitOfWorkBase3(ILoggerctor + abstractOnCommit(), 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
VirtualMachineRepositoryCrudTestsmirror the six base case test-for-test (Insert/Update/DeletexCommit/NoCommit), assert on READ-BACK STATE the way the base do, and addCommitAsyncvariant, 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
Insertapply immediately turn 8 test red. (2) EmptyingRollback()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_ShouldClearTheBufferItselfassert the buffer directly and now catch it. -
CommitAsyncnow refuse a DISPOSED unit of work (ObjectDisposedException). It used to flush, inert only as a side effect ofRollbackclearing. -
ChangeAsyncDEFECT, fixed by refusing rather than pretending. Both provider apply NO field. Driven through the update path that mean: stop the guest, change nothing, restart, reportUpdated++for work never done - and since nothing was applied the same difference persist, so EVERY reconciliation bounce the guest again.IVirtualMachineOperations.CanApplyChangesnow declare the capability, both provider return FALSE, and the repository refuse the update BEFOREStopAsync. The ORDER is the point: throwing from insideChangeAsyncwould leave the guest POWERED OFF, because a per-record failure is caught and the restart never run. Same rule the provider already follow forNetworkName/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_ShouldApplyTheChangeprove the REPOSITORY buffering, never a provider’s CRUD. No provider apply fields yet, so there is nothing to prove on that side untilChangeAsyncis 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
VirtualMachineBuildercarry no key, and that is the NORMAL update path. Matching it needed three corrections. (1) The builder used to inventFrom(name), a third key shape no provider project - removed. (2) A name-based fallback compared case-SENSITIVELY (MachineNamedo 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 projectFromSerialwhile a keyless row key asenv/name. Final shape:ResolveLiveRE-KEY every live row asFromEnvironment(EnvironmentName, r.Name)and compare that, so there is ONE comparison and it is always a normalisedVirtualMachineKey. 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_ShouldReturnUniquelyNamedRecordsalongside 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.VmCommitResultnow carryNotFound, andApplyUpdateAsyncreturn anUpdateOutcometri-state (Applied / Unchanged / NotFound) so a MISS is caller-visible and testable rather than only a log line. -
NAMING, round 4:
VmOperationRecordandVmCommitFailurenow carryMachineName Nameas 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 asfailed 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 madeWithLoggera no-op whenever the service provider happened to supply a logger.WithLoggeris wired at all four live test call site. -
ResolveIpAddressreturnIPAddress?, 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:
ChangeAsyncon 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 twoReadoverload stay sync-over-async; andVmConnectionStringBuilder/VirtualizationDataStoreReferenceProvider3/VirtualizationDataStorehave 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 insrc/osisa.Virtualization/(Contracts/ ValueObjects/ DataStore/ Operations/ Repositories/ UnitOfWork/ Exceptions/) plussrc/osisa.Virtualization.Tests/, both registered insrc/NETSetup.sln, consumed by NETSetup and NETSetup.Tests viaProjectReference(the in-solution convention - the PackageReference rule is for CROSS-solution deps). ONE project with aContracts/folder, following osisa.Mikrotik / osisa.OPNSense / osisa.Inventory rather than the two-assembly split.VmDataStoreExceptionbecameVirtualizationException : Exception- a library must not depend on the app assembly. -
VALUE OBJECTS: new Vogen
VirtualMachineKey(IKeyOf<VirtualMachineSettings>, only two factories -FromSerial(SerialNumber)andFromEnvironment(MachineName, MachineName)),VmId,VirtualSwitchName,StorageName,WmiNamespace. REUSED rather than reinvented:MachineName,OperatingSystem,DataSize(twice).CoresstayintandAutoStartstaybool- a genuine count and a genuine two-state. -
THE
IsoSPLIT is the substantive design change. Onestringused to mean an absolute local path on Hyper-V and a storage-relative name on Proxmox, which is why the test base could only assertEndWith("nixos.iso"). The row now carryISubPath Iso(the NAME) plusStorageName IsoStorage, and each provider resolve it against its own root - Proxmox against the storage, Hyper-V through the newIBootImageResolver(implemented byNETSetup.Helpers.BootImageResolverover the keyed operator). The identical row now go to both hypervisor unchanged. -
ASYNC:
IVirtualMachineOperationsis Task-returning, the repository flush is async, and the unit of work implementICanCommitAsync.CommitAsyncis the real path; the synchronousCommit()delegate to it at the OUTER boundary only, because netbaseIUnitOfWorkdeclare a synchronousCommit. OnlyCommit()is genuinely FORCED (netbase declare it synchronous with no async counterpart); the twoReadoverload are a second and third sync-over-async point that could be lifted, because netbase also shipICanReadAsync<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.DescribesSameMachineAsnow compare only the fieldsChangecan apply. Also: Proxmox insert now honourrecord.VmId(WithVmId(PickFreeVmId(…))used to be silently dropped); a failed record no longer escapeFlushleaving the unit of work dirty; the real exception survive intoVmCommitFailureand the aggregate throw; Hyper-VStop/Startthrow 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 onNetworkName. A field a provider drop without a word is the same class of lie as the dual-meaningIsowas. -
VOGEN BUG FOUND: a
[Instance]carrying backslashes is emitted into a NON-verbatim literal without re-escaping, soroot\virtualization\v2silently becamerootirtualization2(the\vpair read as a vertical tab) and every Hyper-V connection failed. Anything with a backslash must be built throughFromin a plain static property instead.WmiNamespace_HyperV_ShouldKeepItsBackslashesguard it. -
KEY MATCHING, fixed after review:
VirtualMachineBuildermust NOT invent a key. It used to default toFrom(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:
VirtualMachineKeydocumented case-insensitive comparison but only an explicitComparisonValueuppercased - the generated==/GetHashCodestayed ordinal, and the repository dictionary use the DEFAULT comparer. A VogenNormalizeInputnow lower-case on creation so every consumer obey one rule. Null is passed THROUGH that hook on purpose: Vogen run it BEFOREValidate, so coalescing there would turnFrom(null)into a silent empty key. -
NETSetupFileOperators.Iso2renamed toBootImages(+DefaultBootImagesRoot/BootImagesRoot) - numbered names say nothing, and the key needed a five-line comment to justify itself. -
The raw
ProcessStartInfoin the Hyper-V test is gone. Two nested NETCommand facade command (GetVmNetworkAdapterCommand,NeighborLookupCommand) delegate toPowershellCommand.Builder().UseEncoded(), carry aRenderedShellCommandtest 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.FindIPByMACto find a guest on the Default Switch. It actively ARP-SWEEP every address of the subnet, and that switch is a /20 - roughly 4000 synchronousSendARPcalls, which crashed the test host unscoped and outlasted the whole boot timeout when scoped. READ the neighbour cache withGet-NetNeighborinstead; 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 aWith…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. -
VirtualMachineSettingsgained OPTIONAL init property (Iso,NetworkName,Storage,IsoStorage,AutoStart). The positional signature is untouched, so every existing construction keep compiling - purely additive. -
Inserton both provider now realise those value with the SAME hardware shapeProvisionNixosVmalready use:Processor+RandomAccessModule+VMDiskon Proxmox, and theVirtualMachine(name, iso, switch, disk, cpu, ram)ctor on Hyper-V. The 4-arg Hyper-V ctor default to 4 cores / 4 GiB, so aWithCores/WithMemorywould 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.ModifySettingDatawas 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 Deviceon 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 intonixos.isorootauthorized_keys), thenuname -aexecuted and asserted - a half-open socket cannot pass. Both guest answerLinux nixos-installer 6.12.78 … x86_64 GNU/Linux.TestIpfsNixProxmoxon osisaLib03 andTestIpfsNixHyperVon 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 underC:\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: Createsrc/osisa.VirtualMachines/osisa.VirtualMachines.csproj(targets$(DefaultNetTargetFramework),<InternalsVisibleTo Include="osisa.VirtualMachines.Tests" />, referencesosisa.Repository,osisa.Repository.Contracts,osisa.Specifications,osisa.Validation,osisa.SystemManagement.Contracts,Microsoft.Extensions.DependencyInjection.Abstractions,Microsoft.Extensions.Logging.Abstractions) andsrc/osisa.VirtualMachines.Tests/osisa.VirtualMachines.Tests.csproj. Add both tosrc/NETSetup.sln. Createnamespace osisa.VirtualMachines.Contractswith:VirtualMachineKey(Vogen VO overstring,From(SerialNumber)+From(int vmId, string hostName)fallback),VmPowerStateenum (Unknown,Stopped,Running),VmOperationenum (Read,Insert,Stop,Change,Start,Delete),VirtualMachineSettingsrecord (VirtualMachineKey Key,string Name,string OperatingSystem,int VmId,long MemoryBytes,int Cores,long DiskBytes,VmPowerState PowerState),VmOperationRecordrecord (VirtualMachineKey Key,VmOperation Operation,int Ordinal),VmCommitResultrecord (IReadOnlyList<VmOperationRecord> Operations,int Inserted,int Updated,int Deleted,IReadOnlyList<VirtualMachineKey> Failed),VmDataStoreException : Exception,NetsetupDataStoreProvidersstatic class (HyperV,Proxmox,MemoryVmconsts). 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+VirtualMachineRepositoryBasewith buffered Insert/Update/Delete: CreateContracts/IVirtualMachineRepository.csasIGenericRepository<VirtualMachineSettings>so it inherits netbaseICanRead,ICanInsert,ICanUpdate,ICanDeleteofVirtualMachineSettings. CreateRepositories/VirtualMachineRepositoryBase.cs(internal abstract): holdsIVirtualMachineOperations Operations(injected), a pending-change list, andbool IsDirty.Insert/Update/Deleteonly enqueue;ReadAll/ReadSinglego straight through toOperations.Read(always live).FlushAsyncapplies pending changes in the fixed order Delete → Update → Insert and returns theVmOperationRecordlist, expanding eachUpdateintoStop→Change→Start(Startomitted when the pre-update record’sPowerState != Running; the whole triple omitted when the diff is empty). Clears pending +IsDirtyon 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+VirtualizationUnitOfWorkwithCommit: CreateContracts/IVirtualizationUnitOfWork.cs(: IUnitOfWork) exposingIVirtualMachineRepository VirtualMachines { get; }- shaped after the existingISystemManagementUnitOfWorksoGetRepository<VirtualMachineSettings>()resolves the SAME instance as the named property. CreateUnitOfWork/VirtualizationUnitOfWork.cs(internal,: UnitOfWorkBase3where the base fits, else the interface directly): ctor takesIVirtualMachineOperations+ILogger.Commit()flushes the repository whenIsDirty, aggregates per-record failures intoVmCommitResult.Failedand throwsVmDataStoreExceptionlisting every failed key; a cleanCommit()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): CreateDataStore/VmConnectionStringBuilder.cs(: DbConnectionStringBuilder,DataStoreName+HostName+ nestedValueNamesclass) andDataStore/MemoryVmDataStoreReference5.csdecorated[DataStoreProvider(NetsetupDataStoreProviders.MemoryVm)], derivingDataStoreReferenceBase5<VmConnectionStringBuilder, MemoryVmDataStoreReference5>. OverrideExists(),EnsureDeleted(),GetName()(→DataStoreName.From(ConnectionStringBuilder.DataStoreName)) andOpenDataStoreImplementation<TUnitOfWork>(out bool isNew). NestedBuilder : DataStoreNameDependencyInjectionDataStoreReferenceBuilder5<MemoryVmDataStoreReference5, VmConnectionStringBuilder, Builder>withWithDataStoreName+WithSeed(params VirtualMachineSettings[]). BackingInMemoryVmOperationskeeps records in a dictionary keyed byVirtualMachineKeyand records everyVmOperationit 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: CreateDataStore/HyperVVmDataStoreReference5.csdecorated[DataStoreProvider(NetsetupDataStoreProviders.HyperV)], sameDataStoreReferenceBase5shape, nestedBuilderwithWithDataStoreName+WithManagementScope(ManagementScope).Exists()probes the scope. CreateOperations/HyperVVmOperations.cs(internal: IVirtualMachineOperations):Read→ManagementScope.ReadAllVirtualMachines()projected toVirtualMachineSettings;Insert→ManagementScope.InsertVirtualMachine(already DB-shaped - seeNETSetup.Helpers.VMHelper.CreateVM);Stop→VMHelper.StopVM;Start→VMHelper.StartVM;Delete→RemoveVirtualMachineAsync/DestroyVirtualMachine;Changeapplies diffed fields only.VMHelper.RebootVMMUST NOT appear - reboot collapses stop and start into one guest operation and would hide theChangestep. 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: CreateDataStore/ProxmoxVmDataStoreReference5.csdecorated[DataStoreProvider(NetsetupDataStoreProviders.Proxmox)], nestedBuilderwithWithDataStoreName+WithHost(PveHost).Exists()pings the node through the existingosisa.SystemManagement.Proxmoxwrapper. CreateOperations/ProxmoxVmOperations.cs(internal: IVirtualMachineOperations):Read→Host.GetVirtualMachines()projected toVirtualMachineSettings;Insert→Host.CreateVirtualMachine;Stop→StopVirtualMachineAsync(ShutdownVirtualMachineAsyncwhen the guest agent responds);Start→StartVirtualMachineAsync;Delete→RemoveVirtualMachineAsync;Changeapplies diffed fields only.RebootVirtualMachineAsyncMUST NOT be used forUpdate. No new hypervisor code - every call goes through the existing wrapper behind theIVirtualMachineOperationsseam 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
VirtualizationDataStoreReferenceProvider3wiring BOTH providers on one builder: CreateDataStore/VirtualizationDataStoreReferenceProvider3.cs(: IDataStoreReferenceProvider3, Shape B - hold an innerDataStoreReferenceProvider3.Builderand delegateGetReference/GetReferences), registeringAddReference3<HyperVVmDataStoreReference5.Builder>(…)ANDAddReference3<ProxmoxVmDataStoreReference5.Builder>(…). NOTE: the API isAddReference3<TBuilder>(Action<TBuilder>);osisa.Repositoryhas noAddReference2. Store names are consts on the class:LocalHyperVStoreName = "LocalHyperV",OsisaLib03StoreName = "osisaLib03". MirrorsMachineScopeProvider3fromosisa.SystemManagement.Windows. PARTIAL (2026-08-24): the provider + builder landed and the store key moved to the test side asDataStoreTests/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> : HostingUnitTestsBaseholding the ENTIRE scenario: build the reference fromConfigureBuilder,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.Inconclusivewhen false). Then createReadAll/HyperVVirtualMachineReadAllTests.cs(ConfigureBuilder→WithManagementScope(LiveMethods.ConnectToLiveLocalSystem(@"root\virtualization\v2")),ExpectedVmCount→scope.ReadAllVirtualMachines().Count()) andReadAll/ProxmoxVirtualMachineReadAllTests.cs(ConfigureBuilder→WithHost(proxmox.GetHost("osisaLib03"))against192.168.3.233:8006root/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(): Createsrc/osisa.VirtualMachines.Tests/ReadAll/VirtualizationDataStoreReferenceProvider3ReadAllTests : HostingUnitTestsBase,[TestCategory("local")]. ResolvesVirtualizationDataStoreReferenceProvider3, iteratesGetReferences(), and for EACH reference runs the identicalOpenDataStore3<IVirtualizationUnitOfWork>()→GetUnitOfWork()→VirtualMachines.ReadAll()walk, writing"{storeName}: {count} VMs"per reference. This is the validation that oneAddReference3registration 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): Createsrc/osisa.VirtualMachines.Tests/TestInfrastructure/TestValues.cs(Some*constants only). CreateMemoryVmDataStoreReference5Tests : DataStoreReference5TestBase<MemoryVmDataStoreReference5, MemoryVmDataStoreReference5.Builder>-[TestClass],[TestCategory(DataStoreReferenceTest)],DefaultUnitOfWorkTestMapping ⇒ new(typeof(IVirtualizationUnitOfWork), typeof(VirtualizationUnitOfWork)),FromConnectionString,ConfigureregisteringAddUnitOfWorkMapping<IVirtualizationUnitOfWork, VirtualizationUnitOfWork>(), plusAssertConnectionString/AssertDataStoreType/AssertFromConnectionString/ConfigureBuilder. CreateHyperVVmDataStoreReference5TestsandProxmoxVmDataStoreReference5Testsfrom the same base against stubbed operations. CreateVirtualMachineRepositoryCrudTests : UnitOfWorkRepositoryTestsBase3<VirtualMachineSettings, IVirtualMachineRepository, IVirtualizationUnitOfWork>implementingCreateDataStore,CreateEntity(index, unitOfWork)($"{SomeVmName}_{index}"),CreateUnitOfWork,ExpectedInitialCount,GetRepository,OnEnsureDeleteDataStore,UpdateEntity(bumpMemoryBytes). 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 againstVmCommitResult.Operations(orderedVmOperationRecordlist), 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): Createsrc/NETSetup/Helpers/VmDesiredStateBuilder.cs(public static).Build(NETSetupConfig config, SerialNumber hostSerial)→IReadOnlyList<VirtualMachineSettings>. MUST delegate ordering toCreateVirtualMachines.GetOrderedVmsForHost- do not reimplement the OPNsense-first / DC-second / NixDns-last rule. Maps eachMachineto aVirtualMachineSettings(KeyfromSerialNumber,NamefromMachineName,OperatingSystemfromMachine.OperatingSystem.Name, sizing from the same template defaultsProvisionNixosVmalready 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): Createsrc/NETSetup/Helpers/VmDataStorePlanner.cs(public static) +VmChangePlanrecord (IReadOnlyList<VirtualMachineSettings> Inserts,Updates,Deletes).Plan(desired, live, hostSerial)diffs onVirtualMachineKey: 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 onhostSerial(CreateVirtualMachines.IsForHost). A hand-created VM, or one belonging to another host, is never proposed for deletion.VmChangePlanhas noApplyin this iteration - read-only by design sonetsetup updatebehaviour 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 : UnitTestsBaseproving the new layer changed nothing.EnsureCustomerVms.RunCoremust still produce the exact outcome sequence it produces today for the same input, and must not reference the new types.UpdateMethods.RunProxmoxUpdatemust not callVmDataStorePlanneror 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-rootnetsetup.adocexplaining the record/provider/Insert/Delete/stop-change-restart model, the Hyper-V and Proxmox provider mapping, theAddReference3two-provider registration, and that the planner is read-only for now. Addsrc/NETSetup/docs/NETSetupVmDataStore.pumlshowingNETSetupConfig→VmDesiredStateBuilder→VmDataStorePlanner→IVirtualizationUnitOfWork.Commit→ Delete / Stop-Change-Start / Insert against the provider. Add a siblingNetsetupAdocVmDataStoreTestsnext toNetsetupAdocProvisionVmsTests, anchoring the repo root on.gitexactly 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 intosrc/NETSetup.sln. Same shape asosisa.Mikrotik/osisa.OPNSense: packable lib (Package.Build.props),$(DefaultNetTargetFramework),InternalsVisibleTothe test project. -
Three probe behind one
IHostProbeinterface, scanner run ALL per address and merge:ArpHostProbe(Win32SendARP→ MAC, Windows + same-segment only),IcmpHostProbe(Ping→ round-trip, cross-segment but silent on ICMP-blocking host),TcpPortHostProbe(TcpClientover 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. -
ScanTargetExpanderCOUNT before it materialize → mistyped/8throwScanTargetLimitExceededExceptioninstead of 16 million probe. Concurrency bounded viaSemaphoreSlimfromInventoryScanRequest.MaxConcurrency. -
Reuse osisa VO for address / port / name:
IP,Port,MachineNamefromosisa.SystemManagement.Contracts. New Vogen VO only where none exist:MacAddress(canonicalAA:BB:CC:DD:EE:FF+FromAnyFormatfor dash/dot/bare/lowercase wire input),OuiPrefix,VendorName(withUnknowninstance). -
MacAddress.TryFromAnyFormatuseout MacAddress?NOTout MacAddress- Vogen VOG009 prohibit handing outdefault. -
DeviceClassifierrule are ORDERED most-specific-first, port + vendor only. SMB+SSH → Nas BEFORE the Windows rule, else every NAS read as Windows. -
OuiVendorTable.Defaultis 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. -
ArpHostProbemust qualifySystem.OperatingSystem.IsWindows()-osisa.SystemManagement.Contracts.Elementsalso defineOperatingSystem. -
Library stand ALONE: no CLI verb, no
netsetup updatehook, noNETSetupConfigfeed. Deliberate - keep the scope to the project. -
"Scan the whole network" need NO configured range:
ILocalNetworkProvider/LocalNetworkProviderread the machine own NIC and emit CIDR specifier straight intoInventoryScanRequest.Targets. Skip down link, loopback, tunnel, link-local169.254.*, non-contiguous netmask, and anything wider thanInventoryScanDefaults.MinimumLocalPrefixLength(/22). Floor exist because a NIC on a/16is 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 asPhysicalOpnsenseIntegrationTests. Never run in a normaldotnet test. -
LocalNetworkProviderTestsDO 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
SendARPagainst 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 block00:FF:*) report all 254 address of192.168.4.0/24as device.NetworkScanner.RemoveProxyArpArtifactsnow drop a group when the address answered ARP ONLY and more thanInventoryScanDefaults.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 overMacAddress,OuiPrefix,ScanTargetExpander(incl. limit ceiling),LocalNetworkProvider,OuiVendorResolver,DeviceClassifier,NetworkScanner(responding + silent host),AddInventoryregistration. -
✓ Whole-network scan:
ILocalNetworkProvider+LocalNetworkProvider+InventoryScanDefaults.MinimumLocalPrefixLength, registered inAddInventory. -
✓ Live example
src/osisa.Inventory.Tests/LiveNetworkScanIntegrationTests.cs- 3 test (sweep every attached network / assert answering method recorded / sweep env-configured range viaNETSETUP_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 byNetworkScannerProxyArpTests(ARP-only shared MAC → dropped) andNetworkScannerSharedMacWithRealEvidenceTests(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.LanSweeperonly 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/DCOMZugriff verweigert, WinRM filtered) and SNMP enabled on exactly 1 of 254 device. -
❏ OPEN (supersedes the
sourceIpfix): wrap nmap behind a NETCommand builder + parse-oXXML, keeposisa.Inventorydomain model on top. Retire the curated OUI table AND theArpHostProbesourceIp = 0defect 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.Restclient 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 verweigertand WinRM 5985 isfiltered. Needs a dedicated scanning account with local-admin + firewall rule before ANY tool can reach LanSweeper depth. -
❏ OPEN:
scan-inventoryCLI verb insrc/NETSetup/CLI/Commands/Network/writing JSON via the existingScanOutputPath/ScanProbeRunneroutput convention. -
❏ OPEN: load full IEEE MA-L registry from file instead of the curated table.
-
❏ OPEN: feed discovered device into
NETSetupConfig/ customer.csdraft, 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 orosisa.Rest.RepositoryUnitOfWork, not rawHttpClient.
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 |
Booted from a NETSetup ISO / USB stick (WinPE) |
|
Proxmox / Linux host |
|
Dev box, from source |
|
Rules that hold for every command:
-
Elevation. Anything that write to machine (
install,update,create-iso,install-drivers,backup,restore, disk + edition + domain commands) callExitIfNotElevatedand abort withNEEDS TO BE RUN PRIVILEGED (AS ADMINISTRATOR)when not elevated. Read-only commands (get-,check-,list-*,info) run unelevated. -
Exit codes.
0success,1failure - nothing else. Scripts can branch on that alone. On failure message also land in timestamped<yyyy-MM-ddTHH-mm-ss>.errfile next to logs. -
Logs. Under
LoggingOptions.LogsPathOverridefromappsettings.json, else/NETSetup/Logs. -
Config lookup is automatic. Commands that need customer config (
install,update) resolve it by machine’s own serial number: serial → matchingConfig/<serial>#<ticket>.mapMapFile →#<ticket>.json. Nothing to pass by hand.installaccept--ticket/--hostnameto override that detection;updatetake 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 (DropboxNETSetup/BOOT/…). Paths passed toensure-*are relative to that boot directory, not to local disk.
6.1.2. Command Index
All 60 commands, grouped as registered in src/NETSetup/CLI/NETSetup.cs:
| Group | Commands |
|---|---|
Core lifecycle (6) |
|
Build (3) |
|
Info / diagnostics (8) |
|
Hash (13) |
|
Network (12) |
|
Disk (5) |
|
Windows (8) |
|
Files (4) |
|
Dev (1) |
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 triggerupdate.
# 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 (
EnsureLatestBinaryOrReexecon 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 runEnsureProxmoxPostInstall,EnsureCustomerVms, andEnsureCustomerLxcsto provision and converge the customer VM/LXC stack (see How it works).
# 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-hostis set). Falls back toProxmoxOptions.Passwordinappsettings.jsonwhen 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:9for PVE 9 / Debian 13trixie). -
--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:
-
Probe initial
pveversionand free root filesystem capacity (/). -
Reclaim root disk space (
journalctl --vacuum-size=200M, remove.gz/.1/.oldrotated logs, truncate oversized active logs> 100 MB, clean/tmpand/var/tmp,apt-get clean) and offload/var/cache/apt/archivesonto/var/lib/vz/apt-archivesvia symlink when/var/lib/vzhas greater free capacity. -
Normalize APT sources (
bullseye/bookworm/trixie), disable enterprise.listand.sourcesrepositories, and configurepve-no-subscription(pluspbs-no-subscriptionwhen Proxmox Backup Server is installed). -
Execute non-interactive
dpkg --configure -a,apt-get update,apt-get dist-upgrade -y(--force-confdef,--force-confold), andapt-get --purge autoremove -ywithin the current major version. -
When
currentMajor < targetMajorand--minor-onlyis not set, step through in-place major release upgrades (PVE 7 → 8andPVE 8 → 9) including UEFIgrub-efi-amd64protection, LVM autoactivation migration, conflicting meta-package removal, andpve7to8/pve8to9checks. -
Re-apply the subscription nag-removal hook, verify the updated
pveversion, and report initial/final versions and whether a kernel reboot is required.
-
# 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\SYSTEMwhen omitted). -
--target <path>(optional): Target backup folder (auto-detected by probing USB drives for\NETSetup\NETSetup.exewhen 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).
# 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>\ontoC:. Profiles restored via robocopy withNTUSER.DAT/UsrClass.dat/AppData\Localexcluded (would corrupt fresh registry/cache).AppData\Roamingrestored 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 ininstallflow on Windows side just before update phase. Idempotent viaBackupRestoredlog marker.
# 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.infdriver packages. -
--recursive(optional): Walk subdirectories recursively.
-
-
Behavior: Install all
.infdrivers in<path>viapnputil /add-driver /install.
.\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 intoNETSetup-<ticket>-<hostname>.iso. Require--ticketand--hostname. -
--reusable(optional, mutually exclusive with--offline): Bake images + drivers into ONENETSetup.isowith NO per-ticket config; each guest resolve its own config online by serial number (pair withpublish-config). Need neither--ticketnor--hostname. -
--output <path>(optional): Target output directory for generated ISO. -
--ticket <ticket>(optional/conditional): Customer ticket ID for--offlinemode. -
--hostname <hostname>(optional/conditional): Target machine hostname for--offlinemode. -
--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:
-
release (no mode flag) - plain
NETSetup.iso, no images, no config. Guest need network. -
offline (
--offline) - self-contained per-machineNETSetup-<ticket>-<hostname>.iso. -
reusable (
--reusable) - sharedNETSetup.isowith images + drivers baked in. On Proxmox hosts finished ISO is copied to queried ISO storage location.
-
# 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-x64NETSetup.exe,linux-x64NETSetup,linux-musl-x64NETSetup.musl) intooutput/artifacts/and write SHA256.adocsidecar files. Does not build an ISO. Used by CI/CD deployment workflow.
.\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.jsonor local Dropbox sync). -
Options:
-
--ticket <ticket>(required): Ticket number (4711and#4711both normalize to#4711.json). -
--config <path>(optional): Path to generated JSON file; defaultConfig/#<ticket>.jsonunder current directory.
-
-
Behavior: Upload customer config
<ticket>.jsonand its per-machineConfig/<serial><ticket>.mapMapFiles to remote source (Dropbox), WITHOUT building an ISO. Online counterpart to--offline: once published, a guest booted from a--reusableNETSetup.isoresolve its config by serial against uploaded MapFiles.
.\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.
.\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.).
.\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.
.\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).
.\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).
.\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, whereLogsRoot=appsettings.jsonLoggingOptions.LogsPathOverrideelse/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.
.\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.
.\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.LogsPathOverrideor/NETSetup/Logs) for testing file logger rotation and sinks.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>.
.\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>. Exit0on match,1on mismatch.
.\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>. Exit0on match,1on mismatch.
.\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>. Exit0on match,1on mismatch.
.\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.
.\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.
.\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.
.\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.
.\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:FForAA-BB-CC-DD-EE-FF). -
--broadcast <ip>(optional): Broadcast IPv4 address (default255.255.255.255). -
--port <port>(optional): UDP target port (default9).
-
-
Behavior: Send Wake-on-LAN (WoL) UDP magic packet to wake single target machine.
.\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.
.\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 gon Linux / NIC power management + advanced properties on Windows).
.\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>.
.\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
0when online,1when offline.
.\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.
.\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 asscan-hardware({LogsRoot}/hwscan-<timestamp>.json). -
--no-self(optional): Skip probing local scanning machine.
-
-
Behavior: Probe current machine + remote Windows hosts via PowerShell
Invoke-Commandhardware inventory collector. Unreachable hosts logged as failures without aborting; output JSON include per-host status and hardware details.
.\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 (defaultLiberator). -
--wifi-password <pw>(optional): Wi-Fi password (default12345678). -
--hostname <name>(optional): Router identity hostname (defaultLib_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-clienton BOTH untaggedether1AND taggedether1-vlan10(VLAN 10 sub-iface, constWanVlanId="10"); both joinWANinterface-list; masquerade useout-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+wifi2with static10.0.0.1/24+ dhcp-server pool10.0.0.2-10.0.0.254. -
Factory route: pick first
UpEthernet adapter (log name+MAC+ifIndex+other-count), set static192.168.88.10/24on it, 10s countdown, probe192.168.88.1, exit with clear message if unreachable; revert NIC to DHCP afterreset-configuration(and infinallyon any failure). -
Flow: ensure
putty.installchoco pkg (providepscp) → resolve MAC→IP via ARP scan (fallback192.168.88.1) → render.rsc→pscpupload →/system reset-configuration keep-users no-defaults skip-backup run-after-reset=<file>→ poll10.0.0.1until API respond (5 min timeout). Cable PC toether2-5before running. -
Manual WinBox equivalent + iWay activation: Step-by-step → Setup phase.
-
.\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).
.\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.
.\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).
# 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.
.\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.
.\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) usechangepk.exe. DowngradePro → Homeis UNSUPPORTED by Microsoft; implemented via registry rewrite (EditionID,ProductName,CompositionEditionIDunderHKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion) plusslmgr /ipk+/atowith Home generic key. User data stay in place. Future Windows feature update may revert edition marker. Reboot required.
# 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
--ouuse WMIWin32_ComputerSystem.JoinDomainOrWorkgroup(places computer in defaultCN=Computers). With--ouswitch toAdd-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.
.\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 freshinstall.
.\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 toLocalSubnetinstead 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 plainEnable-PSRemotingrefuse),WINRM-HTTP-In-TCPfirewall rules enabled (addressed by-Name, never localized-DisplayName), andLocalAccountTokenFilterPolicy=1set so local admin is not token-filtered. Idempotent.
# 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), thenwsl --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.
.\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\Principalor 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 (Administratorenon German nodes). Custom groups such asdocker-usersfall back to literal name. Existing membership is reported and treated as success.
.\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
WindowsCapabilitiesblock declared on machine’s config (WinRM, WSL2 runtime, local-group membership, Docker autostart) to an ALREADY installed node. Same code path Stage 3 run duringinstall, bringing a node level without reinstall. Config resolved by serial likeinstall/update. Marker-gated and idempotent.
.\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 Dockersettings-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.jsonand clears"credsStore": ""in%USERPROFILE%\.docker\config.json.
.\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.
.\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.
.\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 (default80).
-
-
Behavior: Start lightweight HTTP server serving single
<file>on<port>.
.\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 (default80).
-
-
Behavior: Start lightweight HTTP server serving directory tree at
<path>on<port>.
.\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.
# 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 |
|
|
seed config |
|
empty |
translate |
|
fully populated |
override |
tail of the customer |
same config, customer specifics patched in |
serialise |
|
|
publish |
|
Dropbox |
consume |
|
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 identity. |
|
a thing: Server, RouterFirewall, Desktop, Laptop, AccessPoint, Software |
|
THE match key. Hosted form |
|
sub-type discriminator for servers (see device table below). Also carry the MikroTik admin password |
|
a task BUNDLES software. Task is the organising principle, not the job title |
|
AD container AND the unit that get its own |
|
what the person DO and what they HAVE |
|
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 |
|---|---|
|
|
|
|
|
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:
-
Domain name =
company.Namewhen it already valid, elseNetbiosNameProvider.TranslateToDomainName(company.Name). -
NETSetupConfig.CreateBuilder(domainName).AddDirectoryService(domainName)- config name AND directory service key both derive from that one name. -
Three service users seeded, all carrying
NetsetupCredentials.DefaultAdminPassword:Username Role Used for adminClientAdministratorlocal admin on every Windows client
rootServerAdministratorProxmox root; every Nix VM admin inherit its password + email
osisaadminDomainAdministratorAD domain admin, DC join
-
Locale + timezone from country:
CH/schweiz/switzerland→de-CH+ W. Europe Standard Time, elseen-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 |
|
every OU get |
2 |
|
per-OU IP allocator. No OU at all → throw |
2b |
|
whole |
3 |
|
device → typed machine entity, IP popped from the plan |
3b |
public IP lift |
|
3c |
|
|
4 |
|
add the 5 canonical Liberator VMs (table below) |
5 |
|
user account, device association, task → software rollup |
6 |
derive config fields |
|
7 |
|
only when a MikroTik AccessPoint device exist: user |
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 |
|
DC, Proxmox / Liberator hosts, Nix VMs, firewall. 41 addresses; exhaust → throw |
client / DHCP |
|
Windows desktops + laptops |
gateway |
|
router / OPNsense. Also become |
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 |
- |
|
client |
Server |
empty, |
|
server |
Server |
|
|
server |
Server |
|
|
server |
Server |
|
|
server |
Server |
|
|
server |
Server |
anything else |
|
- |
Router |
- |
|
gateway |
RouterFirewall |
- |
|
gateway |
AccessPoint |
(Description = admin password) |
|
fixed |
Printer / PrintScanner |
- |
|
- |
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 |
|---|---|---|---|---|
|
1001 |
|
OPNSense |
|
|
1002 |
|
NixDns |
|
|
1020 |
|
NixNextcloud |
|
|
1090 |
|
NixMail |
|
|
1091 |
|
PmgLxc |
|
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
ServerAdministratorpassword + 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.MailHostnamewhen the customer did not set one.
6.2.1.4.4. Employee → user + software
EmployeeTranslator, per employee:
-
Username = employee name trimmed, lowercased, spaces → dots (
Alice Wasserfallen→alice.wasserfallen). -
Email =
<username>@<PublicUri authority>, or@localwhen no public URI is set. -
Role
CommonRoles.Default- the MARKER that separate real employees from service accounts.EnsureNextcloudUsersand the Windows stage filter on it to pick exactly the employee set. -
For each
UsesDevice: ensure the computer exist (translate the device on the fly if needed), then add the user to that computer. -
Software rollup: for each
HasTask, walk the task and its subtasks, collect theDeviceTypes.Softwareentries, match them againstconfig.SoftwareListby 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.
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-configdeserialise the JSON, stage it, and callGenerateMapFiles(stagingOp, "<ticket>"). One.mapper computer, named<mapKey><ticket>.map, wheremapKey=<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/ReadAndParseConfigdeserialise 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
AddDevicethe OPNSense / Dns / Nextcloud / Mail / PMG VMs - the translator inject them. -
At least one
AddOrganizationalUnit, elseNetworkPlanthrow. -
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 theupdate-software-catalogskill). -
A
HasTaskthe 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 |
|
|
Hyper-V (target) |
a WMI management scope |
|
its own |
Proxmox (target) |
a Proxmox node |
|
its own |
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 |
|---|---|
|
the desired machines of the customer |
|
the machines that actually exist right now |
|
create the missing VM |
|
stop → change → restart the VM whose sizing drifted (restart ONLY if it was running) |
|
MARK a VM the desired state no longer declare - stop it and rename it |
|
destroy what was marked, disks included. The only irreversible verb, and a commit can never reach it |
|
there is drift buffered but not yet applied |
|
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 |
|---|---|
|
the reference. Derive |
|
the builder |
|
the six primitive operations against the config: read, insert, stop, change, start, delete |
|
desired cores / memory / disk as an |
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 |
|---|---|
|
|
|
|
|
|
|
the numeric part of a hosted serial |
|
|
|
always |
Three asymmetries are deliberate, and each one is a lie avoided:
-
PowerStateis alwaysUnknown. A desired state does not know whether a machine is running. ReportingRunningwould make every target look drifted; reportingStoppedwould invite a pointless start. -
StopAsync/StartAsyncare no-ops. A config declare machines, it never run them. -
ChangeAsyncrefuse to overwrite a known OS withOS.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 |
|---|---|
|
soft delete: stop the guest, rename it to |
|
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.Contractsexport aMachineNameof its own, so a bare<see cref="MachineName"/>inosisa.Virtualizationnow resolve ambiguously. -
The interface is deliberately INVARIANT. Covariance (
out TEntity) would read fine today and foreclose a futurePermanentlyDeleteAsync(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 |
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 |
|---|---|---|
|
Hyper-V, netconfig |
the osisa convention - a leading |
|
Proxmox |
Proxmox validate a guest name as a DNS NAME and refuse |
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 |
What selects the rows? |
|
What does one run compare? |
One host against the config rows hosted on THAT host ( |
Where does VM sizing come from? |
Config |
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 |
|---|---|
|
a translated config register as a data store reference under its own name |
|
every machine of the config come back as a record, by name |
|
the config serve the same unit-of-work contract as a hypervisor store |
|
reading is not writing |
|
the record identity is the NETSetup serial, and a hosted serial surface its vm id |
|
only hosted machines are flagged as virtual machines - the physical boxes are not |
|
a company that NEED an IPFS node translate without one - the gap the insert close |
|
an uncommitted insert leave the config untouched |
|
the node land in the config, hosted on the Liberator, and inside that host slice |
|
a name that already exist is refused, never silently duplicated |
|
both halves of a deletion, in order: commit MARK, |
|
a second delete of a marked machine do not produce |
|
a host see only its own guests |
|
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 |
|---|---|
|
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 |
|
the base expect one row LESS. Here a commit only MARK; the count drop when the deletion is EXECUTED |
|
the base MUTATE the entity in place. |
The same base run against the REAL environments, wiring only, in
NETSetup.Tests.DataStoreTests:
| Suite | Environment |
|---|---|
|
the local Hyper-V host |
|
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 - |
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
VirtualMachineconstructor runPrecondition.NotNullOrWhiteSpace(iso)- so the test guests carrynixos.iso, attached, never booted. -
Proxmox answer
500 InternalServerErrorwith 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
CanApplyChangesfalse as well - it was assumed to be a Proxmox-only gap. Both are wired now: Hyper-V through the netbaseApplyHardware(oneModifyResourceSettingscall 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 ( |
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.Diskis get-only, sowith { Disk = … }does not compile andDescribesSameMachineAsignore 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
DeviceTranslatordo 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 |
|---|---|---|
|
bool |
Service + HTTP listener + firewall ( |
|
bool |
Autologon + Docker Desktop startup chain, so the engine exist after a reboot with nobody logged on. Needs |
|
string |
Machine |
|
bool |
DISM-first WSL2 feature staging + runtime. Reboot required. See |
|
string? |
URL of the WSL2 release MSI. REQUIRED in practice - see gotcha 5 below. Null falls back to |
|
string? |
OU distinguished name the computer object is CREATED in at join time. Null = the domain default |
|
map |
Local group → principals. Built-ins resolved by well-known SID. See |
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):
-
Stage3Windowsapply 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-capabilitiesapply 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.
# 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.
-
upload as
NETSetup.iso.new, never straight over the live name -
sha256sumit on the target and compare against the source -
only then
mv NETSetup.iso NETSetup.iso.bak-<date>andmv 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 |
lock task |
|
|
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 → |
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 |
|---|---|---|---|---|
|
devBuilder03 |
300 |
|
Reusable-ISO node, 16 GB, lab addressing. |
|
devBuilder05 |
305 |
|
The same, plus 250 GB disk and a |
|
devBuilder06 |
306 |
|
Identical declaration to |
|
devRunner01 |
307 |
|
Identical declaration to |
|
devRunner02 |
308 |
|
Identical declaration to |
|
devRunner03 |
- |
|
Local Hyper-V on maskeLT1, NOT Proxmox. Plain BIOS serial, no vmid. Set up through the DataStore V5 insert path ( |
|
devRunner04 |
309 |
|
Generator |
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:
-
VirtualMachineSettingsgain optional init propertyMacAddress(MAC?);VirtualMachineBuilder.WithMacAddressset it. -
ProxmoxVmOperations.InsertAsyncNO LONGER throw onSerialNumber. It now setOperatingSystem, Secure Boot ON for Windows family (same shape asProxmoxWindowsVmFactory), 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 (
PowerStateStopped), so test failed although guest ran. Never read back from the committing store. -
LocalHyperV.LabSwitch=External_WIFI, notNETSetup_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 |
Out-of-band finish |
|
Result |
Both in |
Still open |
Static IPs .81 / .82 NOT applied (sit on DHCP .18 / .34). |
|
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:
-
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
#915xticket. -
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.Hardwareis typedIHardwareand a config that persist it become unloadable for every guest that read it. -
Generate. See "Generating a config when the store have no account secrets" below.
-
Publish.
NETSetup.exe publish-config --ticket <n> --config <ROOTED path>write<n>.jsonplus one<serial><n>.mapper 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. -
Prepare the host.
pvesm set local --disable 0and stage the sharedNETSetup.isohost-to-host. -
Install.
dotnet test src\NETSetup.Tests --settings src\prod.runsettings --filter <test>, from an ELEVATED session. ~3 h. -
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> /reuseon a domain box, base64 the blob into the guest,djoin /requestODJ, WRITEC:\NETSetup\Logs\DomainJoin.log, then a FULL STOP/START (a pendingqm set -coresonly land on a cold boot), thenNETSetup.exe apply-capabilities --hostname <node>in the guest, then one more reboot for the Docker autologon session. -
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 -
ProxmoxDevRunner01VerificationTestsis 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.lognaming<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.exepresent,docker run hello-worldanswering, 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:
-
dir-storage
localships DISABLED.ProvisionWindowsVmOnProxmoxpassisoStorage: "local"literally, because the ISO live in local’s/var/lib/vz/template/isowhileGetISOStoragewould happily picklocal-btrfs(both carrycontent=iso) and then fail the volume lookup. Enable it withpvesm set local --disable 0- idempotent, and exactly whatUpdateMethods.EnsureLocalStorageEnableddo during a normalupdate. -
the shared
NETSetup.isomust 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 withcurl -C -, thensha256sumBOTH 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/pinganswer 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. -
qmrebootcan leave the VM STOPPED. It reportVM quit/powerdown failedwhen the guest is too busy to answer ACPI - a Windows guest with pending servicing after a WSL install is exactly that. Prefer an in-guestshutdown /r /t 5, and ALWAYS re-readstatus/currentafter 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 withAdministratorenalready containingosisa\LOCALADMINSon the FIRST pass, straight from the GPO - which is the thing devBuilder05 never got, sitting inCN=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.
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
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)showingadminon 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
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 |
|
set the feed token machine-wide |
|
|
|
Rules that make it go away:
-
install tools MACHINE-WIDE -
--tool-path C:\dotnet-toolsplus that path on the machinePATH, never--global, which land in the CALLING user’s profile (yours over WinRM, not `admin’s); -
set variables at MACHINE scope, never User;
-
RESTART
GitHubActionsRunner-Interactiveafterwards, and only THEN judge a CI result; -
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.
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.
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 |
|
credentials |
|
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.
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.
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.
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.
-
netbase-build Pushfail withThe type initializer for 'Nuke.Common.NukeBuild' threw an exception. Reproduce onmaskeLT1with a different tool version and checkout. The workflow last succeeded 2026-07-08 and has failed EVERY run since 2026-07-11. -
developneed a netbase package GitHub Packages does not serve, so a machine without a hand-copiedC:\_gh\packagesfail to COMPILE (IPermanentDeletions<>not found) even after restore succeed.devBuilder01/devBuilder02have 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.
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
DomainOuPathalso starts working), or -
offline-join with
djoin:djoin /provision /domain corp.osisa.com /machine <name> /savefile <blob> /reuseon a box already authenticated to the domain, thendjoin /requestODJ /loadfile <blob> /windowspath C:\Windows /localoson 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).
WslHelpertherefore download withcurl.exe -C -in a resume loop, notInvoke-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.exeoverqm guest execanswerDas 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:
-
djoin /requestODJ+ writeC:\NETSetup\Logs\DomainJoin.log, then reboot. The marker MUST be written or Stage3 re-pin DNS and retry the impossible join every boot. -
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.
-
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 genericnixos.iso(qemu-guest-agent on, master pubkey bake in). Wait guest-agent report ipv4. Pick template viaNixTemplateFactory.ForMachine(machine). Emitconfig.nix+hardware.nix+ sopssecrets.yaml+.default.keyto/NETSetup/generated-nix/<vm>(no flake emit - base flake reuse).scpconfig to VM/nixos-config/,sshinstall.sh -h default -d <disk>via master key.install.shdecrypt the sopsluks_passwordto/tmp/luks.keysodisko-installrun non-interactive (no stdin prompt over ssh). -
Windows flow - os match
Windows10/Windows11/WindowsServer201x/202x. Build per-VM offlineNETSetup-{ticket}-{host}.isovia 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 whenhost.Description == "Liberator". Prebuilt raw disk image at/var/lib/vz/template/iso/opnsense.img(synced viaEnsureOpnsenseImage.EnsureOpnsenseImageOnProxmoxHostfrom DropboxNETSetup/BOOT/Images/Opnsense.7z).qm createSeaBIOS, 5 vNICs Option Y (net0untagged WAN,net1tag=10 server tier,net2tag=20 LAN,net3tag=250 MGMT,net4tag=254 Guest),qm importdiskraw image,qm set --scsi0 … --boot order=scsi0,qm start. First-bootOpnsenseInstallSSHes in with master key, scpsconfig.xml(5 if assignments, DHCP per VLAN, static maps fromconfig.Computers[].MAC, DNS forwarder → NixDns, FW deny-default inter-VLAN), reloads services, persists api key. Order first inGetOrderedVmsForHost. See Liberator.adoc for full topology + role matrix.
Paths / keys / cadence:
-
Proxmox iso store:
/var/lib/vz/template/iso/(bothnixos.isoandNETSetup-*.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.EnsureNixosIsoOnProxmoxHostcheck sha, download+7z-extract if miss/mismatch. -
Per-VM sops secrets:
secrets.yamlalways carryluks_password(random 32-byte hex, encrypt to the single installer age pubkey). NixIPFS VM ALSO carryipfs/swarm_key(block scalar:/key/swarm/psk/1.0.0/+/base16/+ 64-lowercase-hex) andipfs/cluster_secret(CLUSTER_SECRET=<64-lowercase-hex>EnvironmentFile line), both nest under oneipfs:map. These two are cluster-shared: mint ONCE per customer byIpfsClusterSecretsGenerator.EnsureOn(config), stash onNETSetupConfig.IpfsCluster, stay STABLE across re-provision (regen would split the cluster).SecretsYamlBuilderrender flat single keys (luks back-compat) and nesteda/bkeys; templates declare needs viaNixHostTemplate.GetRequiredSecrets(config). -
Non-interactive LUKS:
disko-installon encrypted layout read the passphrase frompasswordFile = "/tmp/luks.key"(set indisk-layouts.nixencrypted + dual-boot blocks).install.shwrite the decrypted sopsluks_passwordthere BEFOREdisko-install, delete it after TPM enroll. WHY needed: withoutpasswordFiledisko prompt on stdin, get nothing over the ssh pipe,luksFormatabort, and the closure copy into the still-unmounted/mnt/disko-install-rootRAM 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 wasnixos-enter --root /mnt -c "nix-enroll-tpm-unlock /tmp/luks.key"and it carried TWO independent defects, either of which alone kill the node:-
Wrong path.
nixos-enterCHROOT into/mntand bind-mount only/dev/sys/proc(+ resolv.conf) - never/tmp. So/tmp/luks.keyinside the chroot mean/mnt/tmp/luks.key, which nobody ever create. -
Command does not exist.
nix-enroll-tpm-unlockonly get installed whenhost.system.nixCommands.enable = true. That is amkEnableOption, 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 atPlease enter passphrase for disk disk-main-luks (cryptroot). Broken since commit4028be54(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+7produce a validsystemd-tpm2token (confirmed by reading the LUKS2 JSON header off the raw disk), the initrd crypttab correctly saycryptroot /dev/disk/by-partlabel/disk-main-luks - tpm2-device=auto,/dev/tpm0IS 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-cryptsetupfall 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-cryptenrollship with systemd so the installer always have it on PATH, and the TPM is reachable because the installer own/dev. LUKS device come from theLUKS_UUIDalready detected after partitioning, elseblkid -t TYPE=crypto_LUKS. Failure now report LOUD andinstall.shexit non-zero, so an unbootable node can never report success again.WarningSecurity 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
tpmstatedisk (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-installUNMOUNT the target when it finish. Everything after it wrote to/mnt, i.e. into the installer’s RAM -mkdir,cp,git init,git commitall "succeed" and nothing warn. The installed system therefore boot with an EMPTY/etc/nixos. Fix: remount withdisko --mode mount --flake "$WORKDIR#$HOST" --root-mountpoint /mntwhen/mntis not a mountpoint, then HARDexit 1rather than write config into RAM. Note: do NOT pass--disk- that is adisko-installflag; the plaindiskoCLI reject it, print its usage and silently do nothing. -
sops could not decrypt anything (fixed 2026-08-16). Per-VM
secrets.yamlis encrypted to the INSTALLER age public key, buthost.security.secretsdefault to deriving an age key from the host ssh key (ageKeyPaths = ["/etc/ssh/ssh_host_ed25519_key"]), which never encrypted these secrets → activation die withError getting data key: 0 successful groups required, got 0, and every sops-backed service with it. Fix:install.shwrite 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 +NixIpfsRolesetageKeyFile = "/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 whenswarm.key/LIBP2P_FORCE_PNETis present, soipfs.servicecrash-loop until systemd give up withStart request repeated too quickly. The node boot perfectly; only the ROLE was dead. Fix:AutoConf.Enabled = falsein 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:
-
NixVmBootGate.WaitForInstalledHostName- guest agent must report the installednetworking.hostName, so a node that never boot FAIL. -
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:rfirst - in the test bin dir it inherit normal Windows ACLs and OpenSSH refuse it outright withWARNING: 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:
NixHostTemplatedefault 2 GiB / 32 GiB;NixNextcloudTemplate8 GiB / 256 GiB. -
Cadence: every
netsetup updatecall 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
osisaLib03hostname (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/interfacesrewrite (bond0 LACP, vmbr0 VLAN-aware, vmbr0v250 host mgmt) withifupdown2safe-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 $falseBEFORE first boot. It apply to WINDOWS guests too: the OS-based gate inVirtualHyperVTestBase(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
0xC000A002STATUS_AUTH_TAG_MISMATCH.Set-VMKeyProtector+Enable-VMTPMdo 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-v3cannot start on a host without AVX2. Proxmox getcpu=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=hostwork.ProxmoxWindowsVmFactory,ProvisionNixosVmandProxmoxVmOperationsalready override tohost- every NEW creation path must, until the default itself fall back. -
CreateVirtualMachinecan return true whileqmcreatefailed. Never trust the return value. The failure surface later and misleadingly asFailed to find VirtualMachine … Sequence contains no matching elementfrom 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
IdentifyHostwith "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 inVirtualHyperVTestBase.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 |
|---|---|
|
NOTHING but the ISO. It create with |
|
A RUNNING GUEST, on purpose. Copy of the above changed in exactly TWO ways: |
|
Nothing - every |
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 |
Proxmox web UI, live migration, snapshots. NixOS redeploy over SSH with |
Host itself Debian, configured imperatively via GUI, not Git. |
2. Proxmox on NixOS (community port |
PVE stack (QEMU, Corosync, PVE daemons, web GUI) packaged as NixOS module. |
Whole hypervisor host (bridges, kernel modules, firewall) in one |
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) |
|
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 |
|---|---|---|
|
one host |
the data. no nix syntax inside |
|
fluent build |
same shape as customer |
|
any nix value |
recursive: |
|
|
renders |
|
|
|
|
|
|
|
renderer |
IS a |
|
reusable role |
|
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 |
|
|
Technical config |
what is RUNNING / how |
|
|
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.
NixModelTemplateskip its ownhost.system.diskwhen model set that path. Two definitions of same attribute in one body is a nix eval ERROR, not a merge. -
networking.hostNameMUST equal machine name.InstallNixosVmalready-installed gate compare guest hostname tomachine.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 onosisaLib03,Prod+LongRunning, VM nameTestNixIpfsModel(distinct from the hand written test’sTestNixIpfsProxmoxso both node coexist).
Final state (2026-08-17): BOTH paths green, verified end to end.
| entity model | hand written | |
|---|---|---|
VM |
|
|
Boot unattended (TPM unlock) |
yes |
yes |
|
|
|
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
6.2.9.2. Liberator network topology
6.2.9.3. Liberator network push (cutover)
6.2.9.4. 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.
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.
6.2.10.1. The two gates before anything happen
| Gate | Rule |
|---|---|
is this a domain node |
|
already joined |
|
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 |
|---|---|
|
|
|
|
|
|
|
that same User’s |
|
|
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 |
6.2.10.5. What happen AFTER a successful join
-
marker
DomainJoinis written; -
host.ExitReboot()- Stage 3 end the run and reboot; -
after the reboot Stage 3 re-enter, skip the marked step, and
Grant-DomainAdminsRDPrun; -
THEN the
WindowsCapabilitiesblock 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 |
|
Nr, Name, Tel, Mobil, E-Mail, Rolle |
|
device / IP / user / password, plus VPN (SSTP, IPSEC) |
|
Netzwerkskizze, Modem/Router, Internetzugang, Firewall (+ Portweiterleitungen), Domains |
|
Geteilte Mailboxen, Microsoft 365 Gruppen, Verteilerlisten |
|
Drucker, Druckerfreigaben |
|
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.
-
AddOrganizationalUnitappears 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.
HasTaskis 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 |
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 |
XML |
Organisation as trading or payment counterparty. |
schema.org/Organization |
JSON-LD / microdata |
Publishing organisation data on the web. |
LDAP / X.500 |
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.2.11.3. Sources
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.
-
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)
-
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, OSNixDns): coredns auth zone + step-ca internal CA + lego LE DNS-01
ddns-updater. PMG LXC (Debian +proxmox-mailgateway, OSPmgLxc): inbound filter on 25, relays tomail.<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. -
Provider-pluggable DNS + ACME. New
NETSetupConfig.DnsProviderPOCO (Provider,CredentialsFile,CredentialsAttrs) feeds both lego (cert issuance) and ddns-updater (A-record sync) on the DNS VM. Supported provider list (intersection of nixpkgsservices.ddns-updaterand 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. -
Optional smarthost relay. New
NETSetupConfig.MailRelayPOCO (Host,Portdefault 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.PassFileis a SOPS-encrypted secret path on the mail VM; NETSetup never reads its content. -
DNS records auto-derived from customer config.
NixDnsTemplatescans 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 PMGmxalias pointing atpmg.<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. -
New OS constants in
NetsetupOS.NixMail,NixDns,PmgLxc. The two Nix-prefixed names satisfyIsNixBackedautomatically and are routed byEnsureCustomerVms→NixHostTemplateFactoryto the newNixMailTemplate/NixDnsTemplate(subclasses ofNixHostTemplate).PmgLxcdoes NOT satisfyIsNixBacked— intentional, soEnsureCustomerVmsskips it — and is routed by the newEnsureCustomerLxcs→ProvisionPmgLxcinstead. -
LXC provisioning infrastructure — all new.
LxcCommand.Builderwraps/usr/sbin/pct(subcommands:create,start,stop,destroy,exec,set,status) using the sameAddNamed/AddBoolpattern as every other NETCommand builder — never rawAdd($"--flag {value}").ProvisionPmgLxcis the LXC equivalent ofProvisionNixosVm(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 tomail.<CustomerDomain>:26. -
Wired into
RunProxmoxUpdate.EnsureCustomerLxcs.Runruns afterEnsureCustomerVms.Run— mandatory ordering, because the PMG relay config inside the LXC referencesmail.<domain>:26, so the mail VM must exist first.EnsurePbsInstalledis appended toEnsureProxmoxPostInstallafterEnsureLocalStorageEnabled(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. -
TestCompany fixture additions. Three new hosted devices on the Liberator test host (host serial
lib), inserted afterLibWin11: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 = cloudflareplaceholder (provider swap is a one-line config change, not a code change). -
Manual e2e via
info@osisa.com. After the automated test leaves the stack live, operator sends a real mail frominfo@osisa.comto<user>@test01.iam.freeand verifies arrival in roundcube onhttps://mail.test01.iam.free/. Reply path back toinfo@osisa.comexercises the smarthost relay (or direct MX, depending onMailRelay). Automated e2e covers provisioning + service-up + DNS resolution: new test classProxmoxPublicMailTests.Liberator([Ignore("Local Tests")]) asserts PBS active on host,pct listshows pmg@lib,qm listshows mail+dns, dovecot/postfix/ nginx/coredns/step-ca/lego/ddns-updater allis-active,dig @localhost mail.test01.iam.freeresolves, and a rawHttpClientGET ofhttps://mail.test01.iam.free/returns the roundcube login. -
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:
-
First-boot is USB-only.
netsetup-first-boot.shcopies the linux-x64 binary from the SYSTEM partition (labelSYSTEMon USB #1) andcp -ppreserves its mtime. No Dropbox / Pages fallback. Noself-update.sh. NoExecStartPre- the systemd unit simply runs/NETSetup/NETSetup update. -
Self-update via GitHub Releases.
UpdateMethods.EnsureLatestBinaryOrReexecruns as the first step of everynetsetup updatecycle. It decrypts an embedded fine-grained PAT (Contents:Read onosisa/netsetuponly, AES-256-CBC viaosisa.Security.Contracts.EncryptionHelpersostrings(1)only sees a base64 blob), GETs/repos/osisa/netsetup/releases/latest, parses the response withParseReleaseInfo, and compares versions withCompareSemVer. 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 asNETSetup update, waits, and `Environment.Exit`s with the child’s exit code. -
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.249in0.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). -
Post-install ran step-by-step in C#.
EnsureProxmoxPostInstallno longer writes a giant bash script. Each side-effect (disable enterprise repos, writepve-no-subscription.sources, install nag-removal hook, configure banners) is a discrete NETCommand orIFileOperatorcall so failures stay scoped. -
Customer VM provisioning (e.g. Nextcloud + Win11).
EnsureCustomerVms.Runis wired intoRunProxmoxUpdateafterapt 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. TheOfflineCompanytest fixture has a Nextcloud server (100@lib) and a Win11 client (201@lib) hosted on the Liberator; testProxmoxWithUSBStickTests.Liberatorend-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 (rawHttpClientprobe - 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.CreateOfflineProductionISOrequires 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(whereticketIdis the int parse ofConfigNameor its FNV-1a hash for non-numericConfigName`s). `ProvisionWindowsVmOnProxmoxdetects an existing file at that path and skips the source-dependent rebuild. The Liberator test follows the same pattern:PreStageLibWin11IsoOnLiberatorbuilds locally + SCPs to Liberator before NETSetup runs.NixOS install on tmpfs-limited VMs.
ProvisionNixosVmattaches a 16 Gscsi1swap disk pre-create andNixRemoteInstallCommandrunsmkswap /dev/sdb && swapon /dev/sdb && mount -o remount,size=20G /nix/.rw-storebefore invokinginstall.sh. Without this the Nextcloud closure overruns the live-ISO tmpfs (RAM/2, ~4 G at 8 G RAM) witherror: 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 usesqm stop+qm start(notqm reboot) because the live-ISO installer doesn’t honor ACPI shutdown. -
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 itChoco install drop Needs
Grab the file from a choco install (
|
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]