mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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:
|
||||
* - 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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user