mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569)
* 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
This commit is contained in:
@@ -22,6 +22,7 @@ import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { isSelfStack } from '../helpers/selfStackGuard';
|
||||
import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification';
|
||||
|
||||
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
|
||||
@@ -266,6 +267,7 @@ export class ComposeDoctorService {
|
||||
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
|
||||
const selfStack = await isSelfStack(stackName);
|
||||
|
||||
return {
|
||||
stackName,
|
||||
@@ -288,6 +290,7 @@ export class ComposeDoctorService {
|
||||
serviceIntents,
|
||||
accessUrlPorts,
|
||||
hasAccessUrls,
|
||||
isSelfStack: selfStack,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ export interface BulkStackInfo {
|
||||
running?: number;
|
||||
/** Total container count for the stack; paired with `running` for the sidebar tooltip. */
|
||||
total?: number;
|
||||
/** True when this stack is the running Sencho instance (compose project matches stack name). */
|
||||
isSelf?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassifiedImage {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -20,11 +21,13 @@
|
||||
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';
|
||||
|
||||
@@ -41,6 +44,7 @@ export type CheckId =
|
||||
| 'docker_socket'
|
||||
| 'docker_compose'
|
||||
| 'compose_dir'
|
||||
| 'self_stack_location'
|
||||
| 'path_mapping'
|
||||
| 'tls'
|
||||
| 'disk_space';
|
||||
@@ -74,6 +78,8 @@ export interface DiskUsage {
|
||||
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
|
||||
@@ -94,6 +100,8 @@ export interface EnvironmentProbes {
|
||||
* 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>;
|
||||
}
|
||||
@@ -199,6 +207,58 @@ function checkComposeDir(dir: string, access: DirAccess): EnvironmentCheck {
|
||||
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).
|
||||
@@ -297,7 +357,7 @@ export async function collectEnvironmentReport(probes: EnvironmentProbes): Promi
|
||||
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([
|
||||
const [socket, compose, access, mounts, selfStackDirectory, disk] = await Promise.all([
|
||||
checkDockerSocket(probes.pingDocker),
|
||||
checkDockerCompose(probes.composeVersion),
|
||||
probes.accessDir(probes.composeDir).then(
|
||||
@@ -308,13 +368,16 @@ export async function collectEnvironmentReport(probes: EnvironmentProbes): Promi
|
||||
(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),
|
||||
@@ -385,6 +448,7 @@ export function buildRealProbes(opts: { proto: string; host: string }): Environm
|
||||
},
|
||||
accessDir: realAccessDir,
|
||||
bindMounts: () => SelfIdentityService.getInstance().getBindMounts(),
|
||||
selfStackDirectoryName: () => getSelfStackDirectoryName(composeDir),
|
||||
diskUsage: realDiskUsage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,6 +608,20 @@ const effectiveModelExpanded: PreflightRule = {
|
||||
},
|
||||
};
|
||||
|
||||
const selfManagedStack: PreflightRule = {
|
||||
id: 'self-managed-stack',
|
||||
run(ctx) {
|
||||
if (!ctx.isSelfStack) return [];
|
||||
return [{
|
||||
ruleId: 'self-managed-stack',
|
||||
severity: 'warning',
|
||||
title: 'This stack is the running Sencho instance',
|
||||
message: 'Sencho discovered its own compose project as a managed stack. Generic deploy, update, stop, down, and delete actions are blocked here because they would recreate or remove the dashboard you are using.',
|
||||
remediation: 'Update Sencho via Fleet -> Node Update. To manage it as a normal stack, move its compose project outside COMPOSE_DIR.',
|
||||
}];
|
||||
},
|
||||
};
|
||||
|
||||
// ----- exposure-intent rules ------------------------------------------------
|
||||
// These read the user's stored exposure classification (resolved per service)
|
||||
// and the dossier's documented access URLs from the context, plus a sensitivity
|
||||
@@ -785,6 +799,7 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
exposurePortVsDossier,
|
||||
reverseProxyUndocumented,
|
||||
effectiveModelExpanded,
|
||||
selfManagedStack,
|
||||
];
|
||||
|
||||
export const RULE_IDS: readonly string[] = PREFLIGHT_RULES.map(r => r.id);
|
||||
|
||||
@@ -125,4 +125,6 @@ export interface PreflightContext {
|
||||
accessUrlPorts: Set<number>;
|
||||
/** Whether the dossier records any access URL (gates the port-vs-documented rule). */
|
||||
hasAccessUrls: boolean;
|
||||
/** True when this stack is the running Sencho instance on the node. */
|
||||
isSelfStack: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user