mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
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.
This commit is contained in:
@@ -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<void>;
|
||||
/** Resolves the `docker compose` version string; rejects when the plugin is absent. */
|
||||
composeVersion: () => Promise<string>;
|
||||
accessDir: (dir: string) => Promise<DirAccess>;
|
||||
/**
|
||||
* 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<Array<{ source: string; destination: string }> | null>;
|
||||
/** Disk usage of the filesystem backing the compose dir, or null when unknown. */
|
||||
diskUsage: (dir: string) => Promise<DiskUsage | null>;
|
||||
}
|
||||
|
||||
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<EnvironmentCheck> {
|
||||
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<EnvironmentCheck> {
|
||||
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<EnvironmentReport> {
|
||||
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<DirAccess> {
|
||||
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<DiskUsage | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user