feat: complete TASK-WP0-07 supervisor
This commit is contained in:
@@ -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