mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +00:00
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:
@@ -3,8 +3,10 @@
|
|||||||
*
|
*
|
||||||
* Covers:
|
* Covers:
|
||||||
* - POST /api/nodes with mode=pilot_agent mints an enrollment token, persists
|
* - POST /api/nodes with mode=pilot_agent mints an enrollment token, persists
|
||||||
* the SHA256 hash into pilot_enrollments, and returns a docker run command
|
* the SHA256 hash into pilot_enrollments, and returns a Docker Compose
|
||||||
* containing the bearer token.
|
* 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
|
* - consumePilotEnrollment is one-shot: a second consume on the same hash
|
||||||
* returns null (replay protection).
|
* returns null (replay protection).
|
||||||
* - Expired enrollments are not consumable.
|
* - Expired enrollments are not consumable.
|
||||||
@@ -14,8 +16,23 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
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 tmpDir: string;
|
||||||
let app: import('express').Express;
|
let app: import('express').Express;
|
||||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||||
@@ -31,7 +48,7 @@ beforeAll(async () => {
|
|||||||
afterAll(() => cleanupTestDb(tmpDir));
|
afterAll(() => cleanupTestDb(tmpDir));
|
||||||
|
|
||||||
describe('POST /api/nodes (pilot_agent mode)', () => {
|
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)
|
const res = await request(app)
|
||||||
.post('/api/nodes')
|
.post('/api/nodes')
|
||||||
.set('Cookie', adminCookie)
|
.set('Cookie', adminCookie)
|
||||||
@@ -41,9 +58,11 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
|
|||||||
expect(res.body.success).toBe(true);
|
expect(res.body.success).toBe(true);
|
||||||
expect(res.body.enrollment).toBeDefined();
|
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.token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||||
expect(res.body.enrollment.dockerRun).toContain('SENCHO_MODE=pilot');
|
expect(typeof res.body.enrollment.composeYaml).toBe('string');
|
||||||
expect(res.body.enrollment.dockerRun).toContain(`SENCHO_ENROLL_TOKEN=${res.body.enrollment.token}`);
|
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.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
expect(res.body.enrollment).not.toHaveProperty('dockerRun');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists the token hash, not the raw token', async () => {
|
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' });
|
.send({ name: 'pilot-anon', type: 'remote', mode: 'pilot_agent' });
|
||||||
expect(res.status).toBe(401);
|
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', () => {
|
describe('POST /api/nodes/:id/pilot/enroll', () => {
|
||||||
|
|||||||
+25
-11
@@ -22,7 +22,7 @@ import { getErrorMessage } from '../utils/errors';
|
|||||||
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
|
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
|
||||||
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
|
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 db = DatabaseService.getInstance();
|
||||||
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
|
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
|
||||||
if (!jwtSecret) throw new Error('JWT secret not configured');
|
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 host = req.get('host') || 'localhost:1852';
|
||||||
const primaryUrl = `${protocol}://${host}`;
|
const primaryUrl = `${protocol}://${host}`;
|
||||||
|
|
||||||
const dockerRun =
|
// Top-level `name` plus `container_name` make the agent container's HOSTNAME
|
||||||
`docker run -d --restart=unless-stopped --name sencho-agent ` +
|
// equal to `sencho-agent`, which is how SelfUpdateService locates its own
|
||||||
`-v /var/run/docker.sock:/var/run/docker.sock ` +
|
// compose context to enable remote self-update.
|
||||||
`-v sencho-agent-data:/app/data ` +
|
const composeYaml = [
|
||||||
`-v /opt/docker/sencho:/app/compose ` +
|
`name: sencho-agent`,
|
||||||
`-e SENCHO_MODE=pilot ` +
|
`services:`,
|
||||||
`-e SENCHO_PRIMARY_URL=${primaryUrl} ` +
|
` agent:`,
|
||||||
`-e SENCHO_ENROLL_TOKEN=${token} ` +
|
` image: saelix/sencho:latest`,
|
||||||
`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();
|
export const nodesRouter = Router();
|
||||||
|
|||||||
@@ -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.
|
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.
|
Only outbound HTTPS from the remote to the primary is required. Nothing else is exposed.
|
||||||
|
|
||||||
## Enrollment walkthrough
|
## Enrollment walkthrough
|
||||||
@@ -36,29 +38,46 @@ On your primary instance open **Settings → Nodes** and click **Add Node**.
|
|||||||
- **Name:** any label, for example `homelab-nuc`
|
- **Name:** any label, for example `homelab-nuc`
|
||||||
- **Compose Directory:** the folder on the remote host where stack folders will live
|
- **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>
|
<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>
|
</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
|
```bash
|
||||||
docker run -d --restart=unless-stopped --name sencho-agent \
|
docker compose -f compose.yaml up -d
|
||||||
-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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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
|
### 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.
|
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
|
## 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
|
```yaml
|
||||||
docker run -d --restart=unless-stopped --name sencho-agent \
|
name: sencho-agent
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
services:
|
||||||
-v sencho-agent-data:/app/data \
|
agent:
|
||||||
-v /opt/docker/sencho:/app/compose \
|
image: saelix/sencho:latest
|
||||||
-v /etc/ssl/internal-ca.pem:/etc/ssl/internal-ca.pem:ro \
|
container_name: sencho-agent
|
||||||
-e SENCHO_MODE=pilot \
|
restart: unless-stopped
|
||||||
-e SENCHO_PRIMARY_URL=https://sencho.internal.example.com \
|
volumes:
|
||||||
-e SENCHO_ENROLL_TOKEN=<short-lived token> \
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
-e SENCHO_PILOT_CA_FILE=/etc/ssl/internal-ca.pem \
|
- sencho-agent-data:/app/data
|
||||||
saelix/sencho:latest
|
- /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.
|
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>
|
||||||
|
|
||||||
<Accordion title="I ran the enrollment command on the wrong host">
|
<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>
|
||||||
|
|
||||||
<Accordion title="Primary was restored from backup and the agent will not reconnect">
|
<Accordion title="Primary was restored from backup and the agent will not reconnect">
|
||||||
|
|||||||
@@ -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:
|
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
|
- 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)
|
- 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
|
## Checking for updates
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Backend integration is covered by pilot-tunnel-integration.test.ts and
|
* Backend integration is covered by pilot-tunnel-integration.test.ts and
|
||||||
* the pilot-enrollment / pilot-enrollment-replay vitest suites. This file
|
* the pilot-enrollment / pilot-enrollment-replay vitest suites. This file
|
||||||
* exercises the parts only the browser sees: the mode selector, the
|
* 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.
|
* affordance on an existing pilot-mode node.
|
||||||
*
|
*
|
||||||
* Out of scope: simulating an agent connecting to flip the row to Online.
|
* 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);
|
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 nodeName = `${NAME_PREFIX}create-${Date.now()}`;
|
||||||
|
|
||||||
const addBtn = page.getByRole('button', { name: /add node/i }).first();
|
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.locator('#node-name').fill(nodeName);
|
||||||
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
|
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
|
||||||
|
|
||||||
// Enrollment modal opens. The docker run command must contain the
|
// Enrollment modal opens. The compose file must carry the SENCHO_MODE
|
||||||
// SENCHO_MODE flag and a JWT-shaped Bearer token.
|
// env entry and a JWT-shaped enrollment token.
|
||||||
await expect(page.getByText(/Run this command on/i)).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText(/Deploy the pilot agent on/i)).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
const dockerCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
|
const composeFile = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
|
||||||
await expect(dockerCommand).toBeVisible();
|
await expect(composeFile).toBeVisible();
|
||||||
|
|
||||||
const cmd = await dockerCommand.innerText();
|
const cmd = await composeFile.innerText();
|
||||||
expect(cmd).toContain('SENCHO_MODE=pilot');
|
expect(cmd).toContain('SENCHO_MODE: pilot');
|
||||||
expect(cmd).toContain('SENCHO_PRIMARY_URL=');
|
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 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 nodeName = `${NAME_PREFIX}regen-${Date.now()}`;
|
||||||
|
|
||||||
const addBtn = page.getByRole('button', { name: /add node/i }).first();
|
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.locator('#node-name').fill(nodeName);
|
||||||
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
|
await page.getByRole('dialog').getByRole('button', { name: /add node/i }).click();
|
||||||
|
|
||||||
const firstCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
|
const firstCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
|
||||||
await expect(firstCommand).toBeVisible({ timeout: 10_000 });
|
await expect(firstCompose).toBeVisible({ timeout: 10_000 });
|
||||||
const firstText = await firstCommand.innerText();
|
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();
|
expect(firstToken).toBeTruthy();
|
||||||
|
|
||||||
// Close the enrollment dialog (Escape lands on the row view).
|
// 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 row.getByRole('button', { name: 'Edit node' }).click();
|
||||||
await page.getByRole('button', { name: /regenerate enrollment token/i }).click();
|
await page.getByRole('button', { name: /regenerate enrollment token/i }).click();
|
||||||
|
|
||||||
const secondCommand = page.locator('pre').filter({ hasText: /SENCHO_MODE=pilot/ });
|
const secondCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
|
||||||
await expect(secondCommand).toBeVisible({ timeout: 10_000 });
|
await expect(secondCompose).toBeVisible({ timeout: 10_000 });
|
||||||
const secondText = await secondCommand.innerText();
|
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).toBeTruthy();
|
||||||
expect(secondToken).not.toBe(firstToken);
|
expect(secondToken).not.toBe(firstToken);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface NodeTestInfo {
|
|||||||
interface PilotEnrollment {
|
interface PilotEnrollment {
|
||||||
token: string;
|
token: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
dockerRun: string;
|
composeYaml: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeFormData {
|
interface NodeFormData {
|
||||||
@@ -207,12 +207,12 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
|||||||
const copyEnrollment = async () => {
|
const copyEnrollment = async () => {
|
||||||
if (!activeEnrollment) return;
|
if (!activeEnrollment) return;
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(activeEnrollment.enrollment.dockerRun);
|
await copyToClipboard(activeEnrollment.enrollment.composeYaml);
|
||||||
setEnrollmentCopied(true);
|
setEnrollmentCopied(true);
|
||||||
toast.success('Command copied to clipboard');
|
toast.success('Compose file copied to clipboard');
|
||||||
setTimeout(() => setEnrollmentCopied(false), 2000);
|
setTimeout(() => setEnrollmentCopied(false), 2000);
|
||||||
} catch {
|
} 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
|
<ModalHeader
|
||||||
kicker="NODES · PILOT ENROLLMENT"
|
kicker="NODES · PILOT ENROLLMENT"
|
||||||
title="Enroll the pilot agent"
|
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>
|
<ModalBody>
|
||||||
{activeEnrollment && (
|
{activeEnrollment && (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</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>
|
||||||
|
|
||||||
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Expires <span className="font-mono tabular-nums">{formatTimeUntil(activeEnrollment.enrollment.expiresAt)}</span> from now.
|
Expires <span className="font-mono tabular-nums">{formatTimeUntil(activeEnrollment.enrollment.expiresAt)}</span> from now.
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" variant="outline" className="gap-1" onClick={copyEnrollment}>
|
<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 ? <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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter
|
<ModalFooter
|
||||||
|
|||||||
Reference in New Issue
Block a user