mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
0f9925e04f
* feat: block self-stack lifecycle ops with UI and preflight guardrails Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project. Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions. Add SelfStackProtectedDialog and self-managed-stack preflight warning. Closes #1564 * fix: add missing stackSelfFlags mock to useSidebarContextMenu test The production hook now reads stackListState.stackSelfFlags[file], but the test mock did not include it, causing 6 tests to fail with TypeError: Cannot read properties of undefined (reading 'web.yml'). * fix: harden self-stack protection during startup Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR. Align status decoration and route guards on Docker label fallback detection. Block rollback and service-level stop on the protected self stack. * fix: add self_stack_location to diagnostics-route expected check IDs
455 lines
19 KiB
TypeScript
455 lines
19 KiB
TypeScript
/**
|
|
* 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,
|
|
* is Sencho's own compose project outside that managed directory, is the path
|
|
* 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 path from 'path';
|
|
import { promisify } from 'util';
|
|
import si from 'systeminformation';
|
|
import DockerController from './DockerController';
|
|
import { NodeRegistry } from './NodeRegistry';
|
|
import SelfIdentityService from './SelfIdentityService';
|
|
import { getSelfStackDirectoryName } from '../helpers/selfStackGuard';
|
|
import { withTimeout } from '../utils/withTimeout';
|
|
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
|
|
|
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'
|
|
| 'self_stack_location'
|
|
| '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;
|
|
}
|
|
|
|
type SelfStackDirectory = string | null | 'unknown';
|
|
|
|
/**
|
|
* 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>;
|
|
/** Directory name of the running Sencho compose project under COMPOSE_DIR. */
|
|
selfStackDirectoryName: () => Promise<SelfStackDirectory>;
|
|
/** 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.` };
|
|
}
|
|
|
|
async function checkSelfStackLocation(
|
|
composeDir: string,
|
|
directoryName: SelfStackDirectory,
|
|
accessDir: EnvironmentProbes['accessDir'],
|
|
): Promise<EnvironmentCheck> {
|
|
const base = { id: 'self_stack_location' as const, label: 'Sencho compose location' };
|
|
if (directoryName === 'unknown') {
|
|
return {
|
|
...base,
|
|
status: 'warn',
|
|
detail: 'Could not verify whether Sencho is managed inside COMPOSE_DIR.',
|
|
remediation:
|
|
'Confirm Sencho\'s own compose project is outside COMPOSE_DIR. Use Fleet -> Node Update for Sencho updates.',
|
|
};
|
|
}
|
|
if (!directoryName) {
|
|
return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' };
|
|
}
|
|
if (directoryName.includes('/') || directoryName.includes('\\') || directoryName === '.' || directoryName === '..') {
|
|
return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' };
|
|
}
|
|
const resolvedComposeDir = path.resolve(composeDir);
|
|
const stackDir = path.resolve(resolvedComposeDir, directoryName);
|
|
const insideComposeDir = stackDir === resolvedComposeDir || stackDir.startsWith(resolvedComposeDir + path.sep);
|
|
if (!insideComposeDir) {
|
|
return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' };
|
|
}
|
|
|
|
try {
|
|
const access = await accessDir(stackDir);
|
|
if (access.exists && access.isDir) {
|
|
return {
|
|
...base,
|
|
status: 'warn',
|
|
detail: `Sencho's own compose project appears at ${stackDir}, inside COMPOSE_DIR.`,
|
|
remediation:
|
|
'Move Sencho\'s compose project outside COMPOSE_DIR and use Fleet -> Node Update for Sencho updates.',
|
|
};
|
|
}
|
|
} catch {
|
|
return {
|
|
...base,
|
|
status: 'warn',
|
|
detail: 'Could not verify whether Sencho is managed inside COMPOSE_DIR.',
|
|
remediation:
|
|
'Confirm Sencho\'s own compose project is outside COMPOSE_DIR. Use Fleet -> Node Update for Sencho updates.',
|
|
};
|
|
}
|
|
|
|
return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' };
|
|
}
|
|
|
|
// 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 hostPath = resolveHostBindPath(dir, mounts);
|
|
if (!hostPath) {
|
|
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.`,
|
|
};
|
|
}
|
|
if (!pathsMatch(hostPath, dir)) {
|
|
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, selfStackDirectory, 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.selfStackDirectoryName().then(directory => directory, (e): SelfStackDirectory => { logProbeFailure('selfStackDirectoryName')(e); return 'unknown'; }),
|
|
probes.diskUsage(probes.composeDir).then(d => d, (e) => { logProbeFailure('diskUsage')(e); return null; }),
|
|
]);
|
|
const selfStackLocation = await checkSelfStackLocation(probes.composeDir, selfStackDirectory, probes.accessDir);
|
|
|
|
const checks: EnvironmentCheck[] = [
|
|
socket,
|
|
compose,
|
|
checkComposeDir(probes.composeDir, access),
|
|
selfStackLocation,
|
|
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(),
|
|
selfStackDirectoryName: () => getSelfStackDirectoryName(composeDir),
|
|
diskUsage: realDiskUsage,
|
|
};
|
|
}
|