mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ On the control instance, click your avatar in the top-right and choose **Setting
|
||||
| **Name** | A display name (e.g. `staging-vps`, `media-box`). Visible in the switcher and across the UI. |
|
||||
| **Type** | Set to **Remote**. |
|
||||
| **Mode** | Set to **Pilot Agent**. |
|
||||
| **Compose Directory** | The root directory where compose stack folders live on the remote host. Defaults to `/app/compose`; change it if the remote uses a different volume mount. |
|
||||
| **Compose Directory** | The absolute directory where compose stack folders live on the remote host. Pilot Agent defaults to `/opt/docker/sencho` and mounts the directory at the same path inside the agent so relative stack bind mounts remain host-correct. |
|
||||
|
||||
Click **Add node**.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ You do not have to pick one mode for the whole fleet. Mix and match per node.
|
||||
|
||||
Conceptually, the agent reverses the usual client/server direction.
|
||||
|
||||
1. The agent container starts on the remote host with three environment variables: a mode flag, the control instance URL, and a short-lived enrollment token.
|
||||
1. The agent container starts on the remote host with its mode, control instance URL, short-lived enrollment token, and compose directory configured.
|
||||
2. It dials `wss://<control-instance>/api/pilot/tunnel` and holds the WebSocket open for as long as the container runs.
|
||||
3. For every request the control instance needs to make against that node (listing containers, deploying a stack, streaming logs, opening a console, forwarding mesh traffic), frames are multiplexed over that single connection.
|
||||
4. On the agent side, those frames are demultiplexed and replayed locally against the host's Docker socket and filesystem.
|
||||
@@ -78,7 +78,7 @@ Pilot mode narrows the operator surface in a couple of places where parity with
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and whatever you point `/app/compose` at. |
|
||||
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and the selected compose directory. |
|
||||
| **Network from remote → control instance** | Outbound TCP to the control instance's HTTPS port (whatever your reverse proxy or Sencho exposes). Nothing inbound. |
|
||||
| **Network at the control instance** | The path from the remote to the control instance must allow WebSocket upgrades end-to-end. If you terminate TLS at a reverse proxy in front of Sencho, that proxy needs `Upgrade: websocket` passthrough. |
|
||||
| **Operator role** | Admin on the control instance. The Settings → Nodes panel is admin-only. |
|
||||
@@ -114,7 +114,7 @@ When you submit the Add Node form with Mode set to Pilot Agent, the control inst
|
||||
|
||||
The enrollment token is one-time and short-lived by design. If a token is intercepted or leaked, the window of risk is small and the slot is consumed on first use.
|
||||
|
||||
The generated compose file looks like this, with the three pilot env vars already baked in:
|
||||
The generated compose file looks like this, with the Pilot connection and compose path already configured:
|
||||
|
||||
```yaml
|
||||
name: sencho-agent
|
||||
@@ -126,11 +126,14 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- sencho-agent-data:/app/data
|
||||
- /opt/docker/sencho:/app/compose
|
||||
- type: bind
|
||||
source: /opt/docker/sencho
|
||||
target: /opt/docker/sencho
|
||||
environment:
|
||||
SENCHO_MODE: pilot
|
||||
SENCHO_PRIMARY_URL: https://sencho.example.com
|
||||
SENCHO_ENROLL_TOKEN: <short-lived token>
|
||||
COMPOSE_DIR: /opt/docker/sencho
|
||||
|
||||
volumes:
|
||||
sencho-agent-data:
|
||||
@@ -146,7 +149,7 @@ Deploying through Compose also lets the control instance push over-the-air updat
|
||||
|
||||
### First connect
|
||||
|
||||
The agent boots, reads the three env vars, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
|
||||
The agent boots, reads its configuration, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
|
||||
|
||||
The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The original enrollment token is now useless: the agent does not need it again, and the control instance refuses to accept it a second time.
|
||||
|
||||
@@ -218,13 +221,16 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- sencho-agent-data:/app/data
|
||||
- /opt/docker/sencho:/app/compose
|
||||
- type: bind
|
||||
source: /opt/docker/sencho
|
||||
target: /opt/docker/sencho
|
||||
- /etc/ssl/internal-ca.pem:/etc/ssl/internal-ca.pem:ro
|
||||
environment:
|
||||
SENCHO_MODE: pilot
|
||||
SENCHO_PRIMARY_URL: https://sencho.internal.example.com
|
||||
SENCHO_ENROLL_TOKEN: <short-lived token>
|
||||
SENCHO_PILOT_CA_FILE: /etc/ssl/internal-ca.pem
|
||||
COMPOSE_DIR: /opt/docker/sencho
|
||||
|
||||
volumes:
|
||||
sencho-agent-data:
|
||||
@@ -258,7 +264,7 @@ These environment variables are read by the **agent** container at boot.
|
||||
| `SENCHO_ENROLL_TOKEN` | First boot only | none | The 15-minute enrollment token issued by the control instance. Ignored on subsequent boots once `pilot.jwt` is on disk. |
|
||||
| `SENCHO_PILOT_CA_FILE` | Optional | unset | Absolute path inside the container to a PEM bundle. Use when your control instance's TLS chain is rooted in a private CA. |
|
||||
| `DATA_DIR` | Optional | `/app/data` | Where the persisted `pilot.jwt` is stored. Override only if you are mounting a different volume layout. |
|
||||
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory on the agent host where compose stack folders live. The control instance reads and writes stacks under this path. |
|
||||
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory where compose stack folders live. Enrollment sets it to the absolute path selected for the node and mounts that path identically on the host and in the agent. |
|
||||
|
||||
One variable is read on the **control instance** side, not the agent:
|
||||
|
||||
@@ -322,7 +328,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Stacks tab on a pilot node shows nothing or fails to list">
|
||||
The agent's compose directory is missing or not writable. Verify the host path you mounted at `/app/compose` exists, is owned by a user the agent container can write as, and matches the Compose Directory configured for the node in Settings → Nodes. If the host path has moved, edit the node and update the Compose Directory field.
|
||||
The agent's compose directory is missing or not writable. Verify the selected absolute path exists, is owned by a user the agent container can write as, and is mounted at the same path on the host and inside the agent. The Compose Directory in Settings → Nodes controls the generated enrollment file.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="I changed the Mode on a pilot node in Edit and now it is broken">
|
||||
|
||||
@@ -87,7 +87,7 @@ test.describe('Pilot Agent enrollment', () => {
|
||||
const cmd = await composeFile.innerText();
|
||||
expect(cmd).toContain('SENCHO_MODE: pilot');
|
||||
expect(cmd).toContain('SENCHO_PRIMARY_URL:');
|
||||
expect(cmd).toMatch(/SENCHO_ENROLL_TOKEN: [A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
|
||||
expect(cmd).toMatch(/SENCHO_ENROLL_TOKEN: "?[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"?/);
|
||||
});
|
||||
|
||||
test('regenerating the enrollment token issues a fresh compose file', async ({ page }) => {
|
||||
@@ -108,7 +108,7 @@ test.describe('Pilot Agent enrollment', () => {
|
||||
const firstCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
|
||||
await expect(firstCompose).toBeVisible({ timeout: 10_000 });
|
||||
const firstText = await firstCompose.innerText();
|
||||
const firstToken = firstText.match(/SENCHO_ENROLL_TOKEN: ([A-Za-z0-9_.-]+)/)?.[1];
|
||||
const firstToken = firstText.match(/SENCHO_ENROLL_TOKEN: "?([A-Za-z0-9_.-]+)"?/)?.[1];
|
||||
expect(firstToken).toBeTruthy();
|
||||
|
||||
// Close the enrollment dialog (Escape lands on the row view).
|
||||
@@ -123,7 +123,7 @@ test.describe('Pilot Agent enrollment', () => {
|
||||
const secondCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
|
||||
await expect(secondCompose).toBeVisible({ timeout: 10_000 });
|
||||
const secondText = await secondCompose.innerText();
|
||||
const secondToken = secondText.match(/SENCHO_ENROLL_TOKEN: ([A-Za-z0-9_.-]+)/)?.[1];
|
||||
const secondToken = secondText.match(/SENCHO_ENROLL_TOKEN: "?([A-Za-z0-9_.-]+)"?/)?.[1];
|
||||
expect(secondToken).toBeTruthy();
|
||||
expect(secondToken).not.toBe(firstToken);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [], refreshNodes: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { useNodeActions } from '../useNodeActions';
|
||||
|
||||
function Harness() {
|
||||
const { openCreate, NodeActionModals } = useNodeActions();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={openCreate}>Open</button>
|
||||
{NodeActionModals}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useNodeActions Pilot defaults', () => {
|
||||
it('starts Pilot enrollment with the 1:1 host compose path', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
|
||||
expect(screen.getByLabelText('Compose Directory')).toHaveValue('/opt/docker/sencho');
|
||||
expect(screen.getByText(/mounts this same path inside the container/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the standard compose default for proxy mode', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'Mode' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Distributed API Proxy/i }));
|
||||
|
||||
expect(screen.getByLabelText('Compose Directory')).toHaveValue('/app/compose');
|
||||
});
|
||||
|
||||
it('preserves an operator-entered compose path when the mode changes', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||
const composeDir = screen.getByLabelText('Compose Directory');
|
||||
fireEvent.change(composeDir, { target: { value: '/srv/stacks' } });
|
||||
fireEvent.click(screen.getByRole('combobox', { name: 'Mode' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Distributed API Proxy/i }));
|
||||
|
||||
expect(composeDir).toHaveValue('/srv/stacks');
|
||||
});
|
||||
});
|
||||
@@ -39,13 +39,22 @@ interface NodeFormData {
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_COMPOSE_DIR = '/app/compose';
|
||||
const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho';
|
||||
|
||||
function defaultComposeDir(type: NodeFormData['type'], mode: NodeMode): string {
|
||||
return type === 'remote' && mode === 'pilot_agent'
|
||||
? DEFAULT_PILOT_COMPOSE_DIR
|
||||
: DEFAULT_COMPOSE_DIR;
|
||||
}
|
||||
|
||||
const defaultFormData: NodeFormData = {
|
||||
name: '',
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: '/app/compose',
|
||||
compose_dir: DEFAULT_PILOT_COMPOSE_DIR,
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
@@ -268,7 +277,19 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
<Label htmlFor="node-type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(val) => setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
|
||||
onValueChange={(val) => {
|
||||
const type = val as NodeFormData['type'];
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
type,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(type, formData.mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
@@ -296,7 +317,19 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
<Combobox
|
||||
id="node-mode"
|
||||
value={formData.mode}
|
||||
onValueChange={(val) => setFormData({ ...formData, mode: val as NodeMode, api_url: '', api_token: '' })}
|
||||
onValueChange={(val) => {
|
||||
const mode = val as NodeMode;
|
||||
const currentDefault = defaultComposeDir(formData.type, formData.mode);
|
||||
setFormData({
|
||||
...formData,
|
||||
mode,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
compose_dir: formData.compose_dir === currentDefault
|
||||
? defaultComposeDir(formData.type, mode)
|
||||
: formData.compose_dir,
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ value: 'pilot_agent', label: 'Pilot Agent - outbound tunnel from remote host' },
|
||||
{ value: 'proxy', label: 'Distributed API Proxy - primary dials the remote' },
|
||||
@@ -360,12 +393,14 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
<Label htmlFor="node-compose-dir">Compose Directory</Label>
|
||||
<Input
|
||||
id="node-compose-dir"
|
||||
placeholder="/app/compose"
|
||||
placeholder={defaultComposeDir(formData.type, formData.mode)}
|
||||
value={formData.compose_dir}
|
||||
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The root directory where compose stack folders live on this node.
|
||||
{formData.type === 'remote' && formData.mode === 'pilot_agent'
|
||||
? 'Absolute host path for compose stacks. The generated agent mounts this same path inside the container.'
|
||||
: 'The root directory where compose stack folders live on this node.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user