diff --git a/backend/src/__tests__/pilot-enrollment.test.ts b/backend/src/__tests__/pilot-enrollment.test.ts index f1b95f98..eff731ef 100644 --- a/backend/src/__tests__/pilot-enrollment.test.ts +++ b/backend/src/__tests__/pilot-enrollment.test.ts @@ -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; +} + +interface ComposeFile { + name: string; + services: { agent: ComposeService }; + volumes: Record; +} + 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', () => { diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 77adc7b9..f8b3317d 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -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(); diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx index 82eff1f4..55978a91 100644 --- a/docs/features/pilot-agent.mdx +++ b/docs/features/pilot-agent.mdx @@ -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:///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. - Pilot Agent enrollment dialog with docker run command + Pilot Agent enrollment dialog with compose file -### 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: + +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= \ - 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= \ - -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: + 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 - 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. diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index 38b51fad..51270be7 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -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 diff --git a/e2e/pilot-agent-enrollment.spec.ts b/e2e/pilot-agent-enrollment.spec.ts index 76eebeb6..486c090a 100644 --- a/e2e/pilot-agent-enrollment.spec.ts +++ b/e2e/pilot-agent-enrollment.spec.ts @@ -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); }); diff --git a/frontend/src/components/nodes/useNodeActions.tsx b/frontend/src/components/nodes/useNodeActions.tsx index 7444d698..9c7cb08e 100644 --- a/frontend/src/components/nodes/useNodeActions.tsx +++ b/frontend/src/components/nodes/useNodeActions.tsx @@ -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 {activeEnrollment && ( - <> +

- Run this command on {activeEnrollment.nodeName} to start the pilot agent. The token below is valid for 15 minutes and can only be used once. + Deploy the pilot agent on {activeEnrollment.nodeName} with the Compose file below. The token is valid for 15 minutes and can only be used once.

-
-
{activeEnrollment.enrollment.dockerRun}
+ +
+

Step 1: save the file as compose.yaml

+
+
{activeEnrollment.enrollment.composeYaml}
+
+ +
+

Step 2: start the agent on the remote host

+
+ docker compose -f compose.yaml up -d +
+
+

Expires {formatTimeUntil(activeEnrollment.enrollment.expiresAt)} from now.

- +
)}