mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
fix: enforce 1:1 compose path mapping for Pilot agent mounts (#1516)
* fix: enforce 1:1 compose path mapping for Pilot agent mounts Pilot enrollment now generates validated 1:1 bind mounts so every agent path maps to a unique compose directory. Persisted agent paths reconcile during startup to catch drift. Unsafe relative-bind redeploys are blocked before container removal to prevent path escapes. - Add composePathMapping utility with strict path validation - Generate COMPOSE_DIR and validated mounts during Pilot enrollment - Reconcile persisted agent paths during startup bootstrap - Block redeploy when a relative-bind mount would escape the compose root - Default Pilot UI path to /opt/docker/sencho - Update multi-node and pilot-agent documentation - Add regression tests for enrollment, bootstrap, compose-service, and environment-check paths * fix: update E2E enrollment regexes for YAML-quoted token values
This commit is contained in:
@@ -10,7 +10,8 @@ import { MeshService } from './MeshService';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { parseEffectiveModel } from './preflight/effectiveModel';
|
||||
import { deriveStackExposure } from './preflight/exposure';
|
||||
|
||||
@@ -21,7 +22,8 @@ import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
public readonly rollbackAttempted: boolean;
|
||||
@@ -395,7 +397,7 @@ export class ComposeService {
|
||||
* no env value is materialized. Default off and any settings-read failure both
|
||||
* fall through without blocking.
|
||||
*/
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
|
||||
@@ -411,10 +413,49 @@ export class ComposeService {
|
||||
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
|
||||
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
}
|
||||
|
||||
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
|
||||
if (process.env.SENCHO_MODE !== 'pilot') return;
|
||||
|
||||
let mounts: Array<{ source: string; destination: string }> | null;
|
||||
try {
|
||||
mounts = await SelfIdentityService.getInstance().getBindMounts();
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
if (mounts === null) return;
|
||||
|
||||
const composeDir = path.resolve(this.baseDir);
|
||||
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
|
||||
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
|
||||
|
||||
const rendered = await this.renderConfig(stackName);
|
||||
if (rendered.rendered === null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rendered.rendered);
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
const model = parseEffectiveModel(parsed, stackName);
|
||||
const unsafeBind = model.services
|
||||
.flatMap((service) => service.binds)
|
||||
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
|
||||
if (!unsafeBind) return;
|
||||
|
||||
throw new Error(
|
||||
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
|
||||
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -606,8 +647,9 @@ export class ComposeService {
|
||||
startStream();
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
|
||||
@@ -26,6 +26,7 @@ import DockerController from './DockerController';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -222,17 +223,8 @@ function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck {
|
||||
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) {
|
||||
const hostPath = resolveHostBindPath(dir, mounts);
|
||||
if (!hostPath) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
@@ -242,11 +234,7 @@ function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck {
|
||||
+ `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) {
|
||||
if (!pathsMatch(hostPath, dir)) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warn',
|
||||
|
||||
Reference in New Issue
Block a user