feat(pilot): make Docker Compose the canonical pilot enrollment payload (#1121)

* feat(pilot): make Docker Compose the canonical pilot enrollment payload

The Add Node dialog for a pilot-agent now returns a Compose snippet
instead of a single-line docker run command, and the enrollment dialog
walks the operator through a save-and-up flow. The compose project name
and container name align with what SelfUpdateService looks up at boot,
so a Compose-deployed pilot can be updated remotely through the Fleet
view without intervention on the remote host.

Docs (pilot-agent, remote-updates) were rewritten to match.

* test(e2e): align pilot enrollment spec with Compose payload

The spec was written against the docker-run payload; it now asserts the
Compose YAML the dialog renders.
This commit is contained in:
Anso
2026-05-20 02:05:21 -04:00
committed by GitHub
parent 0117556bea
commit 3ad6ea9c5d
6 changed files with 190 additions and 74 deletions
+67 -5
View File
@@ -3,8 +3,10 @@
*
* Covers:
* - POST /api/nodes with mode=pilot_agent mints an enrollment token, persists
* the SHA256 hash into pilot_enrollments, and returns a docker run command
* containing the bearer token.
* the SHA256 hash into pilot_enrollments, and returns a Docker Compose
* snippet containing the bearer token.
* - The composeYaml parses as valid YAML and carries the structural pieces
* SelfUpdateService needs to advertise self-update after the agent boots.
* - consumePilotEnrollment is one-shot: a second consume on the same hash
* returns null (replay protection).
* - Expired enrollments are not consumable.
@@ -14,8 +16,23 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import crypto from 'crypto';
import { parse as parseYaml } from 'yaml';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
interface ComposeService {
image: string;
container_name: string;
restart: string;
volumes: string[];
environment: Record<string, string>;
}
interface ComposeFile {
name: string;
services: { agent: ComposeService };
volumes: Record<string, unknown>;
}
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
@@ -31,7 +48,7 @@ beforeAll(async () => {
afterAll(() => cleanupTestDb(tmpDir));
describe('POST /api/nodes (pilot_agent mode)', () => {
it('mints an enrollment token and returns a docker run command', async () => {
it('mints an enrollment token and returns a compose file', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
@@ -41,9 +58,11 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(res.body.success).toBe(true);
expect(res.body.enrollment).toBeDefined();
expect(res.body.enrollment.token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(res.body.enrollment.dockerRun).toContain('SENCHO_MODE=pilot');
expect(res.body.enrollment.dockerRun).toContain(`SENCHO_ENROLL_TOKEN=${res.body.enrollment.token}`);
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}`);
expect(res.body.enrollment.expiresAt).toBeGreaterThan(Date.now());
expect(res.body.enrollment).not.toHaveProperty('dockerRun');
});
it('persists the token hash, not the raw token', async () => {
@@ -70,6 +89,49 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
.send({ name: 'pilot-anon', type: 'remote', mode: 'pilot_agent' });
expect(res.status).toBe(401);
});
it('composeYaml parses cleanly and matches the structure SelfUpdateService expects', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-yaml', type: 'remote', mode: 'pilot_agent' });
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.name).toBe('sencho-agent');
expect(parsed.services.agent.container_name).toBe('sencho-agent');
expect(parsed.services.agent.image).toBe('saelix/sencho:latest');
expect(parsed.services.agent.restart).toBe('unless-stopped');
});
it('composeYaml carries the three required volume mounts', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-mounts', type: 'remote', mode: 'pilot_agent' });
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
const volumes = parsed.services.agent.volumes;
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(parsed.volumes).toHaveProperty('sencho-agent-data');
});
it('composeYaml embeds the three pilot env vars with the enrollment token', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-env', type: 'remote', mode: 'pilot_agent' });
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
const env = parsed.services.agent.environment;
expect(env.SENCHO_MODE).toBe('pilot');
expect(env.SENCHO_PRIMARY_URL).toMatch(/^https?:\/\//);
expect(env.SENCHO_ENROLL_TOKEN).toBe(res.body.enrollment.token);
});
});
describe('POST /api/nodes/:id/pilot/enroll', () => {
+25 -11
View File
@@ -22,7 +22,7 @@ import { getErrorMessage } from '../utils/errors';
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; dockerRun: string } {
function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; composeYaml: string } {
const db = DatabaseService.getInstance();
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret not configured');
@@ -44,17 +44,31 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp
const host = req.get('host') || 'localhost:1852';
const primaryUrl = `${protocol}://${host}`;
const dockerRun =
`docker run -d --restart=unless-stopped --name sencho-agent ` +
`-v /var/run/docker.sock:/var/run/docker.sock ` +
`-v sencho-agent-data:/app/data ` +
`-v /opt/docker/sencho:/app/compose ` +
`-e SENCHO_MODE=pilot ` +
`-e SENCHO_PRIMARY_URL=${primaryUrl} ` +
`-e SENCHO_ENROLL_TOKEN=${token} ` +
`saelix/sencho:latest`;
// Top-level `name` plus `container_name` make the agent container's HOSTNAME
// equal to `sencho-agent`, which is how SelfUpdateService locates its own
// compose context to enable remote self-update.
const composeYaml = [
`name: sencho-agent`,
`services:`,
` agent:`,
` image: saelix/sencho:latest`,
` container_name: sencho-agent`,
` restart: unless-stopped`,
` volumes:`,
` - /var/run/docker.sock:/var/run/docker.sock`,
` - sencho-agent-data:/app/data`,
` - /opt/docker/sencho:/app/compose`,
` environment:`,
` SENCHO_MODE: pilot`,
` SENCHO_PRIMARY_URL: ${primaryUrl}`,
` SENCHO_ENROLL_TOKEN: ${token}`,
``,
`volumes:`,
` sencho-agent-data:`,
``,
].join('\n');
return { token, expiresAt, dockerRun };
return { token, expiresAt, composeYaml };
}
export const nodesRouter = Router();
+53 -25
View File
@@ -23,6 +23,8 @@ Pick **Distributed API Proxy** (documented in [Multi-Node Management](/features/
The agent runs inside a second container on the remote host, using the same `saelix/sencho:latest` image. Setting `SENCHO_MODE=pilot` plus a primary URL and a one-time enrollment token puts the container into agent mode. On boot it dials the primary at `wss://<primary>/api/pilot/tunnel` and holds that connection open. For every tunneled request the primary demultiplexes frames to an internal loopback server, which re-issues the request locally on the agent host against its Docker socket. License tier, role checks, and all other authorization continue to flow from the primary, exactly like proxy mode.
Deploying with Docker Compose also lets the primary trigger over-the-air updates of the agent itself from the Fleet view, since Compose-managed containers carry the labels Sencho needs to recreate them in place.
Only outbound HTTPS from the remote to the primary is required. Nothing else is exposed.
## Enrollment walkthrough
@@ -36,29 +38,46 @@ On your primary instance open **Settings → Nodes** and click **Add Node**.
- **Name:** any label, for example `homelab-nuc`
- **Compose Directory:** the folder on the remote host where stack folders will live
Click **Add Node**. The form is replaced by an enrollment dialog with a one-line `docker run` command.
Click **Add Node**. The form is replaced by an enrollment dialog with a Docker Compose snippet.
<Frame>
<img src="/images/pilot-agent/enrollment-dialog.png" alt="Pilot Agent enrollment dialog with docker run command" />
<img src="/images/pilot-agent/enrollment-dialog.png" alt="Pilot Agent enrollment dialog with compose file" />
</Frame>
### 2. Run the command on the remote host
### 2. Deploy the agent on the remote host
Copy the command and paste it on the remote host. It looks like:
Copy the compose file and save it as `compose.yaml` on the remote host. It looks like:
```yaml
name: sencho-agent
services:
agent:
image: saelix/sencho:latest
container_name: sencho-agent
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
environment:
SENCHO_MODE: pilot
SENCHO_PRIMARY_URL: https://sencho.example.com
SENCHO_ENROLL_TOKEN: <short-lived token>
volumes:
sencho-agent-data:
```
Bring the agent up from the same directory:
```bash
docker run -d --restart=unless-stopped --name sencho-agent \
-v /var/run/docker.sock:/var/run/docker.sock \
-v sencho-agent-data:/app/data \
-v /opt/docker/sencho:/app/compose \
-e SENCHO_MODE=pilot \
-e SENCHO_PRIMARY_URL=https://sencho.example.com \
-e SENCHO_ENROLL_TOKEN=<short-lived token> \
saelix/sencho:latest
docker compose -f compose.yaml up -d
```
The enrollment token is single-use and expires after 15 minutes. On first connect the agent exchanges it for a long-lived tunnel credential, persisted inside the container volume at `/app/data/pilot.jwt`. Subsequent restarts reconnect automatically without needing a new token.
Deploying the agent through Docker Compose also lets the primary push remote updates to it from the Fleet view ([Remote Updates](/features/remote-updates)) without manual intervention on the remote host.
### 3. Confirm the node is online
The node flips to **Online** in the primary within a few seconds of the agent container starting. The **Endpoint** column shows `tunnel` alongside the time the primary last saw a frame from the agent.
@@ -77,19 +96,28 @@ If the agent container is destroyed before its first successful connect, or the
## Self-signed primary TLS certs
If your primary terminates TLS with an internal CA and the agent cannot validate the certificate against the system trust store, point the agent at the CA bundle with `SENCHO_PILOT_CA_FILE`. Mount the bundle into the agent container and add the env var:
If your primary terminates TLS with an internal CA and the agent cannot validate the certificate against the system trust store, point the agent at the CA bundle with `SENCHO_PILOT_CA_FILE`. Add the bundle mount and the env var to the compose file:
```bash
docker run -d --restart=unless-stopped --name sencho-agent \
-v /var/run/docker.sock:/var/run/docker.sock \
-v sencho-agent-data:/app/data \
-v /opt/docker/sencho:/app/compose \
-v /etc/ssl/internal-ca.pem:/etc/ssl/internal-ca.pem:ro \
-e SENCHO_MODE=pilot \
-e SENCHO_PRIMARY_URL=https://sencho.internal.example.com \
-e SENCHO_ENROLL_TOKEN=<short-lived token> \
-e SENCHO_PILOT_CA_FILE=/etc/ssl/internal-ca.pem \
saelix/sencho:latest
```yaml
name: sencho-agent
services:
agent:
image: saelix/sencho:latest
container_name: sencho-agent
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
- /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
volumes:
sencho-agent-data:
```
The agent uses the bundle as the only trust anchor for the tunnel WebSocket. TLS verification stays on; there is no env var to disable verification globally because that would defeat the credential trust model.
@@ -119,7 +147,7 @@ Each tunnel has fixed protocol-level ceilings to keep one misbehaving agent from
</Accordion>
<Accordion title="I ran the enrollment command on the wrong host">
Stop and remove the agent container on the wrong host (`docker stop sencho-agent && docker rm sencho-agent`), then regenerate the enrollment token on the primary and run the command on the correct host.
Tear the agent down on the wrong host (`docker compose -f compose.yaml down -v` from the directory you saved the file in), then regenerate the enrollment token on the primary and deploy the new compose file on the correct host.
</Accordion>
<Accordion title="Primary was restored from backup and the agent will not reconnect">
+2 -2
View File
@@ -13,11 +13,11 @@ Sencho can update remote nodes directly from the dashboard. When a node is runni
Remote updates work when each node meets these conditions:
- Deployed via **Docker Compose** (the recommended method)
- Deployed via **Docker Compose** (the canonical install for both primary instances and pilot agents)
- The Docker socket (`/var/run/docker.sock`) is mounted into the container
- Running a Sencho version that supports the `self-update` [capability](/features/node-compatibility)
Nodes deployed with `docker run` or orchestrators like Kubernetes do not support self-update. These nodes show an error message when you attempt to update them.
Nodes deployed by orchestrators like Kubernetes do not support self-update and need to be updated through the orchestrator's own deployment flow.
## Checking for updates
+20 -20
View File
@@ -4,7 +4,7 @@
* Backend integration is covered by pilot-tunnel-integration.test.ts and
* the pilot-enrollment / pilot-enrollment-replay vitest suites. This file
* exercises the parts only the browser sees: the mode selector, the
* enrollment dialog, the docker run code block, and the regenerate
* enrollment dialog, the Compose file code block, and the regenerate
* affordance on an existing pilot-mode node.
*
* Out of scope: simulating an agent connecting to flip the row to Online.
@@ -52,7 +52,7 @@ test.describe('Pilot Agent enrollment', () => {
await deleteTestNodes(page);
});
test('creating a pilot-agent node opens the enrollment dialog with a docker run command', async ({ page }) => {
test('creating a pilot-agent node opens the enrollment dialog with a compose file', async ({ page }) => {
const nodeName = `${NAME_PREFIX}create-${Date.now()}`;
const addBtn = page.getByRole('button', { name: /add node/i }).first();
@@ -77,20 +77,20 @@ test.describe('Pilot Agent enrollment', () => {
await page.locator('#node-name').fill(nodeName);
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
// Enrollment modal opens. The docker run command must contain the
// SENCHO_MODE flag and a JWT-shaped Bearer token.
await expect(page.getByText(/Run this command on/i)).toBeVisible({ timeout: 10_000 });
// Enrollment modal opens. The compose file must carry the SENCHO_MODE
// env entry and a JWT-shaped enrollment token.
await expect(page.getByText(/Deploy the pilot agent on/i)).toBeVisible({ timeout: 10_000 });
const dockerCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
await expect(dockerCommand).toBeVisible();
const composeFile = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
await expect(composeFile).toBeVisible();
const cmd = await dockerCommand.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_-]+/);
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_-]+/);
});
test('regenerating the enrollment token issues a fresh docker run command', async ({ page }) => {
test('regenerating the enrollment token issues a fresh compose file', async ({ page }) => {
const nodeName = `${NAME_PREFIX}regen-${Date.now()}`;
const addBtn = page.getByRole('button', { name: /add node/i }).first();
@@ -105,10 +105,10 @@ test.describe('Pilot Agent enrollment', () => {
await page.locator('#node-name').fill(nodeName);
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
const firstCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
await expect(firstCommand).toBeVisible({ timeout: 10_000 });
const firstText = await firstCommand.innerText();
const firstToken = firstText.match(/SENCHO_ENROLL_TOKEN=([A-Za-z0-9_.-]+)/)?.[1];
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];
expect(firstToken).toBeTruthy();
// Close the enrollment dialog (Escape lands on the row view).
@@ -120,10 +120,10 @@ test.describe('Pilot Agent enrollment', () => {
await row.getByRole('button', { name: 'Edit node' }).click();
await page.getByRole('button', { name: /regenerate enrollment token/i }).click();
const secondCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
await expect(secondCommand).toBeVisible({ timeout: 10_000 });
const secondText = await secondCommand.innerText();
const secondToken = secondText.match(/SENCHO_ENROLL_TOKEN=([A-Za-z0-9_.-]+)/)?.[1];
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];
expect(secondToken).toBeTruthy();
expect(secondToken).not.toBe(firstToken);
});
@@ -26,7 +26,7 @@ export interface NodeTestInfo {
interface PilotEnrollment {
token: string;
expiresAt: number;
dockerRun: string;
composeYaml: string;
}
interface NodeFormData {
@@ -207,12 +207,12 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
const copyEnrollment = async () => {
if (!activeEnrollment) return;
try {
await copyToClipboard(activeEnrollment.enrollment.dockerRun);
await copyToClipboard(activeEnrollment.enrollment.composeYaml);
setEnrollmentCopied(true);
toast.success('Command copied to clipboard');
toast.success('Compose file copied to clipboard');
setTimeout(() => setEnrollmentCopied(false), 2000);
} catch {
toast.error('Could not copy automatically. Please select and copy the command manually.');
toast.error('Could not copy automatically. Please select and copy the compose file manually.');
}
};
@@ -470,27 +470,39 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<ModalHeader
kicker="NODES · PILOT ENROLLMENT"
title="Enroll the pilot agent"
description="Run the docker command on the remote host to connect the pilot agent."
description="Save the compose file on the remote host, then bring up the pilot agent."
/>
<ModalBody>
{activeEnrollment && (
<>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Run this command on <strong>{activeEnrollment.nodeName}</strong> to start the pilot agent. The token below is valid for 15 minutes and can only be used once.
Deploy the pilot agent on <strong>{activeEnrollment.nodeName}</strong> with the Compose file below. The token is valid for 15 minutes and can only be used once.
</p>
<div className="rounded-md border border-card-border bg-muted/50 p-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground/90">{activeEnrollment.enrollment.dockerRun}</pre>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground/80">Step 1: save the file as <code className="font-mono text-[0.7rem] px-1 py-0.5 rounded bg-muted">compose.yaml</code></p>
<div className="rounded-md border border-card-border bg-muted/50 p-3">
<pre className="text-xs font-mono whitespace-pre overflow-x-auto text-foreground/90">{activeEnrollment.enrollment.composeYaml}</pre>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground/80">Step 2: start the agent on the remote host</p>
<div className="rounded-md border border-card-border bg-muted/50 p-3">
<code className="text-xs font-mono text-foreground/90">docker compose -f compose.yaml up -d</code>
</div>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Expires <span className="font-mono tabular-nums">{formatTimeUntil(activeEnrollment.enrollment.expiresAt)}</span> from now.
</p>
<Button size="sm" variant="outline" className="gap-1" onClick={copyEnrollment}>
{enrollmentCopied ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
{enrollmentCopied ? 'Copied' : 'Copy command'}
{enrollmentCopied ? 'Copied' : 'Copy compose file'}
</Button>
</div>
</>
</div>
)}
</ModalBody>
<ModalFooter