feat: complete TASK-WP0-07 supervisor
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<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 shutdown().finally(() => socket.end());
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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> | 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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" }));
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Dada.Supervisor/Dada.Supervisor.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<int> 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<object> 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<MissingCredentialException>(
|
||||
() => 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<CredentialProbe> 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<CredentialProbe>(output, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidOperationException("Credential child returned invalid JSON.");
|
||||
}
|
||||
|
||||
private static async Task<int> 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<object?>().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<object> 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<int> 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>(T expected, T actual, string message) where T : notnull
|
||||
{
|
||||
if (!EqualityComparer<T>.Default.Equals(expected, actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{message}: expected {expected}, got {actual}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EqualSequence<T>(IReadOnlyList<T> expected, IReadOnlyList<T> actual, string message)
|
||||
{
|
||||
if (!expected.SequenceEqual(actual))
|
||||
{
|
||||
throw new InvalidOperationException($"{message}: expected [{string.Join(",", expected)}], got [{string.Join(",", actual)}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ThrowsAsync<TException>(Func<Task> 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<string, string> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Dada.Supervisor.Tests")]
|
||||
@@ -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<string> 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<Process> StartAsync(
|
||||
ProcessStartInfo startInfo,
|
||||
ChildRole role,
|
||||
ICredentialStore store,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var credentials = new Dictionary<string, string>(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<NativeCredential>(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;
|
||||
}
|
||||
}
|
||||
@@ -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<DiagnosticCheck> Checks,
|
||||
DiagnosticStorageSummary Storage,
|
||||
IReadOnlyList<CredentialStatus> Credentials)
|
||||
{
|
||||
internal static DiagnosticReport Create(
|
||||
SupervisorState supervisorState,
|
||||
IReadOnlyList<DiagnosticCheck> checks,
|
||||
DiagnosticStorageSummary storage,
|
||||
IReadOnlyList<CredentialStatus> credentials) =>
|
||||
new(typeof(DiagnosticReport).Assembly.GetName().Version?.ToString() ?? "0.0.0", supervisorState, LoopbackEndpoint.Host, LoopbackEndpoint.Port, checks, storage, credentials);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ManagedChildProcess> 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<CancellationToken, Task<ManagedChildProcess>> start;
|
||||
private readonly SupervisorState degradedState;
|
||||
private ManagedChildProcess? child;
|
||||
private int completedRestarts;
|
||||
private DateTimeOffset firstFailure;
|
||||
private bool stopping;
|
||||
|
||||
internal ManagedComponentSupervisor(Func<CancellationToken, Task<ManagedChildProcess>> start, SupervisorState degradedState)
|
||||
{
|
||||
this.start = start;
|
||||
this.degradedState = degradedState;
|
||||
}
|
||||
|
||||
internal event Action<SupervisorState>? 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();
|
||||
}
|
||||
}
|
||||
@@ -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<int> 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<string> 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<InstanceConfiguration>(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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SingleInstanceCoordinator> TryAcquireAsync(string mutexName, string pipeName)
|
||||
{
|
||||
var acquired = new TaskCompletionSource<bool>(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<bool> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> TrayMenuLabels => trayIcon.ContextMenuStrip?.Items
|
||||
.OfType<ToolStripMenuItem>()
|
||||
.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 => "诊断完成",
|
||||
_ => "未知",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<SupervisorState>? StateChanged;
|
||||
|
||||
internal async Task<SupervisorState> 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<Task>();
|
||||
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
|
||||
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
|
||||
await Task.WhenAll(stops);
|
||||
}
|
||||
}
|
||||
@@ -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<SupervisorAction> 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<TimeSpan> Delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)];
|
||||
|
||||
internal static bool CanRestart(int completedRestarts, TimeSpan failureWindow) =>
|
||||
completedRestarts < MaximumRestarts && failureWindow <= TimeSpan.FromMinutes(5);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user