mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06: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:
@@ -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