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([]);