using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Manager;
///
/// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4).
///
/// Die Kürze dieser Klasse ist die Zusage. 99 Prozent der Einstellungen
/// gehören der Instanz und gelten für jede einzeln; hier steht nur, was sich gar nicht je
/// Instanz sagen lässt: eine Weboberfläche für alle, eine Lizenz für die Installation, ein
/// Master-Token, aus dem die Sub-Token entstehen, und der Ort, an dem die Instanzen
/// liegen — den muss er wissen, bevor es überhaupt eine gibt.
///
/// Farbschema, Teilerstellungen, Protokollstufe, Sicherungszeiten: alles Werte einer
/// Oberfläche oder einer Instanz. Wer hier ein Feld ergänzt, sollte erst beantworten,
/// warum es nicht je Instanz gelten kann.
///
/// Nicht dabei: eine Datenbank. Was der Manager braucht, liegt als JSON im
/// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die
/// state.db einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und
/// prozessübergreifend gilt die Schreibsperre von SqliteStorage nicht.
///
public sealed class ManagerSettings
{
// ─── Wo die Instanzen liegen ───
[JsonPropertyName("instancesDirectory")]
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
///
/// Die Programmdatei, mit der ein Instanzprozess gestartet wird.
///
/// Leer heißt: neben der des Managers. Das ist der Normalfall und der Grund,
/// warum hier fast immer nichts steht — beide kommen aus demselben Paket.
///
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
///
/// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2).
///
/// Vorbelegt auf true: Jede Instanz behält ihr Desktop-Fenster, sie laufen
/// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne
/// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier false hin;
/// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
/// bedienen.
///
[JsonPropertyName("instanceWindows")]
public bool InstanceWindows { get; set; } = true;
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
public int WebUiPort { get; set; } = 7000;
/// loopback (Vorgabe) oder any.
[JsonPropertyName("webUiBinding")]
public string WebUiBinding { get; set; } = "loopback";
/// Zugangstoken der Weboberfläche. Verschlüsselt gespeichert.
[JsonPropertyName("webUiToken")]
public string WebUiToken { get; set; } = "";
// ─── Lizenz (E-M3) ───
///
/// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert.
///
/// Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation
/// und erlaubt beliebig viele Instanzen.
///
[JsonPropertyName("licenseKey")]
public string LicenseKey { get; set; } = "";
// ─── Deploymentcenter ───
[JsonPropertyName("deploymentcenterUrl")]
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
///
/// Das Master-Token. Verschlüsselt gespeichert.
///
/// Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf
/// watchdog:ping und bugtracker:report und einzeln widerrufbar. Nach dem
/// Umbau liegt auf keiner Instanz mehr das Master-Token.
///
[JsonPropertyName("deploymentcenterMasterToken")]
public string DeploymentcenterMasterToken { get; set; } = "";
/// Aktualisiert wird das Programm, nicht die Instanz.
[JsonPropertyName("updateChannel")]
public string UpdateChannel { get; set; } = "prod";
// ─── Aufsicht ───
/// Wie oft nachgesehen wird, in Sekunden.
[JsonPropertyName("probeIntervalSeconds")]
public int ProbeIntervalSeconds { get; set; } = 15;
/// Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.
[JsonPropertyName("startTimeoutSeconds")]
public int StartTimeoutSeconds { get; set; } = 60;
/// Wie viele Fehlversuche in Folge „antwortet nicht" ergeben.
[JsonPropertyName("silentChecksBeforeAlarm")]
public int SilentChecksBeforeAlarm { get; set; } = 3;
/// Wie lange eine Instanz nach der Bitte zu enden noch bekommt.
[JsonPropertyName("stopGraceSeconds")]
public int StopGraceSeconds { get; set; } = 20;
// ─── Neustart nach Absturz ───
///
/// Wie oft eine Instanz innerhalb von wieder
/// gestartet wird, bevor der Manager aussetzt.
///
/// Der Sicherungsautomat zum Autostart: Was an einer kaputten Konfiguration
/// scheitert, scheitert beim zehnten Versuch genauso — dann hat man statt einer toten
/// Instanz eine tote Instanz und ein volles Protokoll. Nach dem Aussetzen bleibt sie
/// aus, bis jemand sie von Hand startet; das ist gemeldet und nicht still.
///
[JsonPropertyName("maxRestartsInWindow")]
public int MaxRestartsInWindow { get; set; } = 3;
/// Der Zeitraum, über den zählt.
[JsonPropertyName("restartWindowMinutes")]
public int RestartWindowMinutes { get; set; } = 10;
/// Die geprüfte Neustartregel.
public RestartPolicy ToRestartPolicy() => new(
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440)));
///
/// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben.
///
/// Untergrenzen, weil unsinnige Zahlen in einer von Hand bearbeitbaren Datei
/// stehen können: Ein Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null
/// Fehlversuchen ein Alarm bei jedem Aussetzer.
///
public SupervisionOptions ToSupervisionOptions() => new(
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
}
///
/// Lädt und speichert manager.json.
///
/// Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht. Der Ort
/// kommt von , Geheimnisse liegen in der Datei
/// verschlüsselt und zur Laufzeit im Klartext, geschrieben wird atomar, und eine
/// unlesbare Datei verhindert den Start nicht. Wer eine der beiden kennt, kennt die
/// andere.
///
public sealed class ManagerSettingsStore
{
private const string FileName = "manager.json";
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
public ManagerSettingsStore(string? basePath = null)
=> SettingsPath = Path.Combine(basePath ?? AppPaths.ConfigDirectory, FileName);
/// Der Ort der Datei — für Meldungen und Diagnose.
public string SettingsPath { get; }
public ManagerSettings Settings { get; private set; } = new();
///
/// Warum die Datei nicht gelesen werden konnte — oder null, wenn alles ging.
///
/// Warum das nach aussen muss. Eine unlesbare Datei verhindert den Start
/// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr.
/// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der
/// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen,
/// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und
/// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert:
/// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei.
///
public string? LoadProblem { get; private set; }
public ManagerSettings Load()
{
LoadProblem = null;
if (!File.Exists(SettingsPath))
{
Settings = new ManagerSettings();
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
return Settings;
}
try
{
Settings = JsonSerializer.Deserialize(
AtomicFile.ReadAllText(SettingsPath), JsonOpts) ?? new ManagerSettings();
}
catch (Exception ex) when (ex is JsonException or IOException)
{
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
// gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund
// gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben.
LoadProblem = ex.Message;
Settings = new ManagerSettings();
return Settings;
}
Settings.LicenseKey = TryUnprotect(Settings.LicenseKey);
Settings.DeploymentcenterMasterToken = TryUnprotect(Settings.DeploymentcenterMasterToken);
Settings.WebUiToken = TryUnprotect(Settings.WebUiToken);
return Settings;
}
public void Save()
{
var lizenz = Settings.LicenseKey;
var master = Settings.DeploymentcenterMasterToken;
var web = Settings.WebUiToken;
try
{
Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? "";
Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? "";
Settings.WebUiToken = SecretProtector.Protect(web) ?? "";
AppPaths.EnsureDirectory(Path.GetDirectoryName(SettingsPath)!);
AtomicFile.WriteAllText(SettingsPath, JsonSerializer.Serialize(Settings, JsonOpts));
AppPaths.RestrictToOwner(SettingsPath);
}
finally
{
Settings.LicenseKey = lizenz;
Settings.DeploymentcenterMasterToken = master;
Settings.WebUiToken = web;
}
}
///
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner)
/// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen.
///
private static string TryUnprotect(string value)
{
try { return SecretProtector.Unprotect(value) ?? ""; }
catch (SecretProtectionException) { return ""; }
}
}