From 5289f01bfd9d587c3453403a89ace6ff82b6f27e Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 2 Jun 2026 21:40:38 -0400 Subject: [PATCH] feat(onboarding): add first-run environment checker (#1290) * feat(onboarding): add first-run environment checker Add a preflight that checks whether the host can run Docker deploys before a deploy fails for an avoidable reason. It verifies the Docker engine is reachable and permitted, the Compose plugin is present, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and the compose volume has disk headroom. Each result that needs attention carries a specific fix rather than a generic error, and the checks never block: an operator who knows their setup can continue. The checks run as the final step of first-boot setup and can be re-run any time from the Recovery settings tab. A new admin-only endpoint, GET /api/diagnostics/environment, backs both surfaces. * fix(onboarding): distinguish unverified path mapping and support parent binds Treat a container whose self-inspect fails as an unverified path-mapping warning instead of a false "not containerized" pass, so an unverifiable mapping never reads as healthy. Resolve the compose directory through the longest-prefix bind mount and compare the host path it resolves to, so a parent bind such as -v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that the directory is not bind-mounted. * test(e2e): advance the setup wizard past the environment step in loginAs The first-run setup helper clicked "Initialize console" and immediately waited for the dashboard, but setup now shows an environment-preflight step before landing the console. Click "Enter Sencho" to complete onboarding before asserting the dashboard, so the first test on a fresh instance passes. --- .../src/__tests__/diagnostics-route.test.ts | 25 ++ .../environment-check-service.test.ts | 313 ++++++++++++++ backend/src/routes/diagnostics.ts | 20 + .../src/services/EnvironmentCheckService.ts | 402 ++++++++++++++++++ backend/src/services/SelfIdentityService.ts | 26 ++ docs/getting-started/quickstart.mdx | 2 + docs/operations/recovery.mdx | 14 + e2e/helpers.ts | 6 +- frontend/src/components/Setup.tsx | 38 +- .../components/settings/EnvironmentChecks.tsx | 134 ++++++ .../components/settings/RecoverySection.tsx | 11 + 11 files changed, 989 insertions(+), 2 deletions(-) create mode 100644 backend/src/__tests__/environment-check-service.test.ts create mode 100644 backend/src/services/EnvironmentCheckService.ts create mode 100644 frontend/src/components/settings/EnvironmentChecks.tsx diff --git a/backend/src/__tests__/diagnostics-route.test.ts b/backend/src/__tests__/diagnostics-route.test.ts index c95c450c..22c2277a 100644 --- a/backend/src/__tests__/diagnostics-route.test.ts +++ b/backend/src/__tests__/diagnostics-route.test.ts @@ -63,3 +63,28 @@ describe('GET /api/diagnostics', () => { expect(res.body.config.cloud_backup_secret_key).toBeUndefined(); }); }); + +describe('GET /api/diagnostics/environment', () => { + it('requires authentication', async () => { + const res = await request(app).get('/api/diagnostics/environment'); + expect(res.status).toBe(401); + }); + + it('rejects a non-admin', async () => { + const res = await request(app).get('/api/diagnostics/environment').set('Authorization', viewerAuthHeader); + expect(res.status).toBe(403); + expect(res.body.code).toBe('ADMIN_REQUIRED'); + }); + + it('returns the environment report for an admin', async () => { + const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.checks)).toBe(true); + const ids = (res.body.checks as Array<{ id: string }>).map(c => c.id); + expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space']); + for (const c of res.body.checks as Array<{ status: string; detail: string }>) { + expect(['pass', 'warn', 'fail']).toContain(c.status); + expect(typeof c.detail).toBe('string'); + } + }); +}); diff --git a/backend/src/__tests__/environment-check-service.test.ts b/backend/src/__tests__/environment-check-service.test.ts new file mode 100644 index 00000000..fe87de82 --- /dev/null +++ b/backend/src/__tests__/environment-check-service.test.ts @@ -0,0 +1,313 @@ +/** + * Unit tests for EnvironmentCheckService.collectEnvironmentReport: the mapping + * from injected probe results to check rows, the per-check verdicts, and the + * guarantee that every non-pass row carries actionable remediation. IO is + * stubbed, so these run without a Docker daemon or filesystem. + */ +import { describe, it, expect } from 'vitest'; +import { + collectEnvironmentReport, + pickBackingMount, + type EnvironmentProbes, + type EnvironmentCheck, +} from '../services/EnvironmentCheckService'; + +function baseProbes(overrides: Partial = {}): EnvironmentProbes { + return { + proto: 'https', + host: 'sencho.example.com', + composeDir: '/app/compose', + pingDocker: async () => { /* reachable */ }, + composeVersion: async () => 'v2.29.0', + accessDir: async () => ({ exists: true, isDir: true, writable: true }), + bindMounts: async () => [{ source: '/app/compose', destination: '/app/compose' }], + diskUsage: async () => ({ usePercent: 40, freeBytes: 50 * 1024 ** 3 }), + ...overrides, + }; +} + +function byId(checks: EnvironmentCheck[], id: string): EnvironmentCheck { + const found = checks.find(c => c.id === id); + if (!found) throw new Error(`missing check: ${id}`); + return found; +} + +// remediation only exists on warn / fail rows (discriminated union); this reads +// it without forcing a narrow at every assertion site. +function remediationOf(c: EnvironmentCheck): string | undefined { + return 'remediation' in c ? c.remediation : undefined; +} + +describe('collectEnvironmentReport', () => { + it('passes every check on a healthy environment', async () => { + const { checks } = await collectEnvironmentReport(baseProbes()); + expect(checks.map(c => c.id)).toEqual([ + 'docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space', + ]); + expect(checks.every(c => c.status === 'pass')).toBe(true); + }); + + it('every non-pass row carries remediation', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }, + composeVersion: async () => { throw new Error('not found'); }, + accessDir: async () => ({ exists: false, isDir: false, writable: false }), + bindMounts: async () => [{ source: '/host/compose', destination: '/app/compose' }], + proto: 'http', + diskUsage: async () => ({ usePercent: 96, freeBytes: 1 * 1024 ** 3 }), + })); + for (const c of checks) { + if (c.status !== 'pass') expect(c.remediation, `${c.id} needs remediation`).toBeTruthy(); + } + }); + + describe('docker_socket', () => { + it('flags a permission error distinctly', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }, + })); + const c = byId(checks, 'docker_socket'); + expect(c.status).toBe('fail'); + // The permission remediation is the only one that mentions the docker group. + expect(remediationOf(c)).toMatch(/docker group/i); + }); + + it('flags an unreachable daemon with different guidance', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + pingDocker: async () => { throw Object.assign(new Error('down'), { code: 'ENOENT' }); }, + })); + const c = byId(checks, 'docker_socket'); + expect(c.status).toBe('fail'); + expect(remediationOf(c)).toMatch(/running/i); + // Must not give the permission-fix advice for a daemon-down error. + expect(remediationOf(c)).not.toMatch(/docker group/i); + }); + }); + + describe('docker_compose', () => { + it('fails when the plugin is absent', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + composeVersion: async () => { throw new Error('unknown command "compose"'); }, + })); + const c = byId(checks, 'docker_compose'); + expect(c.status).toBe('fail'); + expect(remediationOf(c)).toMatch(/install/i); + }); + + it('warns (not fails) when the version check times out', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + composeVersion: async () => { throw Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: 'ETIMEDOUT' }); }, + })); + const c = byId(checks, 'docker_compose'); + expect(c.status).toBe('warn'); + // A timeout must not tell the operator to install an already-present plugin. + expect(remediationOf(c)).not.toMatch(/install/i); + }); + + it('reports the version string on success', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ composeVersion: async () => 'v2.31.0' })); + const c = byId(checks, 'docker_compose'); + expect(c.status).toBe('pass'); + expect(c.detail).toContain('v2.31.0'); + }); + }); + + describe('compose_dir', () => { + it('fails when the directory is missing', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + accessDir: async () => ({ exists: false, isDir: false, writable: false }), + })); + expect(byId(checks, 'compose_dir').status).toBe('fail'); + }); + + it('fails when the directory is not writable', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + accessDir: async () => ({ exists: true, isDir: true, writable: false }), + })); + const c = byId(checks, 'compose_dir'); + expect(c.status).toBe('fail'); + expect(c.detail).toMatch(/not writable/i); + }); + + it('fails when the path exists but is not a directory', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + accessDir: async () => ({ exists: true, isDir: false, writable: false }), + })); + const c = byId(checks, 'compose_dir'); + expect(c.status).toBe('fail'); + expect(c.detail).toMatch(/not a directory/i); + }); + }); + + describe('path_mapping', () => { + it('passes when not containerized', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ bindMounts: async () => null })); + expect(byId(checks, 'path_mapping').status).toBe('pass'); + }); + + it('warns (not a false pass) when containerized but mounts are unreadable', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + bindMounts: async () => { throw new Error('self-inspect unavailable'); }, + })); + const c = byId(checks, 'path_mapping'); + expect(c.status).toBe('warn'); + expect(c.detail).toMatch(/could not read/i); + }); + + it('warns when host and container paths differ', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + bindMounts: async () => [{ source: '/srv/host-compose', destination: '/app/compose' }], + })); + const c = byId(checks, 'path_mapping'); + expect(c.status).toBe('warn'); + expect(remediationOf(c)).toContain('/app/compose:/app/compose'); + }); + + it('warns when the compose dir is not under a bind mount', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + bindMounts: async () => [{ source: '/srv/other', destination: '/data' }], + })); + expect(byId(checks, 'path_mapping').status).toBe('warn'); + }); + + it('treats a trailing slash as equal', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + bindMounts: async () => [{ source: '/app/compose/', destination: '/app/compose' }], + })); + expect(byId(checks, 'path_mapping').status).toBe('pass'); + }); + + it('passes a 1:1 parent bind that covers the compose dir', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + composeDir: '/opt/compose', + bindMounts: async () => [{ source: '/opt', destination: '/opt' }], + })); + expect(byId(checks, 'path_mapping').status).toBe('pass'); + }); + + it('warns when a parent bind maps the compose dir to a different host path', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + composeDir: '/opt/compose', + bindMounts: async () => [{ source: '/srv/opt', destination: '/opt' }], + })); + const c = byId(checks, 'path_mapping'); + expect(c.status).toBe('warn'); + expect(c.detail).toContain('/srv/opt/compose'); + }); + + it('selects the longest-prefix mount when both a parent and child bind exist', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + composeDir: '/opt/compose', + bindMounts: async () => [ + { source: '/wrong', destination: '/opt' }, + { source: '/opt/compose', destination: '/opt/compose' }, + ], + })); + expect(byId(checks, 'path_mapping').status).toBe('pass'); + }); + }); + + describe('tls', () => { + it('warns on plain HTTP to a non-loopback host', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'sencho.example.com' })); + expect(byId(checks, 'tls').status).toBe('warn'); + }); + + it('passes on HTTP to localhost', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'localhost:1852' })); + expect(byId(checks, 'tls').status).toBe('pass'); + }); + + it('passes on HTTP to an IPv6 loopback literal', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: '[::1]:1852' })); + expect(byId(checks, 'tls').status).toBe('pass'); + }); + + it('passes on HTTPS', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'https', host: 'sencho.example.com' })); + expect(byId(checks, 'tls').status).toBe('pass'); + }); + }); + + describe('disk_space', () => { + it('warns on high usage', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + diskUsage: async () => ({ usePercent: 95, freeBytes: 20 * 1024 ** 3 }), + })); + expect(byId(checks, 'disk_space').status).toBe('warn'); + }); + + it('warns on low free space even when usage percent is moderate', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + diskUsage: async () => ({ usePercent: 70, freeBytes: 1 * 1024 ** 3 }), + })); + expect(byId(checks, 'disk_space').status).toBe('warn'); + }); + + it('warns at exactly the usage threshold', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + diskUsage: async () => ({ usePercent: 90, freeBytes: 50 * 1024 ** 3 }), + })); + expect(byId(checks, 'disk_space').status).toBe('warn'); + }); + + it('warns at exactly the free-space threshold', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + diskUsage: async () => ({ usePercent: 50, freeBytes: 2 * 1024 ** 3 - 1 }), + })); + expect(byId(checks, 'disk_space').status).toBe('warn'); + }); + + it('warns (not a false pass) when usage cannot be determined', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ diskUsage: async () => null })); + const c = byId(checks, 'disk_space'); + expect(c.status).toBe('warn'); + expect(remediationOf(c)).toBeTruthy(); + }); + }); + + describe('pickBackingMount', () => { + it('returns null for an empty list', () => { + expect(pickBackingMount([], '/app/compose')).toBeNull(); + }); + + it('prefers the longest matching prefix mount', () => { + const got = pickBackingMount([ + { mount: '/', use: 50, available: 100 }, + { mount: '/app', use: 60, available: 200 }, + { mount: '/app/compose', use: 70, available: 300 }, + ], '/app/compose'); + expect(got).toEqual({ usePercent: 70, freeBytes: 300 }); + }); + + it('does not treat /app as a prefix of /application', () => { + const got = pickBackingMount([ + { mount: '/', use: 10, available: 999 }, + { mount: '/app', use: 80, available: 5 }, + ], '/application/data'); + // /app is not a path-segment prefix of /application, so root wins. + expect(got).toEqual({ usePercent: 10, freeBytes: 999 }); + }); + + it('falls back to the C: mount on Windows when nothing matches', () => { + const got = pickBackingMount([ + { mount: 'D:', use: 20, available: 10 }, + { mount: 'C:', use: 30, available: 40 }, + ], 'E:\\compose'); + expect(got).toEqual({ usePercent: 30, freeBytes: 40 }); + }); + }); + + it('degrades to a non-throwing report when probes reject', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + accessDir: async () => { throw new Error('stat blew up'); }, + bindMounts: async () => { throw new Error('inspect blew up'); }, + diskUsage: async () => { throw new Error('fsSize blew up'); }, + })); + // accessDir rejection degrades to "missing" -> fail; a rejected bindMounts + // is unverifiable -> warn; a rejected diskUsage is unknown -> warn. + expect(byId(checks, 'compose_dir').status).toBe('fail'); + expect(byId(checks, 'path_mapping').status).toBe('warn'); + expect(byId(checks, 'disk_space').status).toBe('warn'); + }); +}); diff --git a/backend/src/routes/diagnostics.ts b/backend/src/routes/diagnostics.ts index e944e87c..a29a1d6c 100644 --- a/backend/src/routes/diagnostics.ts +++ b/backend/src/routes/diagnostics.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { requireAdmin, requireUserSession } from '../middleware/tierGates'; import { collectDiagnostics } from '../services/DiagnosticsService'; +import { collectEnvironmentReport, buildRealProbes } from '../services/EnvironmentCheckService'; import DockerController from '../services/DockerController'; import { withTimeout } from '../utils/withTimeout'; @@ -36,3 +37,22 @@ diagnosticsRouter.get('/', async (req: Request, res: Response): Promise => res.status(500).json({ error: 'Failed to collect diagnostics.' }); } }); + +// First-run / preflight environment checks (Docker engine + Compose, the +// compose directory and its host path mapping, TLS, disk headroom). Same admin +// session gate as the recovery report. proto / host come from the request so +// the TLS verdict reflects how this browser reached the dashboard; behind a +// reverse proxy that terminates TLS, x-forwarded-proto carries the real scheme. +diagnosticsRouter.get('/environment', async (req: Request, res: Response): Promise => { + if (!requireUserSession(req, res)) return; + if (!requireAdmin(req, res)) return; + try { + const proto = (req.get('x-forwarded-proto')?.split(',')[0].trim()) || req.protocol; + const host = req.get('host') || ''; + const report = await collectEnvironmentReport(buildRealProbes({ proto, host })); + res.json(report); + } catch (err) { + console.error('[diagnostics] failed to collect environment report:', (err as Error).message); + res.status(500).json({ error: 'Failed to collect environment checks.' }); + } +}); diff --git a/backend/src/services/EnvironmentCheckService.ts b/backend/src/services/EnvironmentCheckService.ts new file mode 100644 index 00000000..80a2f3d4 --- /dev/null +++ b/backend/src/services/EnvironmentCheckService.ts @@ -0,0 +1,402 @@ +/** + * First-run / preflight environment checks shared by the setup wizard's final + * step and the admin Recovery settings tab. Where DiagnosticsService answers + * "is my install broken" (and runs without Docker), this answers "can my + * install actually run Docker deploys": is the Docker socket reachable and + * permitted, is `docker compose` v2 present, is the compose directory writable + * and mounted at the same path on host and container, is the dashboard behind + * TLS, and is there disk headroom. + * + * The mapping from raw probe results to check rows is kept pure and the IO is + * injected (see `EnvironmentProbes` / `buildRealProbes`), so the verdict logic + * is unit-testable without a live Docker daemon or filesystem. No probe failure + * throws out of the report: it degrades to a non-throwing row whose status fits + * the check. The Docker socket and compose-directory probes surface a `fail`; + * an unreadable disk degrades to a `warn` rather than a false `pass`. Path + * mapping distinguishes "not containerized" (a `pass`, the dev / bare-metal + * case) from "containerized but mounts unreadable" (a `warn`), so an + * unverifiable mapping never reads as healthy. + */ +import fs from 'fs/promises'; +import { constants as fsConstants } from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import si from 'systeminformation'; +import DockerController from './DockerController'; +import { NodeRegistry } from './NodeRegistry'; +import SelfIdentityService from './SelfIdentityService'; +import { withTimeout } from '../utils/withTimeout'; + +const execFileAsync = promisify(execFile); + +const DOCKER_PING_TIMEOUT_MS = 2000; +const COMPOSE_VERSION_TIMEOUT_MS = 5000; +const DISK_WARN_USE_PERCENT = 90; +const DISK_WARN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB + +export type CheckStatus = 'pass' | 'warn' | 'fail'; + +export type CheckId = + | 'docker_socket' + | 'docker_compose' + | 'compose_dir' + | 'path_mapping' + | 'tls' + | 'disk_space'; + +interface CheckBase { + id: CheckId; + label: string; + detail: string; +} + +// A pass row carries no remediation; a warn / fail row must carry one. Modelling +// this as a discriminated union makes "every actionable verdict ships a fix" a +// compile-time guarantee instead of a convention a future check could forget. +export type EnvironmentCheck = + | (CheckBase & { status: 'pass' }) + | (CheckBase & { status: 'warn' | 'fail'; remediation: string }); + +export interface EnvironmentReport { + checks: EnvironmentCheck[]; + generatedAt: number; +} + +export interface DirAccess { + exists: boolean; + isDir: boolean; + writable: boolean; +} + +export interface DiskUsage { + usePercent: number; + freeBytes: number; +} + +/** + * Injected IO for the checks. The route wires `buildRealProbes`; tests pass + * stubs. `proto` / `host` come from the request so the TLS check reflects how + * the operator's browser actually reached the dashboard. + */ +export interface EnvironmentProbes { + proto: string; + host: string; + composeDir: string; + /** Resolves when the Docker daemon answers a ping; rejects (with `.code`) otherwise. */ + pingDocker: () => Promise; + /** Resolves the `docker compose` version string; rejects when the plugin is absent. */ + composeVersion: () => Promise; + accessDir: (dir: string) => Promise; + /** + * Bind mounts on the Sencho container, or null when not containerized. A + * rejection means containerized-but-unreadable and is reported as an + * unverified path-mapping warn rather than a false pass. + */ + bindMounts: () => Promise | null>; + /** Disk usage of the filesystem backing the compose dir, or null when unknown. */ + diskUsage: (dir: string) => Promise; +} + +function isLoopbackHost(host: string): boolean { + // Strip the port; IPv6 literals arrive bracketed (`[::1]:1852`). + const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase(); + return name === 'localhost' || name === '127.0.0.1' || name === '::1' || name === ''; +} + +// Normalize for path comparison: drop a single trailing slash so +// `/app/compose` and `/app/compose/` compare equal. +function normPath(p: string): string { + return p.length > 1 ? p.replace(/[/\\]+$/, '') : p; +} + +async function checkDockerSocket(probe: EnvironmentProbes['pingDocker']): Promise { + const base = { id: 'docker_socket' as const, label: 'Docker engine' }; + try { + await probe(); + return { ...base, status: 'pass', detail: 'The Docker daemon is reachable.' }; + } catch (err) { + const code = (err as { code?: string })?.code; + if (code === 'EACCES' || code === 'EPERM') { + return { + ...base, + status: 'fail', + detail: 'Permission denied talking to the Docker socket.', + remediation: + 'Sencho cannot read /var/run/docker.sock. Mount the socket into the container ' + + '(-v /var/run/docker.sock:/var/run/docker.sock) and make sure Sencho runs as a ' + + 'user in the docker group, or with access to the socket.', + }; + } + return { + ...base, + status: 'fail', + detail: 'The Docker daemon is not reachable.', + remediation: + 'Confirm Docker is running on the host and that /var/run/docker.sock is mounted into ' + + 'the Sencho container (-v /var/run/docker.sock:/var/run/docker.sock).', + }; + } +} + +async function checkDockerCompose(probe: EnvironmentProbes['composeVersion']): Promise { + const base = { id: 'docker_compose' as const, label: 'Docker Compose' }; + try { + const version = (await probe()).trim(); + return { ...base, status: 'pass', detail: version ? `Compose ${version} is available.` : 'Compose is available.' }; + } catch (err) { + // A timeout (slow / busy daemon) is not the same as an absent plugin, so + // it must not produce the confident "install the plugin" remediation, + // which would send the operator down the wrong path. + const e = err as { code?: string; killed?: boolean; signal?: string | null }; + if (e.killed || e.signal != null || e.code === 'ETIMEDOUT') { + return { + ...base, + status: 'warn', + detail: 'Could not verify the Compose version in time.', + remediation: + 'The `docker compose version` check timed out, usually a slow or busy Docker daemon. ' + + 'Re-run once the host settles; if it keeps timing out, check daemon health.', + }; + } + return { + ...base, + status: 'fail', + detail: 'The Docker Compose v2 plugin was not found.', + remediation: + 'Install the Docker Compose v2 plugin so `docker compose version` succeeds. The official ' + + 'Docker images bundle it; on a bare host, install the docker-compose-plugin package.', + }; + } +} + +function checkComposeDir(dir: string, access: DirAccess): EnvironmentCheck { + const base = { id: 'compose_dir' as const, label: 'Compose directory' }; + if (!access.exists) { + return { + ...base, + status: 'fail', + detail: `${dir} does not exist.`, + remediation: `Create ${dir} on the host and mount it into Sencho, or point COMPOSE_DIR at an existing mounted directory.`, + }; + } + if (!access.isDir) { + return { + ...base, + status: 'fail', + detail: `${dir} exists but is not a directory.`, + remediation: `Remove the file at ${dir} or point COMPOSE_DIR at a directory.`, + }; + } + if (!access.writable) { + return { + ...base, + status: 'fail', + detail: `${dir} is not writable.`, + remediation: `Grant the user Sencho runs as write access to ${dir} so it can author and update compose files.`, + }; + } + return { ...base, status: 'pass', detail: `${dir} is present and writable.` }; +} + +// Bind mounts on the Sencho container: an array when containerized, `null` when +// confirmed not containerized, `'unknown'` when containerized but the mounts +// could not be read (so the verdict is an unverified warn, not a false pass). +type BindMounts = Array<{ source: string; destination: string }> | null | 'unknown'; + +function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck { + const base = { id: 'path_mapping' as const, label: 'Path mapping' }; + if (mounts === 'unknown') { + return { + ...base, + status: 'warn', + detail: 'Could not read the container mounts to verify path mapping.', + remediation: + `Sencho is running in a container but could not inspect its own mounts. Confirm the compose ` + + `directory is bind-mounted from the host at the same path, e.g. -v ${dir}:${dir}.`, + }; + } + if (mounts === null) { + return { + ...base, + status: 'pass', + detail: 'Sencho is not running in a container; host and container paths are the same.', + }; + } + const target = normPath(dir); + // The bind mount covering the compose dir is the one whose destination is the + // longest path prefix of it, so a parent bind (-v /opt:/opt) covers + // COMPOSE_DIR=/opt/compose just as a direct -v /opt/compose:/opt/compose does. + const match = mounts + .filter(m => { + const d = normPath(m.destination); + return target === d || target.startsWith(d + '/') || target.startsWith(d + '\\'); + }) + .sort((a, b) => normPath(b.destination).length - normPath(a.destination).length)[0]; + if (!match) { + return { + ...base, + status: 'warn', + detail: `${dir} is not under a host bind mount.`, + remediation: + `Bind-mount the compose directory from the host, e.g. -v ${dir}:${dir}. Without it, relative ` + + `bind mounts in your stacks resolve against the container filesystem instead of the host.`, + }; + } + // The host path the daemon resolves for the compose dir: the mount source + // plus the compose dir's path below the mount destination. + const relative = target.slice(normPath(match.destination).length); + const hostPath = normPath(normPath(match.source) + relative); + if (hostPath !== target) { + return { + ...base, + status: 'warn', + detail: `Host path ${hostPath} is mounted at ${dir}.`, + remediation: + `Mount the compose directory at the same path on host and container, e.g. -v ${dir}:${dir}. ` + + `A mismatch breaks relative bind mounts in your stacks, because the daemon resolves them ` + + `against the host path Sencho never sees.`, + }; + } + return { ...base, status: 'pass', detail: `Mounted 1:1 at ${dir}.` }; +} + +function checkTls(proto: string, host: string): EnvironmentCheck { + const base = { id: 'tls' as const, label: 'TLS' }; + if (proto === 'https' || isLoopbackHost(host)) { + return { ...base, status: 'pass', detail: proto === 'https' ? 'Reached over HTTPS.' : 'Reached over a loopback address.' }; + } + return { + ...base, + status: 'warn', + detail: `Reached over plain HTTP at ${host}.`, + remediation: + 'Put Sencho behind a reverse proxy that terminates TLS (Caddy, Traefik, nginx) before exposing ' + + 'it beyond localhost. Credentials and session cookies travel in clear text over plain HTTP.', + }; +} + +function checkDisk(dir: string, usage: DiskUsage | null): EnvironmentCheck { + const base = { id: 'disk_space' as const, label: 'Disk space' }; + if (!usage) { + // Unknown is not healthy: on a preflight surface a green check would + // tell the operator there is headroom that was never measured. + return { + ...base, + status: 'warn', + detail: 'Disk usage could not be determined.', + remediation: + 'Sencho could not read host filesystem stats. An unknown disk state is not a guarantee of ' + + 'free space, so confirm the compose volume has headroom before deploying.', + }; + } + const freeGiB = (usage.freeBytes / (1024 * 1024 * 1024)).toFixed(1); + if (usage.usePercent >= DISK_WARN_USE_PERCENT || usage.freeBytes < DISK_WARN_FREE_BYTES) { + return { + ...base, + status: 'warn', + detail: `${usage.usePercent.toFixed(0)}% used, ${freeGiB} GiB free on the compose volume.`, + remediation: + 'Free up disk space before deploying. Pull and build steps fail partway through on a full ' + + 'volume; prune unused images and volumes from Settings or the host.', + }; + } + return { ...base, status: 'pass', detail: `${usage.usePercent.toFixed(0)}% used, ${freeGiB} GiB free.` }; +} + +// Each probe is awaited inside its check builder's own try/catch (or returns a +// degraded row), so one failing probe never rejects the whole report. +export async function collectEnvironmentReport(probes: EnvironmentProbes): Promise { + const logProbeFailure = (label: string) => (e: unknown) => { + console.warn(`[env-check] ${label} probe failed: ${(e as Error)?.message ?? String(e)}`); + }; + const [socket, compose, access, mounts, disk] = await Promise.all([ + checkDockerSocket(probes.pingDocker), + checkDockerCompose(probes.composeVersion), + probes.accessDir(probes.composeDir).then( + a => a, + (e): DirAccess => { logProbeFailure('accessDir')(e); return { exists: false, isDir: false, writable: false }; }, + ), + probes.bindMounts().then( + (m): BindMounts => m, + (e): BindMounts => { logProbeFailure('bindMounts')(e); return 'unknown'; }, + ), + probes.diskUsage(probes.composeDir).then(d => d, (e) => { logProbeFailure('diskUsage')(e); return null; }), + ]); + + const checks: EnvironmentCheck[] = [ + socket, + compose, + checkComposeDir(probes.composeDir, access), + checkPathMapping(probes.composeDir, mounts), + checkTls(probes.proto, probes.host), + checkDisk(probes.composeDir, disk), + ]; + + return { checks, generatedAt: Date.now() }; +} + +async function realAccessDir(dir: string): Promise { + try { + const stat = await fs.stat(dir); + if (!stat.isDirectory()) return { exists: true, isDir: false, writable: false }; + try { + await fs.access(dir, fsConstants.W_OK); + return { exists: true, isDir: true, writable: true }; + } catch { + return { exists: true, isDir: true, writable: false }; + } + } catch { + return { exists: false, isDir: false, writable: false }; + } +} + +/** + * Choose the filesystem backing `dir` from a `systeminformation` fsSize list: + * the mount that is the longest path prefix of `dir` (so a dedicated compose + * volume wins over the root mount), then `/` or `C:`, then the first entry. + * Exported for unit testing the selection without a live filesystem. + */ +export function pickBackingMount( + sizes: Array<{ mount: string; use?: number; available?: number }>, + dir: string, +): DiskUsage | null { + if (sizes.length === 0) return null; + const target = normPath(dir); + const backing = sizes + .filter(s => { + const m = normPath(s.mount); + return target === m || target.startsWith(m + '/') || target.startsWith(m + '\\'); + }) + .sort((a, b) => b.mount.length - a.mount.length)[0] + ?? sizes.find(s => s.mount === '/' || s.mount === 'C:') + ?? sizes[0]; + if (!backing) return null; + return { usePercent: backing.use ?? 0, freeBytes: backing.available ?? 0 }; +} + +async function realDiskUsage(dir: string): Promise { + return pickBackingMount(await si.fsSize(), dir); +} + +/** + * Wire the real IO for the checks. Called by the diagnostics route; tests build + * `EnvironmentProbes` directly with stubs. + */ +export function buildRealProbes(opts: { proto: string; host: string }): EnvironmentProbes { + const composeDir = NodeRegistry.getInstance().getComposeDir(NodeRegistry.getInstance().getDefaultNodeId()); + return { + proto: opts.proto, + host: opts.host, + composeDir, + pingDocker: async () => { + await withTimeout(DockerController.getInstance().getDocker().ping(), DOCKER_PING_TIMEOUT_MS, 'docker-ping'); + }, + composeVersion: async () => { + const { stdout } = await execFileAsync('docker', ['compose', 'version', '--short'], { timeout: COMPOSE_VERSION_TIMEOUT_MS }); + return stdout; + }, + accessDir: realAccessDir, + bindMounts: () => SelfIdentityService.getInstance().getBindMounts(), + diskUsage: realDiskUsage, + }; +} diff --git a/backend/src/services/SelfIdentityService.ts b/backend/src/services/SelfIdentityService.ts index db04bb62..e134bb01 100644 --- a/backend/src/services/SelfIdentityService.ts +++ b/backend/src/services/SelfIdentityService.ts @@ -154,6 +154,32 @@ class SelfIdentityService { return this.volumeNames.has(name); } + /** + * Bind mounts on the running Sencho container, used by the environment + * checker to verify the compose directory is mounted at the same path on the + * host and inside the container. Returns null when Sencho is not running in + * Docker (dev / bare metal), where the 1:1 path-mapping concern does not + * apply. Throws when Sencho IS containerized (a container id was resolved at + * startup) but its own mounts cannot be read now, so the caller can report an + * unverified state instead of a false "not containerized". Re-inspects on + * each call rather than caching, because it runs only on an admin-triggered + * diagnostic. + */ + async getBindMounts(): Promise | null> { + const docker = DockerController.getInstance().getDocker(); + const info = await this.resolveSelfInspect(docker); + if (!info) { + if (this.containerId) { + throw new Error('container self-inspect unavailable; cannot read mounts'); + } + return null; + } + const mounts = (info.Mounts ?? []) as Array<{ Type?: string; Source?: string; Destination?: string }>; + return mounts + .filter(m => m.Type === 'bind' && m.Source && m.Destination) + .map(m => ({ source: m.Source as string, destination: m.Destination as string })); + } + /** Diagnostic snapshot used by route handlers when composing error responses. */ getIdentity(): { containerId: string | null; diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index 9757663b..39247c28 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -83,6 +83,8 @@ Open `http://localhost:1852` in a browser. On a fresh install you land on the ** Pick a username (the placeholder shows `admin`), choose a password, confirm it, and click **Initialize console**. The strength indicator under the password field expects at least eight characters. +Sencho then runs a short **environment preflight**: it confirms the Docker engine and Compose plugin are reachable, the compose directory is writable and mounted at a matching host path, the dashboard is behind TLS, and there is disk headroom. Anything that needs attention shows an inline fix. The checks never block you, so click **Enter Sencho** to continue; you can re-run them anytime from **Settings · Recovery**. + The Cold start card only appears the first time you open Sencho. Once the admin account exists, every subsequent visit goes to the regular sign-in screen. diff --git a/docs/operations/recovery.mdx b/docs/operations/recovery.mdx index 688cff11..30b1e836 100644 --- a/docs/operations/recovery.mdx +++ b/docs/operations/recovery.mdx @@ -18,6 +18,20 @@ Your compose files on disk are always the source of truth. Sencho reads them; it --- +## Environment checks + +Sencho runs a short environment preflight as the final step of first-boot setup, and you can re-run it anytime from **Settings · Recovery**. It confirms the things a deploy depends on, so a deploy does not fail for an avoidable reason: + +- the Docker engine is reachable and Sencho has permission to talk to the socket, +- the Docker Compose plugin is present, +- the compose directory exists, is writable, and is mounted at a matching host path (the [1:1 path rule](/getting-started/configuration#compose-directory-the-11-path-rule)), +- the dashboard is reached over TLS rather than plain HTTP, +- and the compose volume has disk headroom. + +Every result that needs attention carries a specific fix rather than a generic error. The checks are advisory: they never stop you from continuing, so an operator who knows their setup can move straight on. + +--- + ## Sencho itself won't start or is misbehaving **Symptom:** The dashboard won't load, the container restarts in a loop, or the UI is throwing errors with no useful message. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index d5d3cebe..e874e545 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -69,7 +69,11 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T const confirmInput = page.locator('#confirmPassword'); if (await confirmInput.isVisible()) await confirmInput.fill(password); await page.locator('button[type="submit"]').click(); - // After setup, the app logs in automatically and shows the dashboard + // Setup signs the new admin in and then shows an environment-preflight + // step; clicking "Enter Sencho" completes onboarding and lands the dashboard. + const enterButton = page.getByRole('button', { name: /enter sencho/i }); + await expect(enterButton).toBeVisible({ timeout: 10_000 }); + await enterButton.click(); await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 }); return; } diff --git a/frontend/src/components/Setup.tsx b/frontend/src/components/Setup.tsx index 324991b5..6d877ff2 100644 --- a/frontend/src/components/Setup.tsx +++ b/frontend/src/components/Setup.tsx @@ -6,6 +6,7 @@ import { ArrowRight, Loader2 } from 'lucide-react'; import { AuthCanvas } from '@/components/auth/AuthCanvas'; import { AuthStepHeader } from '@/components/auth/AuthStepHeader'; import { ErrorRail } from '@/components/auth/ErrorRail'; +import { EnvironmentChecks } from '@/components/settings/EnvironmentChecks'; interface SetupProps { onComplete: () => void; @@ -34,6 +35,10 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); + // The admin account is created in step 1; /api/auth/setup signs the operator + // in (session cookie), so step 2 can run the admin-gated environment checks + // before handing off to the console. + const [step, setStep] = useState<'account' | 'env'>('account'); const strength = gaugePassword(password); const strengthClass = @@ -72,7 +77,7 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co }); const data = await response.json(); if (response.ok && data.success) { - onComplete(); + setStep('env'); } else { setError(data.error || 'Setup failed'); } @@ -83,6 +88,37 @@ export function Setup({ onComplete, className, ...props }: SetupProps & React.Co } }; + if (step === 'env') { + return ( +
+ + Console · First boot + Account ready +
+ } + > +
+ + + +
+ + + ); + } + return (
= { pass: 'OK', warn: 'Warning', fail: 'Action needed' }; + +function StatusBadge({ status, children }: { status: CheckStatus; children: ReactNode }) { + const Icon = status === 'pass' ? Check : status === 'warn' ? AlertTriangle : X; + return ( + + + {children} + + ); +} + +function CheckRow({ check }: { check: EnvironmentCheck }) { + return ( +
+
+ {check.label} + {STATUS_WORD[check.status]} +
+

{check.detail}

+ {check.remediation ? ( +

{check.remediation}

+ ) : null} +
+ ); +} + +function ChecksSkeleton() { + return ( +
+ {[0, 1, 2, 3, 4, 5].map(i => )} +
+ ); +} + +/** + * Preflight environment checks (Docker engine + Compose, the compose directory + * and its host path mapping, TLS, disk headroom) with inline remediation. + * Layout-neutral so it renders both inside the Recovery settings tab and as the + * final step of the setup wizard. Self-contained: fetches on mount and exposes + * a Re-run control. It never blocks; the caller decides what continue action, + * if any, sits alongside it. + */ +export function EnvironmentChecks({ className }: { className?: string }) { + const [report, setReport] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const res = await apiFetch('/diagnostics/environment', { localOnly: true }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || 'Failed to run environment checks.'); + setReport(null); + return; + } + setReport(await res.json() as EnvironmentReport); + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Failed to run environment checks.'); + setReport(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void load(); + }, [load]); + + return ( +
+ {isLoading ? ( + + ) : report ? ( +
+ {report.checks.map(check => )} +
+ ) : ( +

Checks could not be run. Try again.

+ )} + + void load()} disabled={isLoading}> + + Re-run + + +
+ ); +} diff --git a/frontend/src/components/settings/RecoverySection.tsx b/frontend/src/components/settings/RecoverySection.tsx index 5c0d5618..8893ee66 100644 --- a/frontend/src/components/settings/RecoverySection.tsx +++ b/frontend/src/components/settings/RecoverySection.tsx @@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions'; +import { EnvironmentChecks } from './EnvironmentChecks'; import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled'; import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled'; @@ -220,6 +221,16 @@ export function RecoverySection() { + +
+ +
+
+