diff --git a/apps/api/src/local-data-root.ts b/apps/api/src/local-data-root.ts index 258db9d..6ddce5f 100644 --- a/apps/api/src/local-data-root.ts +++ b/apps/api/src/local-data-root.ts @@ -33,6 +33,12 @@ const fixedDirectories = [ "logs/supervisor", ] as const; +export function ensureLocalDataRuntimeDirectories(dataRoot: string) { + for (const directory of fixedDirectories) { + mkdirSync(join(resolve(dataRoot), directory), { recursive: true }); + } +} + export const DATA_TRANSFER_POLICY = { allowed_downloads: ["original_generation", "jpg", "png"], application_backup: false, @@ -219,9 +225,7 @@ export function initializeLocalDataRoot(input: { const createdRoot = !existsSync(validation.normalized_path); try { - for (const directory of fixedDirectories) { - mkdirSync(join(validation.normalized_path, directory), { recursive: true }); - } + ensureLocalDataRuntimeDirectories(validation.normalized_path); openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3")); const configuration: InstanceConfiguration = { data_root: validation.normalized_path, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f7034b4..8975c21 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts"; import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; -import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; +import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; import { LatestExportService } from "./latest-exports.js"; import { CreditService } from "./credits.js"; @@ -40,6 +40,7 @@ if (credentialChannelEnabled) { .update(`Dada/P0A/${purpose}/v1`, "utf8") .digest(); const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + ensureLocalDataRuntimeDirectories(dataRoot); const databasePath = join(dataRoot, "db", "dada.sqlite3"); registration = new RegistrationService({ adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), diff --git a/supervisor/Dada.Supervisor.Tests/Program.cs b/supervisor/Dada.Supervisor.Tests/Program.cs index cf4a5b3..24a0a28 100644 --- a/supervisor/Dada.Supervisor.Tests/Program.cs +++ b/supervisor/Dada.Supervisor.Tests/Program.cs @@ -40,6 +40,7 @@ internal static class Program var supervisor = await TestSupervisorLifecycleAsync(); await TestAmapProbeSecurityAsync(); TestSecureConfigurationPersistence(); + TestRuntimeDirectoryBootstrap(); TestStructuredLogging(); WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security); WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor); @@ -125,6 +126,29 @@ internal static class Program } } + private static void TestRuntimeDirectoryBootstrap() + { + var root = Path.Combine(Path.GetTempPath(), $"dada-runtime-root-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(root); + SupervisorRuntime.EnsureRuntimeDirectories(root); + foreach (var relativePath in new[] + { + "db", "content/references", "content/generated", "content/exports", + "managed-assets", "derived-assets", "staging", + "logs/api", "logs/worker", "logs/supervisor", + }) + { + True(Directory.Exists(Path.Combine(root, relativePath)), $"runtime directory missing: {relativePath}"); + } + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + private static async Task TestCredentialBoundaryAsync() { var store = new TestCredentialStore(); diff --git a/supervisor/Dada.Supervisor/Credentials.cs b/supervisor/Dada.Supervisor/Credentials.cs index 15f3d84..1314546 100644 --- a/supervisor/Dada.Supervisor/Credentials.cs +++ b/supervisor/Dada.Supervisor/Credentials.cs @@ -50,7 +50,9 @@ internal static class CredentialProcessLauncher var credentials = new Dictionary(StringComparer.Ordinal); foreach (var target in CredentialCatalog.RequiredFor(role)) { - credentials[target] = store.Read(target) ?? string.Empty; + var value = store.Read(target); + if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target); + credentials[target] = value ?? string.Empty; } startInfo.UseShellExecute = false; @@ -98,7 +100,9 @@ internal static class CredentialProcessLauncher var credentials = new Dictionary(StringComparer.Ordinal); foreach (var target in CredentialCatalog.RequiredFor(role)) { - credentials[target] = store.Read(target) ?? string.Empty; + var value = store.Read(target); + if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target); + credentials[target] = value ?? string.Empty; } startInfo.UseShellExecute = false; diff --git a/supervisor/Dada.Supervisor/SupervisorRuntime.cs b/supervisor/Dada.Supervisor/SupervisorRuntime.cs index a58fe69..91f2fdb 100644 --- a/supervisor/Dada.Supervisor/SupervisorRuntime.cs +++ b/supervisor/Dada.Supervisor/SupervisorRuntime.cs @@ -5,6 +5,19 @@ namespace Dada.Supervisor; internal sealed class SupervisorRuntime : IAsyncDisposable { + private static readonly string[] RequiredDataDirectories = + [ + "db", + Path.Combine("content", "references"), + Path.Combine("content", "generated"), + Path.Combine("content", "exports"), + "managed-assets", + "derived-assets", + "staging", + Path.Combine("logs", "api"), + Path.Combine("logs", "worker"), + Path.Combine("logs", "supervisor"), + ]; private readonly ICredentialStore credentials; private ManagedComponentSupervisor? api; private ManagedComponentSupervisor? worker; @@ -26,6 +39,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable { return SupervisorState.StartupFailed; } + EnsureRuntimeDirectories(configuration.LocalDataRoot); try { logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor"); @@ -42,12 +56,30 @@ internal sealed class SupervisorRuntime : IAsyncDisposable var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs"); if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed; - api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded); - await api.StartAsync(cancellationToken); + try + { + api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded); + await api.StartAsync(cancellationToken); - worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded); - await worker.StartAsync(cancellationToken); - return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable; + worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded); + await worker.StartAsync(cancellationToken); + return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable; + } + catch + { + await StopComponentsAsync(); + return TryLog(new StructuredLogEvent("failed", ErrorCategory: "service_unavailable")) + ? SupervisorState.StartupFailed + : SupervisorState.StorageUnavailable; + } + } + + internal static void EnsureRuntimeDirectories(string dataRoot) + { + foreach (var directory in RequiredDataDirectories) + { + Directory.CreateDirectory(Path.Combine(dataRoot, directory)); + } } private void EnsureAdminPepper() @@ -102,10 +134,26 @@ internal sealed class SupervisorRuntime : IAsyncDisposable } public async ValueTask DisposeAsync() + { + await StopComponentsAsync(); + } + + private async Task StopComponentsAsync() { var stops = new List(); if (worker is not null) stops.Add(worker.DisposeAsync().AsTask()); if (api is not null) stops.Add(api.DisposeAsync().AsTask()); - await Task.WhenAll(stops); + try + { + await Task.WhenAll(stops); + } + catch + { + } + finally + { + worker = null; + api = null; + } } } diff --git a/tests/package/postv1-runtime.test.mjs b/tests/package/postv1-runtime.test.mjs index d69132c..6593924 100644 --- a/tests/package/postv1-runtime.test.mjs +++ b/tests/package/postv1-runtime.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -49,7 +49,6 @@ test("portable package serves the product and keeps SQLite data across API resta assert.match(await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"), /GenerationProcessor/); const root = await mkdtemp(join(tmpdir(), "dada-postv1-")); const dataRoot = join(root, "data"); - await mkdir(join(dataRoot, "db"), { recursive: true }); const configPath = join(root, "instance.json"); await writeFile(configPath, JSON.stringify({ data_root: dataRoot, initialized: true, instance_id: "portable-test", schema_version: 1, secure_config_revision: 1, admin_allowlist_hashes: [], admin_recovery_hashes: [] })); let api = startApi(configPath, dataRoot);