feat(welcome): ApplyService orchestrator (modules->accounts->bitlocker->teardown)

This commit is contained in:
sysadmin
2026-06-09 02:33:39 +01:00
parent 3be703d5c9
commit 8fe577e0bf
5 changed files with 78 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyProgress(string Stage, int Percent);

View File

@@ -0,0 +1,4 @@
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed record ApplyRequest(FlavourManifest Flavour, string Username, string Password,
string AdminPassword, string BitLockerPin, string BootstrapUser);

View File

@@ -0,0 +1,28 @@
using System.Text.Json;
using SilverOS.Welcome.Core.Flavours;
namespace SilverOS.Welcome.Core.Apply;
public sealed class ApplyService(IProcessRunner runner, IAccountService accounts,
IBitLockerService bitlocker, IBootstrapService bootstrap, string hardeningDir) : IApplyService
{
public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
{
progress.Report(new("Applying hardening", 10));
var mods = string.Join(",", req.Flavour.Hardening.Modules.Select(m => $"'{m}'"));
var pjson = JsonSerializer.Serialize(req.Flavour.Hardening.Params).Replace("\"", "\\\"");
var script = Path.Combine(hardeningDir, "Invoke-Hardening.ps1");
var res = await runner.RunAsync("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" -Modules {mods} -ParamsJson \"{pjson}\"", ct);
if (res.ExitCode != 0) throw new InvalidOperationException($"hardening failed: {res.StdErr}");
progress.Report(new("Creating your account", 55));
await accounts.CreateAccountsAsync(req.Username, req.Password, req.AdminPassword, ct);
progress.Report(new("Encrypting the disk", 75));
await bitlocker.EnableAsync(req.BitLockerPin, ct);
progress.Report(new("Finishing up", 95));
await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success
progress.Report(new("Done", 100));
}
}

View File

@@ -0,0 +1,2 @@
namespace SilverOS.Welcome.Core.Apply;
public interface IApplyService { Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default); }

View File

@@ -0,0 +1,42 @@
using Moq;
using SilverOS.Welcome.Core.Apply;
using SilverOS.Welcome.Core.Flavours;
using Xunit;
public class ApplyServiceTests
{
[Fact]
public async Task Runs_modules_then_accounts_then_bitlocker_then_bootstrap_last()
{
var order = new List<string>();
var run = new Mock<IProcessRunner>();
run.Setup(r => r.RunAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string,string,CancellationToken>((_, a, _) => { if (a.Contains("Invoke-Hardening")) order.Add("modules"); })
.ReturnsAsync(new ProcessResult(0, "", ""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("accounts")).Returns(Task.CompletedTask);
var bl = new Mock<IBitLockerService>(); bl.Setup(b => b.EnableAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bitlocker")).Returns(Task.CompletedTask);
var boot = new Mock<IBootstrapService>(); boot.Setup(b => b.TearDownAsync(It.IsAny<string>(),It.IsAny<CancellationToken>())).Callback(() => order.Add("bootstrap")).Returns(Task.CompletedTask);
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var flavour = new FlavourManifest { Id="daily-driver", Hardening = new HardeningSpec { Modules = new[]{"00"} } };
var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap");
var progress = new List<string>();
await sut.RunAsync(req, new Progress<ApplyProgress>(p => progress.Add(p.Stage)));
Assert.Equal(new[]{"modules","accounts","bitlocker","bootstrap"}, order);
Assert.Contains("Applying hardening", progress);
}
[Fact]
public async Task Does_not_tear_down_bootstrap_if_account_creation_fails()
{
var run = new Mock<IProcessRunner>(); run.Setup(r => r.RunAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ReturnsAsync(new ProcessResult(0,"",""));
var acct = new Mock<IAccountService>(); acct.Setup(a => a.CreateAccountsAsync(It.IsAny<string>(),It.IsAny<string>(),It.IsAny<string>(),It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
var bl = new Mock<IBitLockerService>(); var boot = new Mock<IBootstrapService>();
var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard");
var req = new ApplyRequest(new FlavourManifest{ Hardening=new HardeningSpec{Modules=new[]{"00"}}}, "a","b","c","123456","sm-bootstrap");
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(req, new Progress<ApplyProgress>(_ => {})));
boot.Verify(b => b.TearDownAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
}