mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +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:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user