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:
Anso
2026-06-29 14:35:50 -04:00
committed by GitHub
parent 9ff678a7bb
commit 41dc339c26
14 changed files with 481 additions and 53 deletions
+90 -1
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import os from 'os';
import path from 'path';
import type WebSocket from 'ws';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -17,7 +18,7 @@ const {
mockBackupStackFiles, mockRestoreStackFiles,
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
mockGetGlobalSettings, mockPruneDanglingImages,
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
@@ -38,6 +39,7 @@ const {
mockRmdirSync: vi.fn(),
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
mockGetBindMounts: vi.fn().mockResolvedValue(null),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
@@ -125,11 +127,18 @@ vi.mock('../services/MeshService', () => ({
},
}));
vi.mock('../services/SelfIdentityService', () => ({
default: {
getInstance: () => ({ getBindMounts: mockGetBindMounts }),
},
}));
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import { DriftLedgerService } from '../services/DriftLedgerService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
const originalSenchoMode = process.env.SENCHO_MODE;
/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
@@ -196,6 +205,8 @@ beforeEach(() => {
mockGetComposeFilename.mockResolvedValue('compose.yaml');
mockGetOverrideFilename.mockResolvedValue(null);
mockEnsureStackOverride.mockResolvedValue(null);
mockGetBindMounts.mockResolvedValue(null);
delete process.env.SENCHO_MODE;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
@@ -211,6 +222,11 @@ afterEach(() => {
} else {
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = originalStallTimeout;
}
if (originalSenchoMode === undefined) {
delete process.env.SENCHO_MODE;
} else {
process.env.SENCHO_MODE = originalSenchoMode;
}
});
// ── runCommand ─────────────────────────────────────────────────────────
@@ -530,6 +546,79 @@ describe('ComposeService - authoredComposeArgs mesh override', () => {
// ── deployStack ────────────────────────────────────────────────────────
describe('ComposeService - deployStack', () => {
it('blocks a pilot deploy with relative binds before replacing containers when path mapping differs', async () => {
process.env.SENCHO_MODE = 'pilot';
const composeDir = path.resolve('/test/compose');
mockGetBindMounts.mockResolvedValue([
{ source: '/opt/docker/sencho', destination: composeDir },
]);
const configProc = createMockProcess();
mockSpawn.mockReturnValue(configProc);
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack').then(() => null, (error: Error) => error);
await waitForSpawn();
configProc.stdout.emit('data', Buffer.from(JSON.stringify({
name: 'my-stack',
services: {
db: {
image: 'postgres:17',
volumes: [{
type: 'bind',
source: path.join(composeDir, 'my-stack', 'data'),
target: '/var/lib/postgresql/data',
}],
},
},
})));
configProc.emit('close', 0);
const error = await result;
expect(error?.message).toContain('1:1');
expect(mockGetContainersByStack).not.toHaveBeenCalled();
expect(mockRemoveContainers).not.toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalledTimes(1);
});
it('allows a pilot deploy that uses only absolute external bind sources', async () => {
process.env.SENCHO_MODE = 'pilot';
const composeDir = path.resolve('/test/compose');
mockGetBindMounts.mockResolvedValue([
{ source: '/opt/docker/sencho', destination: composeDir },
]);
let spawnCount = 0;
mockSpawn.mockImplementation(() => {
spawnCount += 1;
const proc = createMockProcess();
Promise.resolve().then(() => {
if (spawnCount === 1) {
proc.stdout.emit('data', Buffer.from(JSON.stringify({
name: 'my-stack',
services: {
db: {
image: 'postgres:17',
volumes: [{
type: 'bind',
source: '/srv/postgres/data',
target: '/var/lib/postgresql/data',
}],
},
},
})));
}
proc.emit('close', 0);
});
return proc;
});
mockListContainers.mockResolvedValue([]);
const promise = ComposeService.getInstance(1).deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await expect(promise).resolves.toBeUndefined();
expect(mockGetContainersByStack).toHaveBeenCalledWith('my-stack');
});
it('runs docker compose up -d --remove-orphans', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
@@ -185,6 +185,14 @@ describe('collectEnvironmentReport', () => {
expect(byId(checks, 'path_mapping').status).toBe('pass');
});
it('passes a 1:1 root bind that covers the compose dir', async () => {
const { checks } = await collectEnvironmentReport(baseProbes({
composeDir: '/opt/compose',
bindMounts: async () => [{ source: '/', destination: '/' }],
}));
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',
@@ -17,11 +17,12 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ensurePilotJwtSecret: typeof import('../bootstrap/startup').ensurePilotJwtSecret;
let reconcilePilotComposeDir: typeof import('../bootstrap/startup').reconcilePilotComposeDir;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ensurePilotJwtSecret } = await import('../bootstrap/startup'));
({ ensurePilotJwtSecret, reconcilePilotComposeDir } = await import('../bootstrap/startup'));
});
afterAll(() => {
@@ -30,10 +31,38 @@ afterAll(() => {
beforeEach(() => {
delete process.env.SENCHO_MODE;
delete process.env.COMPOSE_DIR;
});
afterEach(() => {
delete process.env.SENCHO_MODE;
delete process.env.COMPOSE_DIR;
});
describe('pilot-agent compose directory bootstrap', () => {
it('reconciles the persisted local node with COMPOSE_DIR in pilot mode', () => {
const db = DatabaseService.getInstance();
const node = db.getDefaultNode();
expect(node).toBeDefined();
db.updateNode(node!.id, { compose_dir: '/app/compose' });
process.env.SENCHO_MODE = 'pilot';
process.env.COMPOSE_DIR = '/opt/docker/sencho';
expect(reconcilePilotComposeDir()).toBe(true);
expect(db.getDefaultNode()?.compose_dir).toBe('/opt/docker/sencho');
});
it('does not change the local node outside pilot mode', () => {
const db = DatabaseService.getInstance();
const node = db.getDefaultNode();
expect(node).toBeDefined();
db.updateNode(node!.id, { compose_dir: '/app/compose' });
process.env.COMPOSE_DIR = '/opt/docker/sencho';
expect(reconcilePilotComposeDir()).toBe(false);
expect(db.getDefaultNode()?.compose_dir).toBe('/app/compose');
});
});
describe('pilot-agent bootstrap auth_jwt_secret', () => {
+81 -5
View File
@@ -23,7 +23,11 @@ interface ComposeService {
image: string;
container_name: string;
restart: string;
volumes: string[];
volumes: Array<string | {
type: string;
source: string;
target: string;
}>;
environment: Record<string, string>;
}
@@ -60,7 +64,8 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(res.body.enrollment.token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(typeof res.body.enrollment.composeYaml).toBe('string');
expect(res.body.enrollment.composeYaml).toContain('SENCHO_MODE: pilot');
expect(res.body.enrollment.composeYaml).toContain(`SENCHO_ENROLL_TOKEN: ${res.body.enrollment.token}`);
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.SENCHO_ENROLL_TOKEN).toBe(res.body.enrollment.token);
expect(res.body.enrollment.expiresAt).toBeGreaterThan(Date.now());
expect(res.body.enrollment).not.toHaveProperty('dockerRun');
});
@@ -115,11 +120,52 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock');
expect(volumes).toContain('sencho-agent-data:/app/data');
expect(volumes).toContain('/opt/docker/sencho:/app/compose');
expect(volumes).toContainEqual({
type: 'bind',
source: '/opt/docker/sencho',
target: '/opt/docker/sencho',
});
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/opt/docker/sencho');
expect(parsed.volumes).toHaveProperty('sencho-agent-data');
});
it('composeYaml embeds the three pilot env vars with the enrollment token', async () => {
it('uses a custom absolute compose directory as a safe 1:1 mount', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({
name: 'pilot-custom-compose',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/srv/compose projects',
});
expect(res.status).toBe(200);
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.volumes).toContainEqual({
type: 'bind',
source: '/srv/compose projects',
target: '/srv/compose projects',
});
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/srv/compose projects');
});
it('rejects a relative pilot compose directory', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({
name: 'pilot-relative-compose',
type: 'remote',
mode: 'pilot_agent',
compose_dir: './compose',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('absolute');
});
it('composeYaml embeds the Pilot connection and compose environment', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
@@ -131,6 +177,7 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(env.SENCHO_MODE).toBe('pilot');
expect(env.SENCHO_PRIMARY_URL).toMatch(/^https?:\/\//);
expect(env.SENCHO_ENROLL_TOKEN).toBe(res.body.enrollment.token);
expect(env.COMPOSE_DIR).toBe('/opt/docker/sencho');
});
});
@@ -204,7 +251,12 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
const create = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-regen', type: 'remote', mode: 'pilot_agent' });
.send({
name: 'pilot-regen',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/srv/pilot-stacks',
});
const original = create.body.enrollment.token;
const regen = await request(app)
@@ -213,6 +265,13 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
expect(regen.status).toBe(200);
expect(regen.body.enrollment.token).not.toBe(original);
const parsed = parseYaml(regen.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/srv/pilot-stacks');
expect(parsed.services.agent.volumes).toContainEqual({
type: 'bind',
source: '/srv/pilot-stacks',
target: '/srv/pilot-stacks',
});
});
it('rejects regeneration for proxy-mode remote nodes', async () => {
@@ -242,6 +301,23 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
});
});
describe('PUT /api/nodes/:id Pilot compose directory', () => {
it('rejects a relative compose directory for an existing pilot node', async () => {
const create = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-update-path', type: 'remote', mode: 'pilot_agent' });
const update = await request(app)
.put(`/api/nodes/${create.body.id}`)
.set('Cookie', adminCookie)
.send({ compose_dir: '../stacks' });
expect(update.status).toBe(400);
expect(update.body.error).toContain('absolute');
});
});
describe('consumePilotEnrollment replay protection', () => {
it('marks the row used and rejects the second consume', () => {
const db = DatabaseService.getInstance();
+17
View File
@@ -54,6 +54,21 @@ export function ensurePilotJwtSecret(): boolean {
return true;
}
/** Keep the pilot's persisted local node aligned with its configured mount. */
export function reconcilePilotComposeDir(): boolean {
if (!isPilotMode()) return false;
const configuredDir = process.env.COMPOSE_DIR?.trim();
if (!configuredDir) return false;
const db = DatabaseService.getInstance();
const localNode = db.getDefaultNode();
if (!localNode || localNode.compose_dir === configuredDir) return false;
db.updateNode(localNode.id, { compose_dir: configuredDir });
console.log(`[Startup] pilot-agent: compose directory set to ${configuredDir}`);
return true;
}
function clearSelfContainerNotificationRouting(): void {
const identity = SelfIdentityService.getInstance().getIdentity();
const changed = DatabaseService.getInstance().clearSelfContainerNotificationRouting(
@@ -88,6 +103,8 @@ export async function startServer(server: Server): Promise<void> {
);
}
reconcilePilotComposeDir();
try {
console.log('Running stack migration check...');
const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId());
+38 -4
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import path from 'path';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
@@ -24,6 +25,20 @@ import { sanitizeForLog } from '../utils/safeLog';
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
const DEFAULT_COMPOSE_DIR = '/app/compose';
const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho';
function normalizePilotComposeDir(value: unknown): string | null {
if (value !== undefined && value !== null && typeof value !== 'string') return null;
const raw = typeof value === 'string' ? value.trim() : '';
const candidate = raw || DEFAULT_PILOT_COMPOSE_DIR;
if (!path.posix.isAbsolute(candidate) || /[\0\r\n]/.test(candidate)) return null;
return path.posix.normalize(candidate);
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
/**
* Pick the URL the pilot agent should dial. SENCHO_PUBLIC_URL wins when set
@@ -48,6 +63,10 @@ function resolvePrimaryUrl(req: Request): string {
function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; composeYaml: string } {
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) throw new Error('Node not found');
const composeDir = normalizePilotComposeDir(node.compose_dir);
if (!composeDir) throw new Error('Pilot compose directory must be an absolute path');
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret not configured');
@@ -77,11 +96,14 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp
` volumes:`,
` - /var/run/docker.sock:/var/run/docker.sock`,
` - sencho-agent-data:/app/data`,
` - /opt/docker/sencho:/app/compose`,
` - type: bind`,
` source: ${yamlString(composeDir)}`,
` target: ${yamlString(composeDir)}`,
` environment:`,
` SENCHO_MODE: pilot`,
` SENCHO_PRIMARY_URL: ${primaryUrl}`,
` SENCHO_ENROLL_TOKEN: ${token}`,
` SENCHO_PRIMARY_URL: ${yamlString(primaryUrl)}`,
` SENCHO_ENROLL_TOKEN: ${yamlString(token)}`,
` COMPOSE_DIR: ${yamlString(composeDir)}`,
``,
`volumes:`,
` sencho-agent-data:`,
@@ -170,6 +192,10 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) =>
}
const resolvedMode: 'proxy' | 'pilot_agent' = type === 'remote' && mode === 'pilot_agent' ? 'pilot_agent' : 'proxy';
const pilotComposeDir = resolvedMode === 'pilot_agent' ? normalizePilotComposeDir(compose_dir) : null;
if (resolvedMode === 'pilot_agent' && !pilotComposeDir) {
return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
}
if (type === 'remote' && resolvedMode === 'proxy') {
if (!api_url || typeof api_url !== 'string') {
@@ -184,7 +210,7 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) =>
const id = DatabaseService.getInstance().addNode({
name,
type,
compose_dir: compose_dir || '/app/compose',
compose_dir: pilotComposeDir ?? (compose_dir || DEFAULT_COMPOSE_DIR),
is_default: is_default || false,
api_url: resolvedMode === 'pilot_agent' ? '' : (api_url || ''),
api_token: resolvedMode === 'pilot_agent' ? '' : (api_token || ''),
@@ -274,6 +300,14 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'Node not found' });
}
if (existingNode.mode === 'pilot_agent' && updates.compose_dir !== undefined) {
const composeDir = normalizePilotComposeDir(updates.compose_dir);
if (!composeDir) {
return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
}
updates.compose_dir = composeDir;
}
if (updates.api_url !== undefined && updates.api_url !== '') {
const urlCheck = isValidRemoteUrl(updates.api_url);
if (!urlCheck.valid) {
+51 -9
View File
@@ -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',
+50
View File
@@ -0,0 +1,50 @@
import path from 'path';
export interface BindPathMapping {
source: string;
destination: string;
}
function normalizePath(value: string): string {
if (value === '/' || /^[A-Za-z]:[\\/]?$/.test(value)) return value;
return value.replace(/[\\/]+$/, '');
}
function isAtOrBelow(candidate: string, base: string): boolean {
const resolvedCandidate = path.resolve(candidate);
const resolvedBase = path.resolve(base);
// After resolution, verify the candidate still starts with the base followed
// by a separator (or is an exact match). This catches `..` segments that
// would pass a naive string-prefix check but escape the intended directory.
if (resolvedCandidate === resolvedBase) return true;
// Filesystem root: any resolved absolute path is at or below it.
if (resolvedBase === path.resolve('/')) return path.isAbsolute(resolvedCandidate);
if (/^[A-Za-z]:[\\/]?$/.test(resolvedBase)) {
return resolvedCandidate.toLowerCase().startsWith(resolvedBase.slice(0, 2).toLowerCase());
}
return resolvedCandidate.startsWith(resolvedBase + path.sep);
}
/** Resolve a container-visible path to the corresponding host bind path. */
export function resolveHostBindPath(
containerPath: string,
mounts: ReadonlyArray<BindPathMapping>,
): string | null {
const target = normalizePath(containerPath);
const match = mounts
.filter((mount) => isAtOrBelow(target, normalizePath(mount.destination)))
.sort((a, b) => normalizePath(b.destination).length - normalizePath(a.destination).length)[0];
if (!match) return null;
const destination = normalizePath(match.destination);
const source = normalizePath(match.source);
const suffix = target.slice(destination.length).replace(/^[\\/]+/, '');
if (!suffix) return source;
const separator = source.endsWith('/') || source.endsWith('\\') ? '' : '/';
return `${source}${separator}${suffix}`;
}
export function pathsMatch(left: string, right: string): boolean {
return normalizePath(left) === normalizePath(right);
}