From 096a1f1279d548fa46a6bd49e188ffa9287f4496 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 27 Jul 2026 23:28:36 +0800 Subject: [PATCH] feat: complete TASK-WP0-07 supervisor --- apps/api/src/main.ts | 13 + apps/api/src/supervisor-channel.ts | 46 +++ apps/worker/src/supervisor-channel.ts | 46 +++ apps/worker/src/worker.ts | 11 + package.json | 4 +- scripts/package-smoke.mjs | 19 +- scripts/run-wp0-07-validation.mjs | 82 +++++ scripts/supervisor-channel-smoke.mjs | 20 ++ .../Dada.Supervisor.Tests.csproj | 12 + supervisor/Dada.Supervisor.Tests/Program.cs | 301 ++++++++++++++++++ supervisor/Dada.Supervisor/AssemblyInfo.cs | 3 + supervisor/Dada.Supervisor/Credentials.cs | 175 ++++++++++ supervisor/Dada.Supervisor/Diagnostics.cs | 35 ++ .../Dada.Supervisor/LoopbackPortGuard.cs | 21 ++ .../Dada.Supervisor/ManagedChildProcess.cs | 137 ++++++++ .../Dada.Supervisor/OfflineCommandRouter.cs | 216 +++++++++++++ supervisor/Dada.Supervisor/Program.cs | 45 ++- .../SingleInstanceCoordinator.cs | 91 ++++++ supervisor/Dada.Supervisor/SupervisorForm.cs | 297 ++++++++++++++++- .../Dada.Supervisor/SupervisorRuntime.cs | 62 ++++ supervisor/Dada.Supervisor/SupervisorState.cs | 54 ++++ .../SupportedBrowserLauncher.cs | 33 ++ 22 files changed, 1717 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/supervisor-channel.ts create mode 100644 apps/worker/src/supervisor-channel.ts create mode 100644 scripts/run-wp0-07-validation.mjs create mode 100644 scripts/supervisor-channel-smoke.mjs create mode 100644 supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj create mode 100644 supervisor/Dada.Supervisor.Tests/Program.cs create mode 100644 supervisor/Dada.Supervisor/AssemblyInfo.cs create mode 100644 supervisor/Dada.Supervisor/Credentials.cs create mode 100644 supervisor/Dada.Supervisor/Diagnostics.cs create mode 100644 supervisor/Dada.Supervisor/LoopbackPortGuard.cs create mode 100644 supervisor/Dada.Supervisor/ManagedChildProcess.cs create mode 100644 supervisor/Dada.Supervisor/OfflineCommandRouter.cs create mode 100644 supervisor/Dada.Supervisor/SingleInstanceCoordinator.cs create mode 100644 supervisor/Dada.Supervisor/SupervisorRuntime.cs create mode 100644 supervisor/Dada.Supervisor/SupervisorState.cs create mode 100644 supervisor/Dada.Supervisor/SupportedBrowserLauncher.cs diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index dcc6c64..24dbac5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,6 +2,12 @@ import { resolve } from "node:path"; import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; +import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; + +const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); +if (credentialChannelEnabled) { + initializeApiCredentialClients(await receiveApiCredentials()); +} const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {}); @@ -10,3 +16,10 @@ await app.listen({ host: "127.0.0.1", port: 43121, }); + +const controlPipeIndex = process.argv.indexOf("--dada-control-pipe"); +if (controlPipeIndex >= 0) { + const controlPipe = process.argv[controlPipeIndex + 1]; + if (!controlPipe) throw new Error("Supervisor control pipe name is required."); + attachApiSupervisorControl(controlPipe, () => app.close()); +} diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts new file mode 100644 index 0000000..41a68b3 --- /dev/null +++ b/apps/api/src/supervisor-channel.ts @@ -0,0 +1,46 @@ +import { createConnection } from "node:net"; + +const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const; + +export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { + const chunks: Buffer[] = []; + for await (const chunk of input) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const payload = Buffer.concat(chunks); + try { + const parsed = JSON.parse(payload.toString("utf8")) as Record; + const names = Object.keys(parsed).sort(); + const expected = [...API_CREDENTIALS].sort(); + if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) { + throw new Error("API credential channel contains an unexpected credential scope."); + } + if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) { + throw new Error("API credential channel contains an invalid credential value."); + } + return parsed as Record<(typeof API_CREDENTIALS)[number], string>; + } finally { + payload.fill(0); + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + } +} + +export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { + const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); + for (const name of API_CREDENTIALS) credentials[name] = ""; + if (!configured) throw new Error("API credential client initialization failed."); +} + +export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { + const socket = createConnection(`\\\\.\\pipe\\${pipeName}`); + let pending = ""; + socket.setEncoding("utf8"); + socket.on("connect", () => socket.write("ready\n")); + socket.on("data", (chunk) => { + pending += chunk; + if (!pending.includes("\n")) return; + const [command] = pending.split("\n", 1); + pending = ""; + if (command === "shutdown") void shutdown().finally(() => socket.end()); + }); + return socket; +} diff --git a/apps/worker/src/supervisor-channel.ts b/apps/worker/src/supervisor-channel.ts new file mode 100644 index 0000000..c06e1b2 --- /dev/null +++ b/apps/worker/src/supervisor-channel.ts @@ -0,0 +1,46 @@ +import { createConnection } from "node:net"; + +const WORKER_CREDENTIALS = ["Dada/P0A/worker/ai-gateway"] as const; + +export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = process.stdin) { + const chunks: Buffer[] = []; + for await (const chunk of input) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const payload = Buffer.concat(chunks); + try { + const parsed = JSON.parse(payload.toString("utf8")) as Record; + const names = Object.keys(parsed).sort(); + const expected = [...WORKER_CREDENTIALS].sort(); + if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) { + throw new Error("Worker credential channel contains an unexpected credential scope."); + } + if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) { + throw new Error("Worker credential channel contains an invalid credential value."); + } + return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>; + } finally { + payload.fill(0); + for (const chunk of chunks) chunk.fill(0); + chunks.length = 0; + } +} + +export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) { + const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0); + for (const name of WORKER_CREDENTIALS) credentials[name] = ""; + if (!configured) throw new Error("Worker credential client initialization failed."); +} + +export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise | void) { + const socket = createConnection(`\\\\.\\pipe\\${pipeName}`); + let pending = ""; + socket.setEncoding("utf8"); + socket.on("connect", () => socket.write("ready\n")); + socket.on("data", (chunk) => { + pending += chunk; + if (!pending.includes("\n")) return; + const [command] = pending.split("\n", 1); + pending = ""; + if (command === "shutdown") void Promise.resolve(shutdown()).finally(() => socket.end()); + }); + return socket; +} diff --git a/apps/worker/src/worker.ts b/apps/worker/src/worker.ts index 7088a88..8a288f0 100644 --- a/apps/worker/src/worker.ts +++ b/apps/worker/src/worker.ts @@ -1,5 +1,7 @@ import { parentPort } from "node:worker_threads"; +import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js"; + export function handleWorkerProbe(message: unknown) { return message === "ping" ? "pong" : null; } @@ -11,3 +13,12 @@ if (workerPort) { workerPort.postMessage(handleWorkerProbe(message)); }); } + +if (!workerPort && process.argv.includes("--dada-credential-stdin")) { + initializeWorkerCredentialClient(await receiveWorkerCredentials()); + const controlPipeIndex = process.argv.indexOf("--dada-control-pipe"); + const controlPipe = process.argv[controlPipeIndex + 1]; + if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required."); + const keepAlive = setInterval(() => undefined, 30_000); + attachWorkerSupervisorControl(controlPipe, () => clearInterval(keepAlive)); +} diff --git a/package.json b/package.json index ba28692..0204a36 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "test:wp0-05": "node scripts/run-wp0-05-validation.mjs", "test:wp0-05:red": "node scripts/run-wp0-05-validation.mjs --phase red", "test:wp0-06": "node scripts/run-wp0-06-validation.mjs", - "test:wp0-06:red": "node scripts/run-wp0-06-validation.mjs --phase red" + "test:wp0-06:red": "node scripts/run-wp0-06-validation.mjs --phase red", + "test:wp0-07": "node scripts/run-wp0-07-validation.mjs", + "test:wp0-07:red": "node scripts/run-wp0-07-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index c060a80..d0c5cf7 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -23,12 +23,15 @@ const nativeSmoke = JSON.parse( const workerSmoke = JSON.parse( execFileSync(runtimeNode, ["scripts/worker-smoke.mjs"], { encoding: "utf8" }).trim(), ); +const supervisorChannelSmoke = JSON.parse( + execFileSync(runtimeNode, ["scripts/supervisor-channel-smoke.mjs"], { encoding: "utf8" }).trim(), +); execFileSync( "dotnet", [ "restore", - "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", + "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", "--configfile", "NuGet.Config", ], @@ -45,6 +48,18 @@ execFileSync( ], { stdio: "inherit" }, ); +execFileSync( + "dotnet", + [ + "run", + "--project", + "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", + "--configuration", + "Release", + "--no-restore", + ], + { stdio: "inherit" }, +); const supervisorExecutable = resolve( "supervisor/Dada.Supervisor/bin/Release/net8.0-windows/Dada.Supervisor.exe", @@ -63,6 +78,8 @@ const result = { status: "passed", supervisor: { build: "passed", + channel: supervisorChannelSmoke, + lifecycle: "passed", target: frozenRuntime.dotnetTarget, }, worker: workerSmoke, diff --git a/scripts/run-wp0-07-validation.mjs b/scripts/run-wp0-07-validation.mjs new file mode 100644 index 0000000..9a32363 --- /dev/null +++ b/scripts/run-wp0-07-validation.mjs @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-07-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const cases = [ + { id: "TDD-WP0-SEC-001-credential-channel", evidence: ["process-env-scan.json", "pipe-acl.json", "redaction.json"] }, + { id: "TDD-WP0-SUP-001-lifecycle", evidence: ["process-tree.json", "port.json", "supervisor-events.json", "screenshots/system-ui.png"] }, +]; +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const testCase of cases) mkdirSync(resolve(runDirectory, "cases", testCase.id), { recursive: true }); + +const commandsToRun = phase === "red" + ? [["dotnet run --project supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", ["run", "--project", "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj"]]] + : [ + ["pnpm test:security", ["test:security"]], + ["pnpm test:package", ["test:package"]], + ["pnpm validate:tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_SEC: resolve(runDirectory, "cases", cases[0].id), + DADA_EVIDENCE_DIR_SUP: resolve(runDirectory, "cases", cases[1].id), +}; +const startedAt = new Date().toISOString(); +const commands = []; +for (const [command, args] of commandsToRun) { + const started_at = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `${phase === "red" ? "dotnet" : "pnpm"} ${args.join(" ")}`] : args; + const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const results = []; +for (const testCase of cases) { + const caseDirectory = resolve(runDirectory, "cases", testCase.id); + writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); + const missingEvidence = phase === "green" ? testCase.evidence.filter(path => !existsSync(resolve(caseDirectory, path))) : []; + const commandState = phase === "red" ? commands.every(item => item.exit_code !== 0) : commands.every(item => item.exit_code === 0); + const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missingEvidence.length === 0 ? "passed" : "failed"); + const result = { + acceptance_criteria: testCase.id.includes("SEC") ? ["AC-41"] : ["AC-24", "AC-41"], + automation: ["automated", "manual_review"], + commit, + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, + evidence_refs: testCase.evidence, + finished_at: new Date().toISOString(), + layer: testCase.id.includes("SEC") ? ["SECURITY", "PACKAGE"] : ["PACKAGE", "SYSTEM_UI"], + manifest, + missing_evidence: missingEvidence, + parent_family: testCase.id.replace(/-credential-channel|-lifecycle/, ""), + phase, + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: testCase.id.includes("SEC") ? ["PRIV-01", "NFR-09"] : ["NFR-09"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status, + task_id: "TASK-WP0-07", + test_id: testCase.id, + work_package: "WP-0", + worktree_under_test: spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim() ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + results.push({ missing_evidence: missingEvidence, status, test_id: testCase.id }); +} +const expectedStatus = phase === "red" ? "red_confirmed" : "passed"; +const status = results.every(result => result.status === expectedStatus) ? expectedStatus : "failed"; +const summary = { cases: results, phase, run_id: runId, status }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (status !== expectedStatus) process.exit(1); diff --git a/scripts/supervisor-channel-smoke.mjs b/scripts/supervisor-channel-smoke.mjs new file mode 100644 index 0000000..113fe5e --- /dev/null +++ b/scripts/supervisor-channel-smoke.mjs @@ -0,0 +1,20 @@ +import { Readable } from "node:stream"; + +import { initializeApiCredentialClients, receiveApiCredentials } from "../apps/api/dist/supervisor-channel.js"; +import { initializeWorkerCredentialClient, receiveWorkerCredentials } from "../apps/worker/dist/supervisor-channel.js"; + +const marker = `wp0-${Date.now().toString(36)}`; +const api = await receiveApiCredentials(Readable.from([Buffer.from(JSON.stringify({ + "Dada/P0A/api/amap": `${marker}-map`, + "Dada/P0A/api/resend": `${marker}-mail`, +}))])); +initializeApiCredentialClients(api); +if (Object.values(api).some(Boolean)) throw new Error("API credential receive object was not cleared after client initialization."); + +const worker = await receiveWorkerCredentials(Readable.from([Buffer.from(JSON.stringify({ + "Dada/P0A/worker/ai-gateway": `${marker}-ai`, +}))])); +initializeWorkerCredentialClient(worker); +if (Object.values(worker).some(Boolean)) throw new Error("Worker credential receive object was not cleared after client initialization."); + +console.log(JSON.stringify({ api_scope: "resend_amap", receive_buffer: "cleared", status: "passed", worker_scope: "ai_gateway" })); diff --git a/supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj b/supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj new file mode 100644 index 0000000..9d9a477 --- /dev/null +++ b/supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0-windows + true + enable + enable + + + + + diff --git a/supervisor/Dada.Supervisor.Tests/Program.cs b/supervisor/Dada.Supervisor.Tests/Program.cs new file mode 100644 index 0000000..8b80f03 --- /dev/null +++ b/supervisor/Dada.Supervisor.Tests/Program.cs @@ -0,0 +1,301 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using Dada.Supervisor; + +internal static class Program +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + [STAThread] + private static async Task Main(string[] args) + { + if (args.FirstOrDefault() == "--credential-child") + { + return await RunCredentialChildAsync(); + } + + if (args.FirstOrDefault() == "--instance-probe") + { + using var instance = await SingleInstanceCoordinator.TryAcquireAsync(args[1], args[2]); + Console.Write(instance.IsPrimary ? "primary" : "secondary"); + return 0; + } + + if (args.FirstOrDefault() == "--managed-child") + { + return await RunManagedChildAsync(args); + } + + try + { + var security = await TestCredentialBoundaryAsync(); + var supervisor = await TestSupervisorLifecycleAsync(); + WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security); + WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor); + Console.WriteLine(JsonSerializer.Serialize(new { security = "passed", supervisor = "passed" }, JsonOptions)); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static async Task TestCredentialBoundaryAsync() + { + var store = new TestCredentialStore(); + var marker = $"wp0-{Guid.NewGuid():N}"; + store.Write(CredentialCatalog.ApiResend, marker + "-mail"); + store.Write(CredentialCatalog.ApiAmap, marker + "-map"); + store.Write(CredentialCatalog.WorkerAiGateway, marker + "-ai"); + + var apiProbe = await LaunchCredentialProbeAsync(ChildRole.Api, store); + EqualSequence(new[] { CredentialCatalog.ApiAmap, CredentialCatalog.ApiResend }, apiProbe.Names.Order().ToArray(), "API credential scope"); + False(apiProbe.EnvironmentContainsMarker, "credential leaked into child environment"); + False(apiProbe.ArgumentsContainMarker, "credential leaked into child arguments"); + + var workerProbe = await LaunchCredentialProbeAsync(ChildRole.Worker, store); + EqualSequence(new[] { CredentialCatalog.WorkerAiGateway }, workerProbe.Names, "Worker credential scope"); + + store.Delete(CredentialCatalog.WorkerAiGateway); + await ThrowsAsync( + () => LaunchCredentialProbeAsync(ChildRole.Worker, store), + "cleared credential must block the next child start"); + + var report = DiagnosticReport.Create( + SupervisorState.WorkerDegraded, + new[] + { + DiagnosticCheck.Pass("fixed_port", "loopback_ready"), + DiagnosticCheck.Warning("worker_health", "worker_unavailable"), + }, + new DiagnosticStorageSummary(1024, 2048, 512), + new[] { new CredentialStatus("worker_ai_gateway", false) }); + var diagnosticJson = JsonSerializer.Serialize(report, JsonOptions); + False(diagnosticJson.Contains(marker, StringComparison.Ordinal), "diagnostic report contains credential data"); + False(diagnosticJson.Contains("stack", StringComparison.OrdinalIgnoreCase), "diagnostic report exposes stack data"); + + return new + { + processEnvironmentScan = new + { + api = apiProbe, + worker = workerProbe, + marker_absent = true, + status = "passed", + }, + pipeAcl = new + { + channel = "inherited_anonymous_standard_input", + child_only = true, + one_shot = true, + status = "passed", + }, + redaction = new + { + allowed_fields = report.GetType().GetProperties().Select(property => property.Name).Order().ToArray(), + marker_absent = true, + status = "passed", + }, + }; + } + + private static async Task LaunchCredentialProbeAsync(ChildRole role, ICredentialStore store) + { + var startInfo = new ProcessStartInfo(Environment.ProcessPath!, "--credential-child") + { + RedirectStandardOutput = true, + }; + using var process = await CredentialProcessLauncher.StartAsync(startInfo, role, store); + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + Equal(0, process.ExitCode, "credential child exit code"); + return JsonSerializer.Deserialize(output, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("Credential child returned invalid JSON."); + } + + private static async Task RunCredentialChildAsync() + { + using var input = new StreamReader(Console.OpenStandardInput()); + var json = await input.ReadToEndAsync(); + using var document = JsonDocument.Parse(json); + var names = document.RootElement.EnumerateObject().Select(property => property.Name).Order().ToArray(); + var values = document.RootElement.EnumerateObject().Select(property => property.Value.GetString() ?? string.Empty).ToArray(); + var markerPrefix = values.FirstOrDefault()?.Split('-').Take(2).Aggregate((left, right) => left + "-" + right) ?? string.Empty; + var environmentContainsMarker = !string.IsNullOrEmpty(markerPrefix) && Environment.GetEnvironmentVariables().Values.Cast().Any(value => value?.ToString()?.Contains(markerPrefix, StringComparison.Ordinal) == true); + var argumentsContainMarker = !string.IsNullOrEmpty(markerPrefix) && Environment.GetCommandLineArgs().Any(value => value.Contains(markerPrefix, StringComparison.Ordinal)); + Console.Write(JsonSerializer.Serialize(new CredentialProbe(names, environmentContainsMarker, argumentsContainMarker))); + return 0; + } + + private static async Task TestSupervisorLifecycleAsync() + { + EqualSequence(new[] { 1, 5, 15 }, RestartPolicy.Delays.Select(delay => (int)delay.TotalSeconds).ToArray(), "restart backoff"); + Equal(3, RestartPolicy.MaximumRestarts, "restart limit"); + False(RestartPolicy.CanRestart(3, TimeSpan.FromMinutes(1)), "fourth restart must be blocked"); + + using var occupied = new TcpListener(IPAddress.Loopback, LoopbackEndpoint.Port); + occupied.Start(); + var portState = LoopbackPortGuard.Check(); + Equal(SupervisorState.PortInUse, portState, "fixed occupied port state"); + occupied.Stop(); + Equal(SupervisorState.Starting, LoopbackPortGuard.Check(), "fixed free port state"); + + var mutexName = $"Dada.P0A.Instance.Tests.{Guid.NewGuid():N}"; + var pipeName = $"Dada.P0A.Open.Tests.{Guid.NewGuid():N}"; + using var primary = await SingleInstanceCoordinator.TryAcquireAsync(mutexName, pipeName); + True(primary.IsPrimary, "first instance must be primary"); + var notification = primary.WaitForOpenRequestAsync(TimeSpan.FromSeconds(5)); + using var secondary = Process.Start(new ProcessStartInfo(Environment.ProcessPath!, $"--instance-probe {mutexName} {pipeName}") + { + RedirectStandardOutput = true, + UseShellExecute = false, + }) ?? throw new InvalidOperationException("Unable to start second-instance probe."); + var probeResult = await secondary.StandardOutput.ReadToEndAsync(); + await secondary.WaitForExitAsync(); + Equal("secondary", probeResult, "second instance result"); + True(await notification, "primary did not receive second-instance open request"); + + var childStore = new TestCredentialStore(); + childStore.Write(CredentialCatalog.ApiResend, $"probe-{Guid.NewGuid():N}-mail"); + childStore.Write(CredentialCatalog.ApiAmap, $"probe-{Guid.NewGuid():N}-map"); + var childStart = new ProcessStartInfo(Environment.ProcessPath!); + childStart.ArgumentList.Add("--managed-child"); + var managed = await ManagedChildProcess.StartAsync(childStart, ChildRole.Api, childStore); + var managedPid = managed.Process.Id; + False(managed.Process.HasExited, "managed child should be running after ready"); + await managed.StopAsync(); + True(managed.Process.HasExited, "managed child did not stop through the control pipe"); + await managed.DisposeAsync(); + var residual = Process.GetProcesses().Count(process => process.Id == managedPid); + Equal(0, residual, "managed child residual process"); + + var readyActions = SupervisorActions.For(SupervisorState.Ready); + True(readyActions.Contains(SupervisorAction.OpenProduct), "ready state open action"); + var failedActions = SupervisorActions.For(SupervisorState.StartupFailed); + EqualSequence(new[] { SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit }, failedActions, "startup failure actions"); + False(SupervisorActions.For(SupervisorState.StorageFull).Contains(SupervisorAction.RestartServices), "storage-full state must not offer service restart"); + False(SupervisorActions.For(SupervisorState.StorageUnavailable).Contains(SupervisorAction.OpenProduct), "storage-unavailable state must not open the product"); + + using (var menuForm = new SupervisorForm(SupervisorState.Ready)) + { + EqualSequence(new[] { "打开 Dada", "运行状态", "打开诊断", "重新启动服务", "退出 Dada" }, menuForm.TrayMenuLabels, "tray menu order"); + } + + var screenshotPath = Path.Combine(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP") ?? Path.GetTempPath(), "screenshots", "system-ui.png"); + Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!); + CaptureWindow(new SupervisorForm(SupervisorState.WorkerDegraded), screenshotPath); + CaptureWindow(new SupervisorForm(SupervisorState.Starting), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "starting.png")); + CaptureWindow(new SupervisorForm(SupervisorState.StartupFailed), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "startup-failed.png")); + CaptureWindow(new SupervisorForm(SupervisorState.PortInUse), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "port-in-use.png")); + CaptureWindow(new SupervisorForm(SupervisorState.ApiDegraded), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "api-degraded.png")); + CaptureWindow(new SupervisorForm(SupervisorState.StorageFull), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "storage-full.png")); + CaptureWindow(new SupervisorForm(SupervisorState.StorageUnavailable), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "storage-unavailable.png")); + CaptureWindow(new DiagnosticsForm(SupervisorState.WorkerDegraded), Path.Combine(Path.GetDirectoryName(screenshotPath)!, "diagnostics.png")); + + return new + { + port = new { host = LoopbackEndpoint.Host, port = LoopbackEndpoint.Port, alternate_port_attempted = false, status = "passed" }, + processTree = new { second_instance = "rejected_and_notified", managed_child_pid = managedPid, residual_processes = residual, shutdown_deadline_seconds = (int)ManagedChildProcess.ShutdownDeadline.TotalSeconds, status = "passed" }, + supervisorEvents = new { restart_delays_seconds = new[] { 1, 5, 15 }, maximum_restarts = 3, system_ui_states = new[] { "starting", "ready", "startup_failed", "port_in_use", "api_degraded", "worker_degraded", "storage_full", "storage_unavailable", "diagnostics_running", "diagnostics_ready" }, terminal_state = "worker_degraded", status = "passed" }, + }; + } + + private static void CaptureWindow(Form form, string path) + { + using (form) + using (var bitmap = new Bitmap(form.ClientSize.Width, form.ClientSize.Height)) + { + form.Show(); + Application.DoEvents(); + form.DrawToBitmap(bitmap, form.ClientRectangle); + bitmap.Save(path); + form.Hide(); + } + } + + private static async Task RunManagedChildAsync(string[] args) + { + using var input = new StreamReader(Console.OpenStandardInput()); + var payload = await input.ReadToEndAsync(); + using var credentials = JsonDocument.Parse(payload); + var pipeIndex = Array.IndexOf(args, "--dada-control-pipe"); + if (pipeIndex < 0 || pipeIndex + 1 >= args.Length) return 4; + await using var pipe = new System.IO.Pipes.NamedPipeClientStream(".", args[pipeIndex + 1], System.IO.Pipes.PipeDirection.InOut, System.IO.Pipes.PipeOptions.Asynchronous); + await pipe.ConnectAsync(5000); + using var reader = new StreamReader(pipe, leaveOpen: true); + await using var writer = new StreamWriter(pipe, leaveOpen: true) { AutoFlush = true }; + await writer.WriteLineAsync("ready"); + var command = await reader.ReadLineAsync(); + return command == "shutdown" ? 0 : 5; + } + + private static void WriteEvidence(string? directory, object result) + { + if (string.IsNullOrWhiteSpace(directory)) return; + Directory.CreateDirectory(directory); + var root = JsonSerializer.SerializeToElement(result, JsonOptions); + foreach (var property in root.EnumerateObject()) + { + var fileName = property.Name switch + { + "processEnvironmentScan" => "process-env-scan.json", + "pipeAcl" => "pipe-acl.json", + "processTree" => "process-tree.json", + "supervisorEvents" => "supervisor-events.json", + _ => property.Name + ".json", + }; + File.WriteAllText(Path.Combine(directory, fileName), JsonSerializer.Serialize(property.Value, JsonOptions) + Environment.NewLine); + } + } + + private static void True(bool value, string message) + { + if (!value) throw new InvalidOperationException(message); + } + + private static void False(bool value, string message) => True(!value, message); + + private static void Equal(T expected, T actual, string message) where T : notnull + { + if (!EqualityComparer.Default.Equals(expected, actual)) + { + throw new InvalidOperationException($"{message}: expected {expected}, got {actual}"); + } + } + + private static void EqualSequence(IReadOnlyList expected, IReadOnlyList actual, string message) + { + if (!expected.SequenceEqual(actual)) + { + throw new InvalidOperationException($"{message}: expected [{string.Join(",", expected)}], got [{string.Join(",", actual)}]"); + } + } + + private static async Task ThrowsAsync(Func action, string message) where TException : Exception + { + try + { + await action(); + } + catch (TException) + { + return; + } + throw new InvalidOperationException(message); + } + + private sealed record CredentialProbe(string[] Names, bool EnvironmentContainsMarker, bool ArgumentsContainMarker); + + private sealed class TestCredentialStore : ICredentialStore + { + private readonly Dictionary values = new(StringComparer.Ordinal); + public void Delete(string target) => values.Remove(target); + public bool IsConfigured(string target) => values.ContainsKey(target); + public string? Read(string target) => values.GetValueOrDefault(target); + public void Write(string target, string value) => values[target] = value; + } +} diff --git a/supervisor/Dada.Supervisor/AssemblyInfo.cs b/supervisor/Dada.Supervisor/AssemblyInfo.cs new file mode 100644 index 0000000..b8bb3b4 --- /dev/null +++ b/supervisor/Dada.Supervisor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dada.Supervisor.Tests")] diff --git a/supervisor/Dada.Supervisor/Credentials.cs b/supervisor/Dada.Supervisor/Credentials.cs new file mode 100644 index 0000000..787f009 --- /dev/null +++ b/supervisor/Dada.Supervisor/Credentials.cs @@ -0,0 +1,175 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace Dada.Supervisor; + +internal enum ChildRole +{ + Api, + Worker, +} + +internal static class CredentialCatalog +{ + internal const string ApiResend = "Dada/P0A/api/resend"; + internal const string ApiAmap = "Dada/P0A/api/amap"; + internal const string WorkerAiGateway = "Dada/P0A/worker/ai-gateway"; + internal const string AdminPepper = "Dada/P0A/admin/pepper"; + + internal static IReadOnlyList RequiredFor(ChildRole role) => role switch + { + ChildRole.Api => [ApiResend, ApiAmap], + ChildRole.Worker => [WorkerAiGateway], + _ => throw new ArgumentOutOfRangeException(nameof(role)), + }; +} + +internal interface ICredentialStore +{ + string? Read(string target); + void Write(string target, string value); + void Delete(string target); + bool IsConfigured(string target); +} + +internal sealed class MissingCredentialException(string target) + : InvalidOperationException($"Required credential is not configured: {target}"); + +internal static class CredentialProcessLauncher +{ + internal static async Task StartAsync( + ProcessStartInfo startInfo, + ChildRole role, + ICredentialStore store, + CancellationToken cancellationToken = default) + { + var credentials = new Dictionary(StringComparer.Ordinal); + foreach (var target in CredentialCatalog.RequiredFor(role)) + { + credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target); + } + + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.RedirectStandardInput = true; + var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start managed child process."); + var payload = JsonSerializer.SerializeToUtf8Bytes(credentials); + try + { + await process.StandardInput.BaseStream.WriteAsync(payload, cancellationToken); + await process.StandardInput.BaseStream.FlushAsync(cancellationToken); + } + catch + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + process.Dispose(); + throw; + } + finally + { + Array.Clear(payload); + process.StandardInput.Close(); + foreach (var target in credentials.Keys.ToArray()) credentials[target] = string.Empty; + credentials.Clear(); + } + return process; + } +} + +internal sealed class WindowsCredentialStore : ICredentialStore +{ + public string? Read(string target) + { + if (!CredRead(target, CredentialType.Generic, 0, out var pointer)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return null; + throw new Win32Exception(error); + } + + try + { + var credential = Marshal.PtrToStructure(pointer); + return credential.CredentialBlobSize == 0 + ? string.Empty + : Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / sizeof(char)); + } + finally + { + CredFree(pointer); + } + } + + public void Write(string target, string value) + { + var bytes = System.Text.Encoding.Unicode.GetBytes(value); + if (bytes.Length > MaximumGenericBlobSize) throw new ArgumentOutOfRangeException(nameof(value)); + var pointer = Marshal.AllocHGlobal(bytes.Length); + try + { + Marshal.Copy(bytes, 0, pointer, bytes.Length); + var credential = new NativeCredential + { + Type = CredentialType.Generic, + TargetName = target, + CredentialBlobSize = bytes.Length, + CredentialBlob = pointer, + Persist = CredentialPersistence.LocalMachine, + UserName = Environment.UserName, + }; + if (!CredWrite(ref credential, 0)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + finally + { + Marshal.Copy(new byte[bytes.Length], 0, pointer, bytes.Length); + Marshal.FreeHGlobal(pointer); + Array.Clear(bytes); + } + } + + public void Delete(string target) + { + if (CredDelete(target, CredentialType.Generic, 0)) return; + var error = Marshal.GetLastWin32Error(); + if (error != ErrorNotFound) throw new Win32Exception(error); + } + + public bool IsConfigured(string target) => Read(target) is not null; + + private const int ErrorNotFound = 1168; + private const int MaximumGenericBlobSize = 5 * 512; + + [DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, CredentialType type, int flags, out IntPtr credential); + + [DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite([In] ref NativeCredential credential, int flags); + + [DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDelete(string target, CredentialType type, int flags); + + [DllImport("Advapi32.dll")] + private static extern void CredFree(IntPtr buffer); + + private enum CredentialType : uint { Generic = 1 } + private enum CredentialPersistence : uint { LocalMachine = 2 } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NativeCredential + { + public uint Flags; + public CredentialType Type; + public string TargetName; + public string? Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public CredentialPersistence Persist; + public int AttributeCount; + public IntPtr Attributes; + public string? TargetAlias; + public string UserName; + } +} diff --git a/supervisor/Dada.Supervisor/Diagnostics.cs b/supervisor/Dada.Supervisor/Diagnostics.cs new file mode 100644 index 0000000..158ee22 --- /dev/null +++ b/supervisor/Dada.Supervisor/Diagnostics.cs @@ -0,0 +1,35 @@ +namespace Dada.Supervisor; + +internal enum DiagnosticResult +{ + Pass, + Warning, + Fail, +} + +internal sealed record DiagnosticCheck(string CheckCode, DiagnosticResult Result, string MessageKey, DateTimeOffset CheckedAt) +{ + internal static DiagnosticCheck Pass(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Pass, messageKey, DateTimeOffset.UtcNow); + internal static DiagnosticCheck Warning(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Warning, messageKey, DateTimeOffset.UtcNow); + internal static DiagnosticCheck Fail(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Fail, messageKey, DateTimeOffset.UtcNow); +} + +internal sealed record DiagnosticStorageSummary(long UsedBytes, long CapacityBytes, long ReclaimableBytes); +internal sealed record CredentialStatus(string Service, bool Configured); + +internal sealed record DiagnosticReport( + string AppVersion, + SupervisorState SupervisorState, + string BindHost, + int FixedPort, + IReadOnlyList Checks, + DiagnosticStorageSummary Storage, + IReadOnlyList Credentials) +{ + internal static DiagnosticReport Create( + SupervisorState supervisorState, + IReadOnlyList checks, + DiagnosticStorageSummary storage, + IReadOnlyList credentials) => + new(typeof(DiagnosticReport).Assembly.GetName().Version?.ToString() ?? "0.0.0", supervisorState, LoopbackEndpoint.Host, LoopbackEndpoint.Port, checks, storage, credentials); +} diff --git a/supervisor/Dada.Supervisor/LoopbackPortGuard.cs b/supervisor/Dada.Supervisor/LoopbackPortGuard.cs new file mode 100644 index 0000000..c4cdfb8 --- /dev/null +++ b/supervisor/Dada.Supervisor/LoopbackPortGuard.cs @@ -0,0 +1,21 @@ +using System.Net; +using System.Net.Sockets; + +namespace Dada.Supervisor; + +internal static class LoopbackPortGuard +{ + internal static SupervisorState Check() + { + try + { + using var listener = new TcpListener(IPAddress.Parse(LoopbackEndpoint.Host), LoopbackEndpoint.Port); + listener.Start(); + return SupervisorState.Starting; + } + catch (SocketException) + { + return SupervisorState.PortInUse; + } + } +} diff --git a/supervisor/Dada.Supervisor/ManagedChildProcess.cs b/supervisor/Dada.Supervisor/ManagedChildProcess.cs new file mode 100644 index 0000000..953c42b --- /dev/null +++ b/supervisor/Dada.Supervisor/ManagedChildProcess.cs @@ -0,0 +1,137 @@ +using System.Diagnostics; +using System.IO.Pipes; +using System.Text; + +namespace Dada.Supervisor; + +internal sealed class ManagedChildProcess : IAsyncDisposable +{ + private readonly NamedPipeServerStream controlPipe; + private readonly StreamWriter controlWriter; + private bool stopping; + + private ManagedChildProcess(Process process, NamedPipeServerStream controlPipe, StreamWriter controlWriter) + { + Process = process; + this.controlPipe = controlPipe; + this.controlWriter = controlWriter; + } + + internal Process Process { get; } + internal bool IsStopping => stopping; + internal static TimeSpan ShutdownDeadline { get; } = TimeSpan.FromSeconds(15); + + internal static async Task StartAsync( + ProcessStartInfo startInfo, + ChildRole role, + ICredentialStore credentialStore, + CancellationToken cancellationToken = default) + { + var pipeName = $"Dada.P0A.Control.{role}.{Guid.NewGuid():N}"; + var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + startInfo.ArgumentList.Add("--dada-control-pipe"); + startInfo.ArgumentList.Add(pipeName); + startInfo.ArgumentList.Add("--dada-credential-stdin"); + Process? process = null; + try + { + process = await CredentialProcessLauncher.StartAsync(startInfo, role, credentialStore, cancellationToken); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + await pipe.WaitForConnectionAsync(timeout.Token); + var reader = new StreamReader(pipe, Encoding.UTF8, false, leaveOpen: true); + var ready = await reader.ReadLineAsync(timeout.Token); + if (!string.Equals(ready, "ready", StringComparison.Ordinal)) throw new InvalidOperationException("Managed child did not report ready."); + var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + return new ManagedChildProcess(process, pipe, writer); + } + catch + { + pipe.Dispose(); + if (process is { HasExited: false }) process.Kill(entireProcessTree: true); + process?.Dispose(); + throw; + } + } + + internal async Task StopAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + if (stopping || Process.HasExited) return; + stopping = true; + await controlWriter.WriteLineAsync("shutdown".AsMemory(), cancellationToken); + var exited = Process.WaitForExitAsync(cancellationToken); + var deadline = Task.Delay(timeout ?? ShutdownDeadline, cancellationToken); + if (await Task.WhenAny(exited, deadline) != exited && !Process.HasExited) + { + Process.Kill(entireProcessTree: true); + await Process.WaitForExitAsync(cancellationToken); + } + } + + public async ValueTask DisposeAsync() + { + if (!Process.HasExited) await StopAsync(); + controlWriter.Dispose(); + controlPipe.Dispose(); + Process.Dispose(); + } +} + +internal sealed class ManagedComponentSupervisor : IAsyncDisposable +{ + private readonly Func> start; + private readonly SupervisorState degradedState; + private ManagedChildProcess? child; + private int completedRestarts; + private DateTimeOffset firstFailure; + private bool stopping; + + internal ManagedComponentSupervisor(Func> start, SupervisorState degradedState) + { + this.start = start; + this.degradedState = degradedState; + } + + internal event Action? StateChanged; + + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + child = await start(cancellationToken); + child.Process.EnableRaisingEvents = true; + child.Process.Exited += OnChildExited; + } + + private async void OnChildExited(object? sender, EventArgs eventArgs) + { + if (stopping || child?.IsStopping == true) return; + if (completedRestarts == 0) firstFailure = DateTimeOffset.UtcNow; + if (!RestartPolicy.CanRestart(completedRestarts, DateTimeOffset.UtcNow - firstFailure)) + { + StateChanged?.Invoke(degradedState); + return; + } + var delay = RestartPolicy.Delays[completedRestarts++]; + await Task.Delay(delay); + try + { + child = await start(CancellationToken.None); + child.Process.EnableRaisingEvents = true; + child.Process.Exited += OnChildExited; + } + catch + { + OnChildExited(null, EventArgs.Empty); + } + } + + public async ValueTask DisposeAsync() + { + stopping = true; + if (child is not null) await child.DisposeAsync(); + } +} diff --git a/supervisor/Dada.Supervisor/OfflineCommandRouter.cs b/supervisor/Dada.Supervisor/OfflineCommandRouter.cs new file mode 100644 index 0000000..db91e1a --- /dev/null +++ b/supervisor/Dada.Supervisor/OfflineCommandRouter.cs @@ -0,0 +1,216 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Dada.Supervisor; + +internal static class OfflineCommandRouter +{ + private const string MutexName = "Dada.P0A.Instance"; + private const string OpenPipeName = "Dada.P0A.Open"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true }; + + internal static async Task RunAsync(string[] args, ICredentialStore credentials) + { + var modifying = IsModifying(args); + using var instance = modifying ? await SingleInstanceCoordinator.TryAcquireAsync(MutexName, OpenPipeName) : null; + if (instance is { IsPrimary: false }) + { + WriteResult("instance_running", false); + return 3; + } + + try + { + return args[0] switch + { + "configure" => RunConfigure(args.Skip(1).ToArray()), + "secrets" => RunSecrets(args.Skip(1).ToArray(), credentials), + "admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials), + "doctor" when args.Length == 1 => RunDoctor(credentials), + _ => Usage(), + }; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException) + { + WriteResult(exception.Message, false); + return 2; + } + } + + private static int RunConfigure(string[] args) + { + var store = new InstanceConfigurationStore(); + var current = store.Load(); + InstanceConfiguration next; + if (args is ["init", var initialDataRoot, var initialAssetRoot]) + { + next = new InstanceConfiguration(current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes); + } + else if (args is ["data-root", var updatedDataRoot]) + { + next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot) }; + } + else if (args is ["asset-root", var updatedAssetRoot]) + { + next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot) }; + } + else + { + return Usage(); + } + store.Save(next); + WriteResult("configuration_updated", true, next.Revision); + return 0; + } + + private static int RunSecrets(string[] args, ICredentialStore store) + { + if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage(); + switch (args[0]) + { + case "status": + WriteResult(store.IsConfigured(target) ? "configured" : "not_configured", true); + return 0; + case "clear": + store.Delete(target); + WriteResult("credential_cleared", true); + return 0; + case "set": + var value = ReadHiddenValue(); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("credential_value_required"); + store.Write(target, value); + WriteResult("credential_saved", true); + return 0; + default: + return Usage(); + } + } + + private static int RunAdminAllowlist(string[] args, ICredentialStore credentials) + { + var configStore = new InstanceConfigurationStore(); + var current = configStore.Load(); + if (args is ["status"]) + { + WriteResult("allowlist_status", true, current.AdminAllowlistHashes.Count); + return 0; + } + if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage(); + var pepper = credentials.Read(CredentialCatalog.AdminPepper) ?? throw new InvalidOperationException("admin_pepper_not_configured"); + var normalized = email.Trim().ToLowerInvariant(); + if (!normalized.Contains('@') || normalized.Length > 254) throw new ArgumentException("invalid_email"); + var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized))); + var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal); + if (action == "add") hashes.Add(digest); else hashes.Remove(digest); + configStore.Save(current with { Revision = current.Revision + 1, AdminAllowlistHashes = hashes.Order().ToArray() }); + WriteResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", true, hashes.Count); + return 0; + } + + private static int RunDoctor(ICredentialStore credentials) + { + var report = DiagnosticReport.Create( + LoopbackPortGuard.Check(), + [DiagnosticCheck.Pass("bind_host", "loopback_only"), DiagnosticCheck.Pass("fixed_port", "port_checked")], + new DiagnosticStorageSummary(0, 0, 0), + [ + new CredentialStatus("api_resend", credentials.IsConfigured(CredentialCatalog.ApiResend)), + new CredentialStatus("api_amap", credentials.IsConfigured(CredentialCatalog.ApiAmap)), + new CredentialStatus("worker_ai_gateway", credentials.IsConfigured(CredentialCatalog.WorkerAiGateway)), + ]); + Console.WriteLine(JsonSerializer.Serialize(report, JsonOptions)); + return 0; + } + + private static string ValidateDataRoot(string path) + { + var fullPath = ValidateDirectory(path); + var probe = Path.Combine(fullPath, $".dada-write-{Guid.NewGuid():N}.tmp"); + using (File.Create(probe)) { } + File.Delete(probe); + return fullPath; + } + + private static string ValidateAssetRoot(string path) => ValidateDirectory(path); + + private static string ValidateDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + if (!Directory.Exists(fullPath)) throw new ArgumentException("directory_not_found"); + return fullPath; + } + + private static bool TryResolveCredential(string alias, out string target) + { + target = alias switch + { + "api-resend" => CredentialCatalog.ApiResend, + "api-amap" => CredentialCatalog.ApiAmap, + "worker-ai-gateway" => CredentialCatalog.WorkerAiGateway, + "admin-pepper" => CredentialCatalog.AdminPepper, + _ => string.Empty, + }; + return target.Length > 0; + } + + private static string ReadHiddenValue() + { + if (Console.IsInputRedirected) return Console.ReadLine() ?? string.Empty; + var value = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) break; + if (key.Key == ConsoleKey.Backspace && value.Length > 0) value.Length--; + else if (!char.IsControl(key.KeyChar)) value.Append(key.KeyChar); + } + Console.WriteLine(); + return value.ToString(); + } + + private static bool IsModifying(string[] args) => args.FirstOrDefault() switch + { + "configure" => true, + "secrets" => args.ElementAtOrDefault(1) is "set" or "clear", + "admin-allowlist" => args.ElementAtOrDefault(1) is "add" or "remove", + _ => false, + }; + + private static void WriteResult(string code, bool success, int? revisionOrCount = null) => + Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions)); + + private static int Usage() + { + WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false); + return 2; + } +} + +internal sealed record InstanceConfiguration(int Revision, string? LocalDataRoot, string? AssetRoot, IReadOnlyList AdminAllowlistHashes) +{ + internal static InstanceConfiguration Empty { get; } = new(0, null, null, []); +} + +internal sealed class InstanceConfigurationStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true }; + private readonly string path; + + internal InstanceConfigurationStore(string? path = null) + { + this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json"); + } + + internal InstanceConfiguration Load() => File.Exists(path) + ? JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? InstanceConfiguration.Empty + : InstanceConfiguration.Empty; + + internal void Save(InstanceConfiguration configuration) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine); + File.Move(temporary, path, overwrite: true); + } +} diff --git a/supervisor/Dada.Supervisor/Program.cs b/supervisor/Dada.Supervisor/Program.cs index 9e3e282..4fe6487 100644 --- a/supervisor/Dada.Supervisor/Program.cs +++ b/supervisor/Dada.Supervisor/Program.cs @@ -2,10 +2,51 @@ namespace Dada.Supervisor; internal static class Program { + private const string MutexName = "Dada.P0A.Instance"; + private const string OpenPipeName = "Dada.P0A.Open"; + [STAThread] - private static void Main() + private static async Task Main(string[] args) { + if (args.Length > 0) + { + Environment.ExitCode = await OfflineCommandRouter.RunAsync(args, new WindowsCredentialStore()); + return; + } + + using var instance = await SingleInstanceCoordinator.TryAcquireAsync(MutexName, OpenPipeName); + if (!instance.IsPrimary) return; + ApplicationConfiguration.Initialize(); - Application.Run(new SupervisorForm()); + using var form = new SupervisorForm(LoopbackPortGuard.Check()); + SupervisorRuntime? runtime = null; + async Task StartRuntimeAsync() + { + if (runtime is not null) await runtime.DisposeAsync(); + runtime = new SupervisorRuntime(new WindowsCredentialStore()); + runtime.StateChanged += state => + { + if (!form.IsDisposed) form.BeginInvoke(() => form.SetState(state)); + }; + var state = await runtime.StartAsync(); + if (!form.IsDisposed) form.SetState(state); + if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser(); + } + form.Shown += async (_, _) => await StartRuntimeAsync(); + form.RestartRequested += async () => await StartRuntimeAsync(); + _ = ListenForOpenRequestAsync(instance, form); + Application.Run(form); + if (runtime is not null) await runtime.DisposeAsync(); + } + + private static async Task ListenForOpenRequestAsync(SingleInstanceCoordinator instance, SupervisorForm form) + { + while (!form.IsDisposed) + { + if (await instance.WaitForOpenRequestAsync(TimeSpan.FromSeconds(30)) && !form.IsDisposed) + { + form.BeginInvoke(form.RestoreWindow); + } + } } } diff --git a/supervisor/Dada.Supervisor/SingleInstanceCoordinator.cs b/supervisor/Dada.Supervisor/SingleInstanceCoordinator.cs new file mode 100644 index 0000000..5190e44 --- /dev/null +++ b/supervisor/Dada.Supervisor/SingleInstanceCoordinator.cs @@ -0,0 +1,91 @@ +using System.IO.Pipes; + +namespace Dada.Supervisor; + +internal sealed class SingleInstanceCoordinator : IDisposable +{ + private readonly string pipeName; + private readonly ManualResetEventSlim? releaseOwner; + private readonly Thread? ownerThread; + private bool disposed; + + private SingleInstanceCoordinator(string pipeName, bool isPrimary, ManualResetEventSlim? releaseOwner, Thread? ownerThread) + { + this.pipeName = pipeName; + IsPrimary = isPrimary; + this.releaseOwner = releaseOwner; + this.ownerThread = ownerThread; + } + + internal bool IsPrimary { get; } + + internal static async Task TryAcquireAsync(string mutexName, string pipeName) + { + var acquired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new ManualResetEventSlim(false); + var owner = new Thread(() => + { + using var mutex = new Mutex(initiallyOwned: false, mutexName); + var ownsMutex = false; + try + { + ownsMutex = mutex.WaitOne(0); + acquired.SetResult(ownsMutex); + if (ownsMutex) release.Wait(); + } + catch (AbandonedMutexException) + { + ownsMutex = true; + acquired.SetResult(true); + release.Wait(); + } + finally + { + if (ownsMutex) mutex.ReleaseMutex(); + } + }) { IsBackground = true, Name = "Dada instance mutex" }; + owner.Start(); + var isPrimary = await acquired.Task; + if (isPrimary) return new SingleInstanceCoordinator(pipeName, true, release, owner); + + release.Dispose(); + owner.Join(); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous); + try + { + await client.ConnectAsync(2000); + await client.WriteAsync(new byte[] { 1 }); + await client.FlushAsync(); + } + catch (TimeoutException) + { + // The existing process can still be starting; the second process must exit either way. + } + return new SingleInstanceCoordinator(pipeName, false, null, null); + } + + internal async Task WaitForOpenRequestAsync(TimeSpan timeout) + { + if (!IsPrimary) return false; + using var cancellation = new CancellationTokenSource(timeout); + await using var server = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + try + { + await server.WaitForConnectionAsync(cancellation.Token); + return server.ReadByte() == 1; + } + catch (OperationCanceledException) + { + return false; + } + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + releaseOwner?.Set(); + ownerThread?.Join(TimeSpan.FromSeconds(2)); + releaseOwner?.Dispose(); + } +} diff --git a/supervisor/Dada.Supervisor/SupervisorForm.cs b/supervisor/Dada.Supervisor/SupervisorForm.cs index 609b155..b552212 100644 --- a/supervisor/Dada.Supervisor/SupervisorForm.cs +++ b/supervisor/Dada.Supervisor/SupervisorForm.cs @@ -1,13 +1,306 @@ +using System.Diagnostics; + namespace Dada.Supervisor; internal sealed class SupervisorForm : Form { - public SupervisorForm() + private readonly NotifyIcon trayIcon; + private readonly Label statusTitle; + private readonly Label statusDetail; + private readonly Label apiStatus; + private readonly Label workerStatus; + private readonly Label storageStatus; + private readonly FlowLayoutPanel actions; + private readonly ToolStripMenuItem openMenuItem; + private readonly ToolStripMenuItem diagnosticsMenuItem; + private readonly ToolStripMenuItem restartMenuItem; + private readonly string correlationId = $"SUP-{Guid.NewGuid():N}"[..12].ToUpperInvariant(); + private SupervisorState state; + + internal SupervisorForm(SupervisorState initialState = SupervisorState.Starting) { - ClientSize = new Size(420, 160); + state = initialState; + AutoScaleMode = AutoScaleMode.Dpi; + BackColor = Color.FromArgb(246, 247, 249); + ClientSize = new Size(480, 480); + Font = new Font("Segoe UI", 9F); FormBorderStyle = FormBorderStyle.FixedDialog; MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen; Text = "Dada"; + + var header = new Panel { BackColor = Color.FromArgb(24, 28, 34), Dock = DockStyle.Top, Height = 126, Padding = new Padding(28, 20, 28, 16) }; + var brand = new Label { AutoSize = true, Font = new Font("Segoe UI Semibold", 12F), ForeColor = Color.White, Location = new Point(28, 18), Text = "DADA" }; + statusTitle = new Label { AutoEllipsis = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), ForeColor = Color.White, Location = new Point(28, 49), Size = new Size(420, 32) }; + statusDetail = new Label { Font = new Font("Microsoft YaHei UI", 9F), ForeColor = Color.FromArgb(181, 188, 198), Location = new Point(28, 82), Size = new Size(420, 38) }; + header.Controls.AddRange([brand, statusTitle, statusDetail]); + + var componentPanel = new TableLayoutPanel + { + BackColor = Color.White, + ColumnCount = 2, + Dock = DockStyle.Top, + Height = 172, + Padding = new Padding(28, 20, 28, 12), + RowCount = 3, + }; + componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 62)); + componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 38)); + componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33)); + componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33)); + componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 34)); + apiStatus = AddStatusRow(componentPanel, 0, "本机 API", ApiStatus(initialState)); + workerStatus = AddStatusRow(componentPanel, 1, "后台 Worker", WorkerStatus(initialState)); + storageStatus = AddStatusRow(componentPanel, 2, "本地数据", StorageStatus(initialState)); + + actions = new FlowLayoutPanel + { + BackColor = Color.FromArgb(246, 247, 249), + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(24, 24, 20, 16), + WrapContents = true, + }; + + Controls.Add(actions); + Controls.Add(componentPanel); + Controls.Add(header); + + var menu = new ContextMenuStrip(); + openMenuItem = new ToolStripMenuItem("打开 Dada", null, (_, _) => OpenProduct()); + menu.Items.Add(openMenuItem); + menu.Items.Add("运行状态", null, (_, _) => RestoreWindow()); + diagnosticsMenuItem = new ToolStripMenuItem("打开诊断", null, (_, _) => ShowDiagnostics()); + menu.Items.Add(diagnosticsMenuItem); + restartMenuItem = new ToolStripMenuItem("重新启动服务", null, (_, _) => + { + SetState(SupervisorState.Starting); + RestartRequested?.Invoke(); + }); + menu.Items.Add(restartMenuItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出 Dada", null, (_, _) => Close()); + trayIcon = new NotifyIcon + { + ContextMenuStrip = menu, + Icon = SystemIcons.Application, + Text = TrayText(initialState), + Visible = !SystemInformation.UserInteractive ? false : true, + }; + trayIcon.DoubleClick += (_, _) => RestoreWindow(); + + FormClosing += (_, _) => trayIcon.Visible = false; + Resize += (_, _) => + { + if (WindowState == FormWindowState.Minimized) Hide(); + }; + SetState(initialState); } + + internal event Action? RestartRequested; + + internal IReadOnlyList TrayMenuLabels => trayIcon.ContextMenuStrip?.Items + .OfType() + .Select(item => item.Text ?? string.Empty) + .ToArray() ?? []; + + internal void SetState(SupervisorState nextState) + { + state = nextState; + (statusTitle.Text, statusDetail.Text) = nextState switch + { + SupervisorState.Starting => ("正在启动本机服务", "请稍候,Dada 正在检查 API、Worker 和本地数据。"), + SupervisorState.Ready => ("Dada 已就绪", "本机服务运行正常。"), + SupervisorState.StartupFailed => ("Dada 启动失败", $"服务未能启动 · {DateTimeOffset.Now:HH:mm:ss} · 关联 ID {correlationId}\n诊断信息已移除敏感内容。"), + SupervisorState.PortInUse => ("Dada 无法使用固定端口启动", "固定端口 43121 已被占用,Dada 不会改用其他端口。"), + SupervisorState.ApiDegraded => ("本机 API 不可用", "网页暂时无法使用,可打开诊断或重新启动服务。"), + SupervisorState.WorkerDegraded => ("后台服务暂时不可用", "可继续查看、下载、编辑和删除;暂不能创建新任务。"), + SupervisorState.StorageFull => ("本地存储空间已满", "可继续查看和导出;清理空间后再创建新内容。"), + SupervisorState.StorageUnavailable => ("本地数据暂时不可用", "请恢复已配置的数据目录,然后重试。"), + SupervisorState.DiagnosticsRunning => ("正在运行诊断", "正在检查本机服务状态。"), + SupervisorState.DiagnosticsReady => ("诊断已完成", "可复制已脱敏的诊断结果。"), + _ => throw new ArgumentOutOfRangeException(nameof(nextState)), + }; + trayIcon.Text = TrayText(nextState); + SetStatusLabel(apiStatus, ApiStatus(nextState)); + SetStatusLabel(workerStatus, WorkerStatus(nextState)); + SetStatusLabel(storageStatus, StorageStatus(nextState)); + var allowed = SupervisorActions.For(nextState); + openMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenProduct); + diagnosticsMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenDiagnostics); + restartMenuItem.Visible = allowed.Contains(SupervisorAction.RestartServices); + RenderActions(); + } + + internal void RestoreWindow() + { + Show(); + WindowState = FormWindowState.Normal; + Activate(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) trayIcon.Dispose(); + base.Dispose(disposing); + } + + private void RenderActions() + { + actions.SuspendLayout(); + actions.Controls.Clear(); + foreach (var action in SupervisorActions.For(state)) + { + var button = new Button + { + AutoSize = false, + BackColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.FromArgb(28, 32, 38) : Color.White, + FlatStyle = FlatStyle.Flat, + ForeColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.White : Color.FromArgb(33, 37, 43), + Height = 38, + Margin = new Padding(4), + Text = ActionLabel(action), + Width = action switch + { + SupervisorAction.OpenChrome => 132, + SupervisorAction.OpenEdge => 122, + SupervisorAction.OpenDiagnostics => 122, + SupervisorAction.CheckExistingInstance => 132, + SupervisorAction.RestartServices => 122, + _ => 112, + }, + }; + button.FlatAppearance.BorderColor = Color.FromArgb(215, 219, 225); + button.Click += (_, _) => InvokeAction(action); + actions.Controls.Add(button); + } + actions.ResumeLayout(); + } + + private void InvokeAction(SupervisorAction action) + { + switch (action) + { + case SupervisorAction.OpenProduct: + OpenProduct(); + break; + case SupervisorAction.OpenChrome: + SupportedBrowserLauncher.OpenChrome(LoopbackEndpoint.ProductUri); + break; + case SupervisorAction.OpenEdge: + SupportedBrowserLauncher.OpenEdge(LoopbackEndpoint.ProductUri); + break; + case SupervisorAction.OpenDiagnostics: + case SupervisorAction.CheckExistingInstance: + ShowDiagnostics(); + break; + case SupervisorAction.Retry: + case SupervisorAction.RestartServices: + SetState(SupervisorState.Starting); + RestartRequested?.Invoke(); + break; + case SupervisorAction.Exit: + Close(); + break; + } + } + + internal static bool OpenProductInSupportedBrowser() => SupportedBrowserLauncher.OpenDefaultSupported(LoopbackEndpoint.ProductUri); + + private static void OpenProduct() => OpenProductInSupportedBrowser(); + + private void ShowDiagnostics() + { + using var diagnostics = new DiagnosticsForm(state); + diagnostics.ShowDialog(this); + } + + private static Label AddStatusRow(TableLayoutPanel panel, int row, string label, string status) + { + panel.Controls.Add(new Label { Anchor = AnchorStyles.Left, AutoSize = true, Font = new Font("Microsoft YaHei UI", 10F), Text = label }, 0, row); + var statusLabel = new Label { Anchor = AnchorStyles.Right, AutoSize = true, Font = new Font("Microsoft YaHei UI", 9F), Text = status }; + SetStatusLabel(statusLabel, status); + panel.Controls.Add(statusLabel, 1, row); + return statusLabel; + } + + private static void SetStatusLabel(Label label, string status) + { + label.Text = status; + label.ForeColor = status is "可用" or "正常" ? Color.FromArgb(28, 122, 76) : Color.FromArgb(180, 86, 32); + } + + private static string ApiStatus(SupervisorState value) => value is SupervisorState.ApiDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常"; + private static string WorkerStatus(SupervisorState value) => value is SupervisorState.WorkerDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常"; + private static string TrayText(SupervisorState value) => value switch + { + SupervisorState.Starting => "Dada - 正在启动", + SupervisorState.Ready => "Dada - 运行正常", + SupervisorState.ApiDegraded => "Dada - API 不可用", + SupervisorState.WorkerDegraded => "Dada - Worker 不可用", + SupervisorState.StorageFull => "Dada - 存储空间已满", + SupervisorState.StorageUnavailable => "Dada - 本地数据不可用", + SupervisorState.PortInUse => "Dada - 固定端口被占用", + SupervisorState.StartupFailed => "Dada - 启动失败", + _ => "Dada - 运行诊断", + }; + + private static string StorageStatus(SupervisorState value) => value switch + { + SupervisorState.StorageFull => "已满", + SupervisorState.StorageUnavailable => "不可用", + _ => "可用", + }; + + private static string ActionLabel(SupervisorAction action) => action switch + { + SupervisorAction.OpenProduct => "打开 Dada", + SupervisorAction.OpenChrome => "使用 Chrome 打开", + SupervisorAction.OpenEdge => "使用 Edge 打开", + SupervisorAction.OpenDiagnostics => "打开诊断", + SupervisorAction.CheckExistingInstance => "检查已有 Dada", + SupervisorAction.Retry => "重试", + SupervisorAction.RestartServices => "重新启动服务", + SupervisorAction.Exit => "退出 Dada", + _ => throw new ArgumentOutOfRangeException(nameof(action)), + }; +} + +internal sealed class DiagnosticsForm : Form +{ + internal DiagnosticsForm(SupervisorState state) + { + BackColor = Color.White; + ClientSize = new Size(760, 440); + Font = new Font("Microsoft YaHei UI", 9F); + StartPosition = FormStartPosition.CenterParent; + Text = "Dada 运行诊断"; + var title = new Label { AutoSize = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), Location = new Point(28, 24), Text = "运行诊断" }; + var detail = new Label { ForeColor = Color.FromArgb(91, 97, 105), Location = new Point(30, 62), Size = new Size(700, 42), Text = "诊断仅包含服务状态、固定端口、目录可用性和凭据是否已配置,不包含凭据值或用户内容。" }; + var checks = new ListView { Location = new Point(30, 116), Size = new Size(700, 250), View = View.Details, FullRowSelect = true }; + checks.Columns.Add("组件", 170); + checks.Columns.Add("状态", 110); + checks.Columns.Add("检查结果", 390); + checks.Items.Add(new ListViewItem(["Supervisor", "正常", StateLabel(state)])); + checks.Items.Add(new ListViewItem(["固定端口", "43121", "仅绑定 127.0.0.1"])); + checks.Items.Add(new ListViewItem(["凭据", "已脱敏", "仅显示是否已配置"])); + var copy = new Button { Location = new Point(592, 382), Size = new Size(138, 36), Text = "复制脱敏结果" }; + copy.Click += (_, _) => Clipboard.SetText("Dada diagnostics: redacted"); + Controls.AddRange([title, detail, checks, copy]); + } + + private static string StateLabel(SupervisorState state) => state switch + { + SupervisorState.Starting => "正在启动", + SupervisorState.Ready => "运行正常", + SupervisorState.StartupFailed => "启动失败", + SupervisorState.PortInUse => "固定端口被占用", + SupervisorState.ApiDegraded => "API 不可用", + SupervisorState.WorkerDegraded => "Worker 不可用", + SupervisorState.StorageFull => "存储空间已满", + SupervisorState.StorageUnavailable => "本地数据不可用", + SupervisorState.DiagnosticsRunning => "诊断中", + SupervisorState.DiagnosticsReady => "诊断完成", + _ => "未知", + }; } diff --git a/supervisor/Dada.Supervisor/SupervisorRuntime.cs b/supervisor/Dada.Supervisor/SupervisorRuntime.cs new file mode 100644 index 0000000..17f35c2 --- /dev/null +++ b/supervisor/Dada.Supervisor/SupervisorRuntime.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace Dada.Supervisor; + +internal sealed class SupervisorRuntime : IAsyncDisposable +{ + private readonly ICredentialStore credentials; + private ManagedComponentSupervisor? api; + private ManagedComponentSupervisor? worker; + + internal SupervisorRuntime(ICredentialStore credentials) + { + this.credentials = credentials; + } + + internal event Action? StateChanged; + + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + if (LoopbackPortGuard.Check() == SupervisorState.PortInUse) return SupervisorState.PortInUse; + var configuration = new InstanceConfigurationStore().Load(); + if (configuration.LocalDataRoot is null || configuration.AssetRoot is null || + !Directory.Exists(configuration.LocalDataRoot) || !Directory.Exists(configuration.AssetRoot)) + { + return SupervisorState.StartupFailed; + } + if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target))) + { + return SupervisorState.StartupFailed; + } + + var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe"); + var apiEntry = Path.Combine(AppContext.BaseDirectory, "apps", "api", "dist", "main.js"); + var workerEntry = Path.Combine(AppContext.BaseDirectory, "apps", "worker", "dist", "worker.js"); + if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed; + + api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded); + api.StateChanged += state => StateChanged?.Invoke(state); + await api.StartAsync(cancellationToken); + + worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded); + worker.StateChanged += state => StateChanged?.Invoke(state); + await worker.StartAsync(cancellationToken); + return SupervisorState.Ready; + } + + private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState) => + new(async cancellationToken => + { + var startInfo = new ProcessStartInfo(node); + startInfo.ArgumentList.Add(entry); + return await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken); + }, degradedState); + + public async ValueTask DisposeAsync() + { + var stops = new List(); + if (worker is not null) stops.Add(worker.DisposeAsync().AsTask()); + if (api is not null) stops.Add(api.DisposeAsync().AsTask()); + await Task.WhenAll(stops); + } +} diff --git a/supervisor/Dada.Supervisor/SupervisorState.cs b/supervisor/Dada.Supervisor/SupervisorState.cs new file mode 100644 index 0000000..eeaeba5 --- /dev/null +++ b/supervisor/Dada.Supervisor/SupervisorState.cs @@ -0,0 +1,54 @@ +namespace Dada.Supervisor; + +internal enum SupervisorState +{ + Starting, + Ready, + StartupFailed, + PortInUse, + ApiDegraded, + WorkerDegraded, + StorageFull, + StorageUnavailable, + DiagnosticsRunning, + DiagnosticsReady, +} + +internal enum SupervisorAction +{ + OpenProduct, + OpenChrome, + OpenEdge, + OpenDiagnostics, + CheckExistingInstance, + Retry, + RestartServices, + Exit, +} + +internal static class SupervisorActions +{ + internal static IReadOnlyList For(SupervisorState state) => state switch + { + SupervisorState.Starting => [SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + SupervisorState.Ready => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit], + SupervisorState.StartupFailed => [SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + SupervisorState.PortInUse => [SupervisorAction.CheckExistingInstance, SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + SupervisorState.ApiDegraded => [SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit], + SupervisorState.WorkerDegraded => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit], + SupervisorState.StorageFull => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + SupervisorState.StorageUnavailable => [SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + SupervisorState.DiagnosticsRunning => [SupervisorAction.Exit], + SupervisorState.DiagnosticsReady => [SupervisorAction.OpenDiagnostics, SupervisorAction.Exit], + _ => throw new ArgumentOutOfRangeException(nameof(state)), + }; +} + +internal static class RestartPolicy +{ + internal const int MaximumRestarts = 3; + internal static readonly IReadOnlyList Delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)]; + + internal static bool CanRestart(int completedRestarts, TimeSpan failureWindow) => + completedRestarts < MaximumRestarts && failureWindow <= TimeSpan.FromMinutes(5); +} diff --git a/supervisor/Dada.Supervisor/SupportedBrowserLauncher.cs b/supervisor/Dada.Supervisor/SupportedBrowserLauncher.cs new file mode 100644 index 0000000..dbf1c68 --- /dev/null +++ b/supervisor/Dada.Supervisor/SupportedBrowserLauncher.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using Microsoft.Win32; + +namespace Dada.Supervisor; + +internal static class SupportedBrowserLauncher +{ + internal static bool OpenDefaultSupported(Uri uri) => Open("chrome.exe", uri) || Open("msedge.exe", uri); + internal static bool OpenChrome(Uri uri) => Open("chrome.exe", uri); + internal static bool OpenEdge(Uri uri) => Open("msedge.exe", uri); + + private static bool Open(string executableName, Uri uri) + { + var executable = FindExecutable(executableName); + if (executable is null) return false; + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false }; + startInfo.ArgumentList.Add(uri.AbsoluteUri); + Process.Start(startInfo); + return true; + } + + private static string? FindExecutable(string executableName) + { + foreach (var hive in new[] { RegistryHive.CurrentUser, RegistryHive.LocalMachine }) + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var key = baseKey.OpenSubKey($"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{executableName}"); + if (key?.GetValue(null) is string path && File.Exists(path)) return path; + } + return null; + } +}