mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
chore: merge main into fix/ui-polish, resolve StackRow conflict
This commit is contained in:
@@ -90,7 +90,7 @@ See [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) for the current limitation list
|
||||
- [Auto-update policies](https://docs.sencho.io/features/auto-update-policies) for image rollouts
|
||||
- [Scheduled operations](https://docs.sencho.io/features/scheduled-operations) on cron
|
||||
- [Webhooks](https://docs.sencho.io/features/webhooks) on stack lifecycle events
|
||||
- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection **(Admiral)**
|
||||
- [Blueprints](https://docs.sencho.io/features/blueprint-model): declarative fleet templates with drift detection
|
||||
- Encrypted [Fleet Secrets](https://docs.sencho.io/features/fleet-secrets) pushed to labeled nodes **(Admiral)**
|
||||
|
||||
### Security
|
||||
|
||||
@@ -63,6 +63,15 @@ afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */
|
||||
function insertLegacyLocal(name: string): number {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const result = db.prepare(
|
||||
"INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, 0, 'online', ?)"
|
||||
).run(name, process.env.COMPOSE_DIR ?? '', Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('AutoHealService.evaluate', () => {
|
||||
it('evaluates existing policies on the Community tier (no paid gate)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -318,14 +327,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
|
||||
it('evaluates only policies scoped to each local node', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const secondNodeId = db.addNode({
|
||||
name: 'second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const secondNodeId = insertLegacyLocal('second-local');
|
||||
makePolicy(db, { node_id: secondNodeId, stack_name: 'second-stack' });
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
const getAllContainers = vi.fn().mockResolvedValue([]);
|
||||
@@ -341,14 +343,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
|
||||
it('keeps restart rate-limit state isolated by node while pruning', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const secondNodeId = db.addNode({
|
||||
name: 'rate-limit-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const secondNodeId = insertLegacyLocal('rate-limit-second-local');
|
||||
const policy = makePolicy(db, { node_id: secondNodeId, stack_name: 'second-rate-stack' });
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getAllContainers: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -40,6 +40,18 @@ afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */
|
||||
function insertLegacyLocal(name: string, isDefault = false): number {
|
||||
const rawDb = db.getDb();
|
||||
if (isDefault) {
|
||||
rawDb.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const result = rawDb.prepare(
|
||||
"INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)"
|
||||
).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('DatabaseService - auto-heal policy CRUD', () => {
|
||||
it('addAutoHealPolicy + getAutoHealPolicy round-trip preserves all fields', () => {
|
||||
const input = makePolicy({
|
||||
@@ -111,14 +123,7 @@ describe('DatabaseService - auto-heal policy CRUD', () => {
|
||||
|
||||
it('auto-heal node migration does not rewrite already-scoped node 1 policies', () => {
|
||||
const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-node-one', node_id: 1 }));
|
||||
db.addNode({
|
||||
name: 'new-default-node',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: true,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
insertLegacyLocal('new-default-node', true);
|
||||
|
||||
(db as any).migrateAutoHealNodeId();
|
||||
|
||||
@@ -128,14 +133,7 @@ describe('DatabaseService - auto-heal policy CRUD', () => {
|
||||
it('auto-heal node migration resumes backfill when the completion marker is missing', () => {
|
||||
db.updateGlobalSetting('migration_auto_heal_node_scope_v1', '');
|
||||
const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-partial', node_id: 1 }));
|
||||
const newDefaultId = db.addNode({
|
||||
name: 'partial-new-default-node',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: true,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const newDefaultId = insertLegacyLocal('partial-new-default-node', true);
|
||||
|
||||
(db as any).migrateAutoHealNodeId();
|
||||
|
||||
|
||||
@@ -76,6 +76,18 @@ afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */
|
||||
function insertLegacyLocal(name: string, isDefault = false): number {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
if (isDefault) {
|
||||
db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const result = db.prepare(
|
||||
"INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)"
|
||||
).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
describe('/api/auto-heal routes', () => {
|
||||
it('allows Community tier access', async () => {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
@@ -135,14 +147,7 @@ describe('/api/auto-heal routes', () => {
|
||||
|
||||
it('lists only policies for the active node', async () => {
|
||||
const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
|
||||
const secondNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'route-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const secondNodeId = insertLegacyLocal('route-second-local');
|
||||
makePolicy(defaultNodeId, 'same-stack');
|
||||
makePolicy(secondNodeId, 'same-stack');
|
||||
|
||||
@@ -163,14 +168,7 @@ describe('/api/auto-heal routes', () => {
|
||||
});
|
||||
|
||||
it('rejects history access for a policy owned by a different node', async () => {
|
||||
const secondNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'history-second-local',
|
||||
type: 'local',
|
||||
compose_dir: process.env.COMPOSE_DIR ?? '',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
const secondNodeId = insertLegacyLocal('history-second-local');
|
||||
const policy = makePolicy(secondNodeId, 'history-stack');
|
||||
|
||||
const res = await request(app)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* Authorization parity tests for /api/blueprints.
|
||||
*
|
||||
* The Blueprints UI gates affordances on the paid tier and admin role; these
|
||||
* tests pin the matching server-side guards so a UI gate and a route guard
|
||||
* cannot silently drift apart. Specifically:
|
||||
* - PUT /:id/pin requires the paid tier AND admin role (the admin-role half is
|
||||
* the parity gap the Federation pin control was hardened to match).
|
||||
* - The mutation routes require admin role.
|
||||
* - The read routes require paid tier but NOT admin role.
|
||||
* The Blueprints UI gates edit affordances on admin role; these tests pin the
|
||||
* matching server-side guards so a UI gate and a route guard cannot silently
|
||||
* drift apart. Specifically:
|
||||
* - PUT /:id/pin requires admin role on every tier (Federation placement).
|
||||
* - Core mutation routes require admin role on every tier.
|
||||
* - Read routes require auth but NOT admin role.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -119,7 +118,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.pinned_node_id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin pin a blueprint', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
@@ -129,8 +128,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ nodeId: node.id });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
|
||||
it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => {
|
||||
@@ -148,9 +147,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
});
|
||||
|
||||
describe('Blueprint mutation routes require admin role', () => {
|
||||
// Tier is paid in beforeEach, so requirePaid passes and the admin guard is
|
||||
// what rejects. The gate short-circuits before id parsing, so dummy ids are
|
||||
// sufficient to prove the role boundary.
|
||||
// The gate short-circuits before id parsing, so dummy ids are sufficient
|
||||
// to prove the role boundary.
|
||||
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [
|
||||
{ name: 'create', method: 'post', path: '/api/blueprints' },
|
||||
{ name: 'update', method: 'put', path: '/api/blueprints/1' },
|
||||
@@ -166,18 +164,25 @@ describe('Blueprint mutation routes require admin role', () => {
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from creating with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
.send({
|
||||
name: 'community-bp',
|
||||
compose_content: 'services:\n app:\n image: nginx\n',
|
||||
selector: { type: 'nodes', ids: [node.id] },
|
||||
drift_mode: 'enforce',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.name).toBe('community-bp');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blueprint read routes require paid tier but not admin role', () => {
|
||||
describe('Blueprint read routes require auth but not admin role', () => {
|
||||
it('lets a non-admin paid user list blueprints', async () => {
|
||||
seedBlueprint([]);
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', viewerCookie);
|
||||
@@ -209,10 +214,12 @@ describe('Blueprint read routes require paid tier but not admin role', () => {
|
||||
expect(res.body.classification).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from listing with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin list blueprints (not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
seedBlueprint([]);
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Confirms core Blueprint CRUD, reconciliation, and Federation pin routes are
|
||||
* reachable on the Community tier.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminCookie: string;
|
||||
let viewerAuthHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
|
||||
let counter = 0;
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
function seedNode(): { id: number; name: string } {
|
||||
counter += 1;
|
||||
const name = `bp-community-node-${counter}`;
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
|
||||
).run(name, Date.now());
|
||||
return { id: result.lastInsertRowid as number, name };
|
||||
}
|
||||
|
||||
function validBlueprintBody(nodeId: number) {
|
||||
return {
|
||||
name: `bp-community-${counter + 1}`,
|
||||
compose_content: 'services:\n app:\n image: nginx\n',
|
||||
selector: { type: 'nodes', ids: [nodeId] },
|
||||
drift_mode: 'enforce',
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
|
||||
|
||||
DatabaseService.getInstance().addUser({ username: 'bp-community-viewer', password_hash: 'hash', role: 'viewer' });
|
||||
const viewerToken = jwt.sign({ username: 'bp-community-viewer' }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
viewerAuthHeader = `Bearer ${viewerToken}`;
|
||||
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockTier('community');
|
||||
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileOne').mockResolvedValue(undefined);
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
});
|
||||
|
||||
describe('Blueprints on Community tier', () => {
|
||||
it('GET /api/blueprints returns 200 for an admin', async () => {
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/blueprints creates a blueprint for an admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.drift_mode).toBe('enforce');
|
||||
});
|
||||
|
||||
it('GET /api/blueprints/:id returns detail for a viewer', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/blueprints/${created.body.id}`)
|
||||
.set('Authorization', viewerAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.blueprint.id).toBe(created.body.id);
|
||||
});
|
||||
|
||||
it('POST /api/blueprints/:id/apply triggers reconciliation for an admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/blueprints/${created.body.id}/apply`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Authorization', viewerAuthHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('lets a Community viewer list blueprints', async () => {
|
||||
const res = await request(app).get('/api/blueprints').set('Authorization', viewerAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('node_proxy with community tier header can reach apply-local (not PAID_REQUIRED)', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/apply-local')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community')
|
||||
.send({});
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('node_proxy with community tier header can list blueprints', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.get('/api/blueprints')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('PUT /api/blueprints/:id/pin succeeds for a Community admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/blueprints/${created.body.id}/pin`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ nodeId: node.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractBuildServicesFromCompose,
|
||||
extractBuildServicesFromRenderedConfig,
|
||||
} from '../services/ImageUpdateService';
|
||||
|
||||
describe('extractBuildServicesFromCompose', () => {
|
||||
it('returns service names that declare build', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
api:
|
||||
image: nginx:1.25
|
||||
worker:
|
||||
build:
|
||||
context: ./worker
|
||||
dockerfile: Dockerfile
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml).sort()).toEqual(['web', 'worker']);
|
||||
});
|
||||
|
||||
it('returns empty for image-only stacks', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.25
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores empty build sections', () => {
|
||||
const yaml = `
|
||||
services:
|
||||
web:
|
||||
build: ""
|
||||
api:
|
||||
build: {}
|
||||
`;
|
||||
expect(extractBuildServicesFromCompose(yaml)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBuildServicesFromRenderedConfig', () => {
|
||||
it('reads build services from a rendered compose json model', () => {
|
||||
const rendered = JSON.stringify({
|
||||
services: {
|
||||
app: { build: { context: '/app' }, image: 'myapp:latest' },
|
||||
cache: { image: 'redis:7' },
|
||||
},
|
||||
});
|
||||
expect(extractBuildServicesFromRenderedConfig(rendered)).toEqual(['app']);
|
||||
});
|
||||
|
||||
it('includes override-only build services from merged model', () => {
|
||||
const rendered = JSON.stringify({
|
||||
services: {
|
||||
web: { image: 'nginx:1.25' },
|
||||
sidecar: { build: './sidecar' },
|
||||
},
|
||||
});
|
||||
expect(extractBuildServicesFromRenderedConfig(rendered)).toEqual(['sidecar']);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ describe('runPreflight', () => {
|
||||
expect(report.renderable).toBe(true);
|
||||
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
|
||||
expect(report.highestSeverity).toBe('high');
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-unset', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
|
||||
expect(report.ranBy).toBe('tester');
|
||||
expect(report.sourceHash).toBeTruthy();
|
||||
|
||||
@@ -128,6 +128,40 @@ describe('runPreflight', () => {
|
||||
expect(JSON.stringify(report)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it('does not expose hash fragments as unset variable names (#1550)', async () => {
|
||||
const stack = 'hashfrag';
|
||||
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'compose.yaml'),
|
||||
[
|
||||
'services:',
|
||||
' demo:',
|
||||
' image: alpine:3',
|
||||
' environment:',
|
||||
' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
|
||||
].join('\n'),
|
||||
);
|
||||
try {
|
||||
stubDocker(
|
||||
{ name: stack, services: { demo: { image: 'alpine:3', environment: { EXAMPLE_AUTH_HASH: '' } } }, networks: {}, volumes: {} },
|
||||
'WARN The "E6SDEbshpc" variable is not set. Defaulting to a blank string.\n'
|
||||
+ 'WARN The "vCSr" variable is not set. Defaulting to a blank string.\n',
|
||||
);
|
||||
const report = await doctor().runPreflight(nodeId, stack, 'tester');
|
||||
const literal = report.findings.filter(f => f.ruleId === 'env-literal-dollar');
|
||||
const unset = report.findings.filter(f => f.ruleId === 'env-unset');
|
||||
expect(literal.length).toBeGreaterThan(0);
|
||||
expect(unset.some(f => f.title.includes('E6SDEbshpc') || f.title.includes('vCSr'))).toBe(false);
|
||||
const persisted = JSON.stringify(db().getPreflightFindings(db().getLatestPreflightRun(nodeId, stack)!.id));
|
||||
expect(persisted).not.toContain('E6SDEbshpc');
|
||||
expect(persisted).not.toContain('vCSr');
|
||||
expect(literal[0].title).toContain('likely secret');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces the prior run rather than accumulating', async () => {
|
||||
stubDocker({ name: STACK, services: { web: { image: 'nginx:latest' } }, networks: {}, volumes: {} });
|
||||
await doctor().runPreflight(nodeId, STACK, null);
|
||||
@@ -248,11 +282,45 @@ describe('getLatest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight acknowledgements', () => {
|
||||
const STACK = 'doctorack';
|
||||
beforeEach(() => { writeStack(STACK); });
|
||||
afterEach(() => { fs.rmSync(path.join(process.env.COMPOSE_DIR as string, STACK), { recursive: true, force: true }); });
|
||||
|
||||
it('lowers activeStatus when a finding is acknowledged', async () => {
|
||||
stubDocker(
|
||||
{ name: STACK, services: { web: { image: 'nginx:latest', ports: [{ target: 80, published: '8080', protocol: 'tcp' }] } }, networks: {}, volumes: {} },
|
||||
);
|
||||
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
|
||||
expect(report.status).toBe('high');
|
||||
const target = report.findings.find(f => f.ruleId === 'port-exposed-all-interfaces');
|
||||
expect(target).toBeTruthy();
|
||||
db().upsertPreflightAcknowledgement({
|
||||
node_id: nodeId,
|
||||
stack_name: STACK,
|
||||
rule_id: target!.ruleId,
|
||||
service: target!.service ?? null,
|
||||
reason: 'intentional',
|
||||
expiry_mode: 'forever',
|
||||
expires_at: null,
|
||||
anchor_rendered_hash: null,
|
||||
anchor_image_ref: null,
|
||||
created_by: 'tester',
|
||||
created_at: Date.now(),
|
||||
});
|
||||
const latest = doctor().getLatest(nodeId, STACK);
|
||||
expect(latest.acknowledgedCount).toBe(1);
|
||||
expect(latest.activeCount).toBe(latest.findings.length - 1);
|
||||
expect(latest.status).toBe('high');
|
||||
expect(latest.activeStatus).not.toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node deletion cleanup', () => {
|
||||
it('removes preflight runs and findings for a deleted node', () => {
|
||||
const ghostNode = 987654;
|
||||
db().replacePreflightRun(
|
||||
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
|
||||
{ id: 'run-x', node_id: ghostNode, stack_name: 's', source_hash: null, rendered_hash: null, service_images: null, status: 'pass', highest_severity: null, created_at: 1, created_by: null },
|
||||
[{ id: 'find-x', run_id: 'run-x', rule_id: 'privileged', severity: 'high', title: 't', message: 'm', source_path: null, remediation: null, service: 's', created_at: 1 }],
|
||||
);
|
||||
expect(db().getLatestPreflightRun(ghostNode, 's')).toBeDefined();
|
||||
|
||||
@@ -12,7 +12,7 @@ import type WebSocket from 'ws';
|
||||
|
||||
const {
|
||||
mockSpawn,
|
||||
mockGetContainersByStack, mockRemoveContainers, mockListContainers,
|
||||
mockGetContainersByStack, mockGetLegacyOrphanContainersByStack, mockRemoveContainers, mockListContainers,
|
||||
mockContainerInspect, mockContainerLogs,
|
||||
mockGetRegistries, mockResolveDockerConfig,
|
||||
mockBackupStackFiles, mockRestoreStackFiles,
|
||||
@@ -20,9 +20,11 @@ const {
|
||||
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
|
||||
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
|
||||
mockGetStackContent, mockGetEnvContent,
|
||||
mockLoadStackBuildServices,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockGetLegacyOrphanContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockRemoveContainers: vi.fn().mockResolvedValue([]),
|
||||
mockListContainers: vi.fn().mockResolvedValue([]),
|
||||
mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
|
||||
@@ -43,6 +45,7 @@ const {
|
||||
mockGetBindMounts: vi.fn().mockResolvedValue(null),
|
||||
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
||||
mockGetEnvContent: vi.fn().mockResolvedValue(''),
|
||||
mockLoadStackBuildServices: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
|
||||
@@ -73,6 +76,7 @@ vi.mock('../services/DockerController', () => ({
|
||||
default: {
|
||||
getInstance: () => ({
|
||||
getContainersByStack: mockGetContainersByStack,
|
||||
getLegacyOrphanContainersByStack: mockGetLegacyOrphanContainersByStack,
|
||||
removeContainers: mockRemoveContainers,
|
||||
pruneDanglingImages: mockPruneDanglingImages,
|
||||
getDocker: () => ({
|
||||
@@ -138,6 +142,11 @@ vi.mock('../services/SelfIdentityService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/ImageUpdateService', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../services/ImageUpdateService')>()),
|
||||
loadStackBuildServices: (...args: unknown[]) => mockLoadStackBuildServices(...args),
|
||||
}));
|
||||
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
import { DriftLedgerService } from '../services/DriftLedgerService';
|
||||
|
||||
@@ -211,6 +220,7 @@ beforeEach(() => {
|
||||
mockGetOverrideFilename.mockResolvedValue(null);
|
||||
mockEnsureStackOverride.mockResolvedValue(null);
|
||||
mockGetBindMounts.mockResolvedValue(null);
|
||||
mockLoadStackBuildServices.mockResolvedValue([]);
|
||||
delete process.env.SENCHO_MODE;
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
@@ -580,7 +590,7 @@ describe('ComposeService - deployStack', () => {
|
||||
|
||||
const error = await result;
|
||||
expect(error?.message).toContain('1:1');
|
||||
expect(mockGetContainersByStack).not.toHaveBeenCalled();
|
||||
expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
|
||||
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -621,7 +631,47 @@ describe('ComposeService - deployStack', () => {
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(mockGetContainersByStack).toHaveBeenCalledWith('my-stack');
|
||||
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
|
||||
});
|
||||
|
||||
it('does not remove compose-managed containers before deploy (#1565)', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
mockGetLegacyOrphanContainersByStack.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.deployStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
|
||||
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'up', '-d', '--remove-orphans'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes legacy orphan containers when compose ps is empty', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
mockGetLegacyOrphanContainersByStack.mockResolvedValue([
|
||||
{ Id: 'legacy-c1' },
|
||||
{ Id: 'legacy-c2' },
|
||||
]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.deployStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
expect(mockRemoveContainers).toHaveBeenCalledWith(['legacy-c1', 'legacy-c2']);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'up', '-d', '--remove-orphans'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs docker compose up -d --remove-orphans', async () => {
|
||||
@@ -664,7 +714,7 @@ describe('ComposeService - deployStack', () => {
|
||||
'Atomic deployment backup failed',
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockGetContainersByStack).not.toHaveBeenCalled();
|
||||
expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws sanitized CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
|
||||
@@ -749,6 +799,62 @@ describe('ComposeService - deployStack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStack: build-aware ───────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - updateStack build-aware', () => {
|
||||
it('runs build --pull, pull --ignore-buildable, and up when build services exist', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.updateStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
||||
expect(spawnArgs.some(args => args.includes('build') && args.includes('--pull'))).toBe(true);
|
||||
expect(spawnArgs.some(args => args.includes('pull') && args.includes('--ignore-buildable'))).toBe(true);
|
||||
expect(spawnArgs.some(args => args.includes('up') && args.includes('-d'))).toBe(true);
|
||||
});
|
||||
|
||||
it('runs plain pull + up when no build services exist', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce([]);
|
||||
setupAutoCloseSpawn();
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.updateStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
await promise;
|
||||
|
||||
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
||||
expect(spawnArgs.some(args => args.includes('build'))).toBe(false);
|
||||
expect(spawnArgs.some(args => args.includes('pull') && !args.includes('--ignore-buildable'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back compose files when a build step fails during atomic update', async () => {
|
||||
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
||||
let spawnCount = 0;
|
||||
mockSpawn.mockImplementation(() => {
|
||||
spawnCount += 1;
|
||||
const proc = createMockProcess();
|
||||
Promise.resolve().then(() => proc.emit('close', spawnCount === 1 ? 1 : 0));
|
||||
return proc;
|
||||
});
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
const error = await result;
|
||||
|
||||
expect(error).not.toBeNull();
|
||||
expect(mockRestoreStackFiles).toHaveBeenCalled();
|
||||
expect(getComposeRollbackInfo(error)?.attempted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateStack: prune-on-update ───────────────────────────────────────
|
||||
|
||||
describe('ComposeService - updateStack prune-on-update', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('../services/NodeRegistry', () => ({
|
||||
getInstance: () => ({
|
||||
getDocker: () => mockDocker,
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/test/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -1567,3 +1568,44 @@ describe('DockerController - getBulkStackStatuses partial status', () => {
|
||||
expect(result['empty-stack'].total).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLegacyOrphanContainersByStack (#1565) ───────────────────────────
|
||||
|
||||
describe('DockerController - getLegacyOrphanContainersByStack', () => {
|
||||
it('returns [] when compose ps already manages containers', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c1' }]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
fetchSpy.mockRestore();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns legacy orphan IDs when compose ps is empty', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]);
|
||||
fetchSpy.mockRestore();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to legacy orphan lookup when compose ps throws', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockRejectedValue(new Error('compose ps failed'));
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c2' }]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c2' }]);
|
||||
fetchSpy.mockRestore();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
readEnvFileKeys,
|
||||
parseUnsetEnvVars,
|
||||
parseMissingRequiredVars,
|
||||
parseBareDollarRefs,
|
||||
parseIntentionalBareDollarRefs,
|
||||
} from '../helpers/envVarParse';
|
||||
|
||||
describe('parseInterpolationRefs', () => {
|
||||
@@ -35,6 +37,21 @@ describe('parseInterpolationRefs', () => {
|
||||
expect(parseInterpolationRefs('x: $${ESCAPED}').map(r => r.name)).not.toContain('ESCAPED');
|
||||
});
|
||||
|
||||
it('extracts bare $VAR refs but skips $$ escapes and ${VAR} forms', () => {
|
||||
expect(parseBareDollarRefs('host: $DB_HOST and $${LIT} and ${BRACED}')).toEqual(['DB_HOST']);
|
||||
});
|
||||
|
||||
it('keeps intentional bare refs for self-refs and whole-value refs only', () => {
|
||||
const src = [
|
||||
'- TOKEN=$TOKEN',
|
||||
'- FOO=$BAR',
|
||||
'- EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
|
||||
'EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED',
|
||||
'command: echo $HELLO',
|
||||
].join('\n');
|
||||
expect(parseIntentionalBareDollarRefs(src).sort()).toEqual(['BAR', 'HELLO', 'TOKEN']);
|
||||
});
|
||||
|
||||
it('merges flags across repeated references of one name', () => {
|
||||
const refs = parseInterpolationRefs('${X} then ${X:?e}');
|
||||
expect(refs).toHaveLength(1);
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
*
|
||||
* These guard the same boundary the NodeCard cordon control renders against, so
|
||||
* a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
|
||||
* require the paid tier AND the node:manage permission (held by admin and
|
||||
* require the node:manage permission on every tier (held by admin and
|
||||
* node-admin roles). The guard order is:
|
||||
* rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
|
||||
* -> requirePaid (PAID_REQUIRED) -> invalid-id 400
|
||||
* -> invalid-id 400
|
||||
* -> reason 400 (cordon only) -> 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
@@ -115,15 +115,15 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin cordon a node', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => {
|
||||
@@ -190,7 +190,7 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
expect(res.body.cordoned_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('checks node:manage before the tier gate (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
|
||||
it('checks node:manage before route validation (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
@@ -243,15 +243,15 @@ describe('POST /api/nodes/:id/uncordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin uncordon a node', async () => {
|
||||
setLicense('community');
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED', async () => {
|
||||
|
||||
@@ -285,3 +285,298 @@ describe('DELETE /api/nodes/:id default-node guard (M-4)', () => {
|
||||
expect(res.body.error).toMatch(/default node/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- helpers for singleton tests ----
|
||||
|
||||
function getLocalNodeId(): number {
|
||||
return DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
|
||||
}
|
||||
|
||||
async function makeRemoteDefault(token: string): Promise<{ remoteId: number; originalDefaultId: number }> {
|
||||
const list = await request(app).get('/api/nodes').set('Authorization', token);
|
||||
const originalDefault = (list.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!;
|
||||
const remoteId = await createRemoteNode(token);
|
||||
await request(app)
|
||||
.put(`/api/nodes/${remoteId}`)
|
||||
.set('Authorization', token)
|
||||
.send({ is_default: true });
|
||||
return { remoteId, originalDefaultId: originalDefault.id };
|
||||
}
|
||||
|
||||
async function restoreDefault(token: string, defaultId: number): Promise<void> {
|
||||
await request(app)
|
||||
.put(`/api/nodes/${defaultId}`)
|
||||
.set('Authorization', token)
|
||||
.send({ is_default: true });
|
||||
}
|
||||
|
||||
/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */
|
||||
function insertLegacyLocal(name: string, isDefault = false): number {
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
if (isDefault) {
|
||||
db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const result = db.prepare(
|
||||
"INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)"
|
||||
).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
// ---- singleton enforcement (HTTP layer) ----
|
||||
|
||||
describe('Local node singleton enforcement', () => {
|
||||
it('POST rejects a second local node with 409', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'second-local', type: 'local', compose_dir: '/app/compose' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/a local node already exists/i);
|
||||
});
|
||||
|
||||
it('POST local with is_default:true does not clear the existing default when rejected', async () => {
|
||||
const listBefore = await request(app).get('/api/nodes').set('Authorization', authHeader);
|
||||
const defBefore = (listBefore.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'rejected-local', type: 'local', is_default: true, compose_dir: '/app/compose' });
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const listAfter = await request(app).get('/api/nodes').set('Authorization', authHeader);
|
||||
const defAfter = (listAfter.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!;
|
||||
expect(defAfter.id).toBe(defBefore.id);
|
||||
});
|
||||
|
||||
it('PUT rejects type change from local to remote with 400', async () => {
|
||||
const localId = getLocalNodeId();
|
||||
const res = await request(app)
|
||||
.put(`/api/nodes/${localId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ type: 'remote' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/cannot be changed/i);
|
||||
});
|
||||
|
||||
it('PUT rejects type change from remote to local with 400', async () => {
|
||||
const remoteId = await createRemoteNode(authHeader);
|
||||
const res = await request(app)
|
||||
.put(`/api/nodes/${remoteId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ type: 'local' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/cannot be changed/i);
|
||||
});
|
||||
|
||||
it('PUT rejects invalid type value with 400', async () => {
|
||||
const localId = getLocalNodeId();
|
||||
const res = await request(app)
|
||||
.put(`/api/nodes/${localId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ type: 'invalid' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/must be "local" or "remote"/i);
|
||||
});
|
||||
|
||||
it('PUT allows renaming the local node', async () => {
|
||||
const localId = getLocalNodeId();
|
||||
const originalName = DatabaseService.getInstance().getNode(localId)!.name;
|
||||
try {
|
||||
const res = await request(app)
|
||||
.put(`/api/nodes/${localId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'Renamed Local' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNode(localId)!.name).toBe('Renamed Local');
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateNode(localId, { name: originalName });
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT allows changing compose_dir on the local node', async () => {
|
||||
const localId = getLocalNodeId();
|
||||
const originalDir = DatabaseService.getInstance().getNode(localId)!.compose_dir;
|
||||
try {
|
||||
const res = await request(app)
|
||||
.put(`/api/nodes/${localId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ compose_dir: '/tmp/test-compose' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNode(localId)!.compose_dir).toBe('/tmp/test-compose');
|
||||
} finally {
|
||||
DatabaseService.getInstance().updateNode(localId, { compose_dir: originalDir });
|
||||
}
|
||||
});
|
||||
|
||||
it('DELETE rejects the last local node with 400', async () => {
|
||||
const { remoteId, originalDefaultId } = await makeRemoteDefault(authHeader);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.delete(`/api/nodes/${originalDefaultId}`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/only local node/i);
|
||||
} finally {
|
||||
await restoreDefault(authHeader, originalDefaultId);
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getNode(remoteId)) db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('DELETE allows removing an extra local when more than one exists', async () => {
|
||||
const extraId = insertLegacyLocal('legacy-extra-local');
|
||||
try {
|
||||
const remoteId = await createRemoteNode(authHeader);
|
||||
await request(app)
|
||||
.put(`/api/nodes/${remoteId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ is_default: true });
|
||||
try {
|
||||
const res = await request(app)
|
||||
.delete(`/api/nodes/${extraId}`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getLocalNodeCount()).toBe(1);
|
||||
} finally {
|
||||
await restoreDefault(authHeader, getLocalNodeId());
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getNode(remoteId)) db.deleteNode(remoteId);
|
||||
}
|
||||
} finally {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (db.getNode(extraId)) db.deleteNode(extraId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DatabaseService direct enforcement ----
|
||||
|
||||
describe('DatabaseService direct local-node enforcement', () => {
|
||||
it('addNode throws when a second local is inserted directly', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
expect(() => db.addNode({
|
||||
name: 'direct-second-local',
|
||||
type: 'local',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
})).toThrow(/a local node already exists/i);
|
||||
});
|
||||
|
||||
it('addNode with is_default:true does not clear existing default when it throws', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const defaultBefore = db.getDefaultNode()!.id;
|
||||
expect(() => db.addNode({
|
||||
name: 'direct-rejected-local',
|
||||
type: 'local',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: true,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
})).toThrow(/a local node already exists/i);
|
||||
expect(db.getDefaultNode()!.id).toBe(defaultBefore);
|
||||
});
|
||||
|
||||
it('updateNode throws when type is changed', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const localId = getLocalNodeId();
|
||||
expect(() => db.updateNode(localId, { type: 'remote' as any })).toThrow(/cannot be changed/i);
|
||||
});
|
||||
|
||||
it('deleteNode throws when the last local is deleted directly', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const localId = getLocalNodeId();
|
||||
const remoteId = db.addNode({
|
||||
name: `direct-remote-${Date.now()}`,
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: true,
|
||||
api_url: 'http://192.168.1.77:1852',
|
||||
api_token: 'tok',
|
||||
});
|
||||
try {
|
||||
expect(() => db.deleteNode(localId)).toThrow(/only local node/i);
|
||||
} finally {
|
||||
db.updateNode(localId, { is_default: true });
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('addNode auto-assigns is_default when creating a local during zero-local recovery', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const localId = getLocalNodeId();
|
||||
const originalType = db.getNode(localId)!.type;
|
||||
// Temporarily remove the only local by flipping its type, simulating a
|
||||
// legacy DB with remotes only.
|
||||
db.getDb().prepare("UPDATE nodes SET type = 'remote' WHERE id = ?").run(localId);
|
||||
let newId: number | undefined;
|
||||
try {
|
||||
newId = db.addNode({
|
||||
name: 'recovery-local',
|
||||
type: 'local',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
expect(db.getNode(newId)!.is_default).toBe(true);
|
||||
} finally {
|
||||
// Restore the original local identity. The recovery node was made
|
||||
// default; re-assign to the original before cleanup.
|
||||
db.getDb().prepare("UPDATE nodes SET type = ? WHERE id = ?").run(originalType, localId);
|
||||
if (newId !== undefined && db.getNode(newId)) {
|
||||
db.updateNode(localId, { is_default: true });
|
||||
db.deleteNode(newId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- startup warnings ----
|
||||
|
||||
describe('logLocalNodeWarnings', () => {
|
||||
it('warns when there are zero local nodes', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const localId = getLocalNodeId();
|
||||
const originalType = db.getNode(localId)!.type;
|
||||
db.getDb().prepare("UPDATE nodes SET type = 'remote' WHERE id = ?").run(localId);
|
||||
try {
|
||||
db.logLocalNodeWarnings();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No local node found'));
|
||||
} finally {
|
||||
db.getDb().prepare('UPDATE nodes SET type = ? WHERE id = ?').run(originalType, localId);
|
||||
}
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('warns when there are multiple local nodes', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const extraId = insertLegacyLocal('warn-extra-local');
|
||||
try {
|
||||
db.logLocalNodeWarnings();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 local nodes'));
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
if (db.getNode(extraId)) db.deleteNode(extraId);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not warn when exactly one local node exists', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
db.logLocalNodeWarnings();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Unit tests for PilotAgent auth fallback event handling using a stub WebSocket.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
const { wsInstances, mockAttachSwitchboard } = vi.hoisted(() => ({
|
||||
wsInstances: [] as Array<{
|
||||
emit: (event: string, ...args: unknown[]) => boolean;
|
||||
readyState: number;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
mockAttachSwitchboard: vi.fn(() => ({
|
||||
handleJsonFrame: vi.fn(() => false),
|
||||
handleBinaryFrame: vi.fn(() => false),
|
||||
cleanup: vi.fn(),
|
||||
tcpStreamCount: vi.fn(() => 0),
|
||||
openReverseStream: vi.fn(() => null),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../mesh/tcpStreamSwitchboard', () => ({
|
||||
attachTcpStreamSwitchboard: mockAttachSwitchboard,
|
||||
resolveByComposeLabels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { EventEmitter } = require('events') as typeof import('events');
|
||||
class MockWebSocket extends EventEmitter {
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
constructor(..._args: unknown[]) {
|
||||
super();
|
||||
wsInstances.push(this);
|
||||
}
|
||||
}
|
||||
return { default: MockWebSocket };
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let PilotAgent: typeof import('../pilot/agent').PilotAgent;
|
||||
let readPersistedToken: typeof import('../pilot/agent').readPersistedToken;
|
||||
let persistToken: typeof import('../pilot/agent').persistToken;
|
||||
let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ PilotAgent, readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('PilotAgent auth fallback (stub WebSocket)', () => {
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => { /* swallow */ });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => { /* swallow */ });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearPersistedToken();
|
||||
});
|
||||
|
||||
it('swaps to the enroll token and clears pilot.jwt after HTTP 401 on upgrade', () => {
|
||||
persistToken('stale-on-disk');
|
||||
const agent = new PilotAgent({
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 1,
|
||||
initialToken: 'stale-token',
|
||||
enrollToken: 'fresh-enroll-token',
|
||||
enrolling: false,
|
||||
});
|
||||
|
||||
(agent as unknown as { connect: () => void }).connect();
|
||||
|
||||
const firstWs = wsInstances[0]!;
|
||||
// Real ws (no unexpected-response listener): abortHandshake emits error then close.
|
||||
firstWs.emit('error', new Error('Unexpected server response: 401'));
|
||||
firstWs.emit('close', 1006, Buffer.from(''));
|
||||
|
||||
expect(readPersistedToken()).toBeNull();
|
||||
expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token');
|
||||
// scheduleReconnect doubles backoff after scheduling the imminent retry.
|
||||
expect((agent as unknown as { backoff: number }).backoff).toBe(2_000);
|
||||
});
|
||||
|
||||
it('swaps to the enroll token after HTTP 404 on upgrade', () => {
|
||||
persistToken('stale-on-disk');
|
||||
const agent = new PilotAgent({
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 1,
|
||||
initialToken: 'stale-token',
|
||||
enrollToken: 'fresh-enroll-token',
|
||||
enrolling: false,
|
||||
});
|
||||
|
||||
(agent as unknown as { connect: () => void }).connect();
|
||||
|
||||
const firstWs = wsInstances[0]!;
|
||||
firstWs.emit('error', new Error('Unexpected server response: 404'));
|
||||
firstWs.emit('close', 1006, Buffer.from(''));
|
||||
|
||||
expect(readPersistedToken()).toBeNull();
|
||||
expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token');
|
||||
});
|
||||
|
||||
it('does not swap when already connecting with the enroll token', () => {
|
||||
const agent = new PilotAgent({
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 1,
|
||||
initialToken: 'only-enroll-token',
|
||||
enrollToken: 'only-enroll-token',
|
||||
enrolling: true,
|
||||
});
|
||||
|
||||
(agent as unknown as { connect: () => void }).connect();
|
||||
|
||||
const firstWs = wsInstances[0]!;
|
||||
firstWs.emit('error', new Error('Unexpected server response: 401'));
|
||||
firstWs.emit('close', 1006, Buffer.from(''));
|
||||
|
||||
expect((agent as unknown as { token: string }).token).toBe('only-enroll-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Regression tests for pilot agent auth fallback when a persisted tunnel
|
||||
* token is rejected at WebSocket upgrade (401 invalid JWT, 404 unknown node).
|
||||
*
|
||||
* Without fallback the agent reconnects forever with the same stale
|
||||
* pilot.jwt credential even when SENCHO_ENROLL_TOKEN carries a fresh
|
||||
* enrollment JWT.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import http from 'http';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { attachUpgrade } from '../websocket/upgradeHandler';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
// agent.ts freezes its pilot.jwt path from DATA_DIR at module load, so it must
|
||||
// be imported only after setupTestDb() points DATA_DIR at the writable tmp dir.
|
||||
let readPersistedToken: typeof import('../pilot/agent').readPersistedToken;
|
||||
let persistToken: typeof import('../pilot/agent').persistToken;
|
||||
let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken;
|
||||
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let pilotTunnelWss: WebSocketServer;
|
||||
let mainWss: WebSocketServer;
|
||||
let nodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent'));
|
||||
|
||||
server = http.createServer();
|
||||
mainWss = new WebSocketServer({ noServer: true });
|
||||
pilotTunnelWss = new WebSocketServer({ noServer: true });
|
||||
attachUpgrade(server, { wss: mainWss, pilotTunnelWss });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error('listen returned unexpected address'));
|
||||
return;
|
||||
}
|
||||
port = addr.port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
nodeId = DatabaseService.getInstance().addNode({
|
||||
name: `pilot-auth-fallback-${Date.now()}`,
|
||||
type: 'remote',
|
||||
mode: 'pilot_agent',
|
||||
compose_dir: '/tmp/x',
|
||||
is_default: false,
|
||||
api_url: '',
|
||||
api_token: '',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const mgr = PilotTunnelManager.getInstance();
|
||||
mgr.closeTunnel(nodeId);
|
||||
mgr.removeAllListeners('tunnel-up');
|
||||
mgr.removeAllListeners('tunnel-down');
|
||||
pilotTunnelWss.close();
|
||||
mainWss.close();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PilotTunnelManager.getInstance().closeTunnel(nodeId);
|
||||
clearPersistedToken();
|
||||
});
|
||||
|
||||
function mintStaleTunnelTokenWrongSecret(): string {
|
||||
return jwt.sign(
|
||||
{ scope: 'pilot_tunnel', nodeId },
|
||||
'wrong-secret-not-the-control-instance',
|
||||
{ expiresIn: '365d' },
|
||||
);
|
||||
}
|
||||
|
||||
function mintStaleTunnelTokenWrongNode(): string {
|
||||
return jwt.sign(
|
||||
{ scope: 'pilot_tunnel', nodeId: 99_999_999 },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '365d' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('clearPersistedToken', () => {
|
||||
it('removes an existing pilot.jwt file', () => {
|
||||
persistToken('stale-token');
|
||||
expect(readPersistedToken()).toBe('stale-token');
|
||||
clearPersistedToken();
|
||||
expect(readPersistedToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when the file is already absent', () => {
|
||||
clearPersistedToken();
|
||||
expect(() => clearPersistedToken()).not.toThrow();
|
||||
expect(readPersistedToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pilot tunnel upgrade rejection (in-process integration)', () => {
|
||||
it('rejects a stale tunnel JWT signed with the wrong secret at upgrade', async () => {
|
||||
const staleToken = mintStaleTunnelTokenWrongSecret();
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${staleToken}`,
|
||||
'x-sencho-agent-version': 'auth-fallback-test/1.0',
|
||||
},
|
||||
});
|
||||
const result = await new Promise<{ status?: number }>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => {
|
||||
resolve({ status: res.statusCode });
|
||||
res.destroy();
|
||||
});
|
||||
ws.on('error', () => { /* close follows */ });
|
||||
});
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a tunnel JWT for an unknown node with HTTP 404 at upgrade', async () => {
|
||||
const staleToken = mintStaleTunnelTokenWrongNode();
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${staleToken}`,
|
||||
'x-sencho-agent-version': 'auth-fallback-test/1.0',
|
||||
},
|
||||
});
|
||||
const result = await new Promise<{ status?: number }>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => {
|
||||
resolve({ status: res.statusCode });
|
||||
res.destroy();
|
||||
});
|
||||
ws.on('error', () => { /* close follows */ });
|
||||
});
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,12 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({
|
||||
const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync, mockUnlinkSync } = vi.hoisted(() => ({
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockWriteFileSync: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
mockMkdirSync: vi.fn(),
|
||||
mockUnlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => {
|
||||
@@ -32,13 +33,14 @@ vi.mock('fs', () => {
|
||||
writeFileSync: mockWriteFileSync,
|
||||
existsSync: mockExistsSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
unlinkSync: mockUnlinkSync,
|
||||
};
|
||||
return { ...mock, default: mock };
|
||||
});
|
||||
|
||||
// agent.ts is imported AFTER vi.mock so the mock is in place when the
|
||||
// module's top-level fs import resolves.
|
||||
import { readPersistedToken, persistToken } from '../pilot/agent';
|
||||
import { readPersistedToken, persistToken, clearPersistedToken } from '../pilot/agent';
|
||||
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -167,3 +169,25 @@ describe('persistToken', () => {
|
||||
expect(() => persistToken('test-token')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPersistedToken', () => {
|
||||
it('unlinks the token file on the happy path', () => {
|
||||
clearPersistedToken();
|
||||
expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('pilot.jwt'));
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not warn on ENOENT (file already absent)', () => {
|
||||
mockUnlinkSync.mockImplementationOnce(() => { throw fsError('ENOENT', 'no such file'); });
|
||||
clearPersistedToken();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns on EACCES (read-only volume)', () => {
|
||||
mockUnlinkSync.mockImplementationOnce(() => { throw fsError('EACCES', 'permission denied'); });
|
||||
clearPersistedToken();
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('Failed to remove persisted tunnel token');
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('EACCES');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('PilotAgent loopback auth injection', () => {
|
||||
primaryUrl: 'http://primary.invalid',
|
||||
loopbackPort: 1,
|
||||
initialToken: 'irrelevant-for-this-test',
|
||||
enrollToken: null,
|
||||
enrolling: false,
|
||||
});
|
||||
mintHeader = (agent as unknown as { getLoopbackAuthHeader: () => string | null }).getLoopbackAuthHeader.bind(agent);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { PreflightAcknowledgement } from '../services/DatabaseService';
|
||||
import { applyPreflightAcknowledgements, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
const baseFinding = (over: Partial<PreflightFinding> = {}): PreflightFinding => ({
|
||||
ruleId: 'uid-gid-risk',
|
||||
severity: 'warning',
|
||||
title: 'Check UID/GID alignment',
|
||||
message: 'test',
|
||||
service: 'web',
|
||||
...over,
|
||||
});
|
||||
|
||||
const baseAck = (over: Partial<PreflightAcknowledgement> = {}): PreflightAcknowledgement => ({
|
||||
id: 1,
|
||||
node_id: 1,
|
||||
stack_name: 'demo',
|
||||
rule_id: 'uid-gid-risk',
|
||||
service: 'web',
|
||||
reason: 'verified ownership',
|
||||
expiry_mode: 'forever',
|
||||
expires_at: null,
|
||||
anchor_rendered_hash: null,
|
||||
anchor_image_ref: null,
|
||||
created_by: 'admin',
|
||||
created_at: Date.now(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('applyPreflightAcknowledgements', () => {
|
||||
const ctx = { renderedHash: 'hash-a', serviceImages: { web: 'nginx:1.2' } };
|
||||
|
||||
it('marks a matching service-scoped ack as acknowledged', () => {
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [baseAck()], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
expect(out[0].acknowledgementId).toBe(1);
|
||||
});
|
||||
|
||||
it('prefers a service-scoped ack over a rule-wide ack', () => {
|
||||
const ruleWide = baseAck({ id: 2, service: null });
|
||||
const serviceScoped = baseAck({ id: 3, service: 'web' });
|
||||
const out = applyPreflightAcknowledgements(
|
||||
[baseFinding()],
|
||||
ctx,
|
||||
[ruleWide, serviceScoped],
|
||||
Date.now(),
|
||||
);
|
||||
expect(out[0].acknowledgementId).toBe(3);
|
||||
});
|
||||
|
||||
it('does not acknowledge when until_compose_change hash differs', () => {
|
||||
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'old-hash' });
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('acknowledges when until_compose_change hash matches', () => {
|
||||
const ack = baseAck({ expiry_mode: 'until_compose_change', anchor_rendered_hash: 'hash-a' });
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
});
|
||||
|
||||
it('expires days mode after expires_at', () => {
|
||||
const now = 1_000_000;
|
||||
const ack = baseAck({ expiry_mode: 'days', expires_at: now - 1 });
|
||||
expect(isPreflightAckActive(ack, ctx, now)).toBe(false);
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], now);
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('honors until_image_change while image ref matches', () => {
|
||||
const ack = baseAck({
|
||||
expiry_mode: 'until_image_change',
|
||||
anchor_image_ref: 'nginx:1.2',
|
||||
});
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(true);
|
||||
});
|
||||
|
||||
it('re-surfaces until_image_change when image ref changes', () => {
|
||||
const ack = baseAck({
|
||||
expiry_mode: 'until_image_change',
|
||||
anchor_image_ref: 'nginx:1.0',
|
||||
});
|
||||
const out = applyPreflightAcknowledgements([baseFinding()], ctx, [ack], Date.now());
|
||||
expect(out[0].acknowledged).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,64 @@ describe('preflight routes', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preflight acknowledgement routes', () => {
|
||||
let stackDir: string;
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n ports:\n - "8080:80"\n');
|
||||
stub();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('POST acknowledges a finding and GET preflight reflects activeStatus', async () => {
|
||||
const run = await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
expect(run.status).toBe(200);
|
||||
const target = run.body.findings.find((f: { ruleId: string }) => f.ruleId === 'port-exposed-all-interfaces');
|
||||
expect(target).toBeTruthy();
|
||||
|
||||
const ack = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: target.ruleId, service: target.service ?? null, reason: 'intentional', expiryMode: 'forever' });
|
||||
expect(ack.status).toBe(201);
|
||||
|
||||
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
expect(get.body.acknowledgedCount).toBeGreaterThanOrEqual(1);
|
||||
const acked = get.body.findings.find((f: { ruleId: string; service?: string }) =>
|
||||
f.ruleId === target.ruleId && f.service === target.service);
|
||||
expect(acked?.acknowledged).toBe(true);
|
||||
expect(get.body.activeCount).toBe(get.body.findings.length - get.body.acknowledgedCount);
|
||||
});
|
||||
|
||||
it('DELETE clears an acknowledgement', async () => {
|
||||
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
const list = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
const target = list.body.findings[0];
|
||||
const ack = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: target.ruleId, service: target.service ?? null, expiryMode: 'forever' });
|
||||
const del = await request(app)
|
||||
.delete(`/api/stacks/${STACK}/preflight/acknowledgements/${ack.body.id}`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(del.status).toBe(204);
|
||||
const get = await request(app).get(`/api/stacks/${STACK}/preflight`).set('Authorization', authHeader);
|
||||
const again = get.body.findings.find((f: { ruleId: string; service?: string }) =>
|
||||
f.ruleId === target.ruleId && f.service === target.service);
|
||||
expect(again?.acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects until_image_change without a service', async () => {
|
||||
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
const m = over.model !== undefined ? over.model : model([]);
|
||||
return {
|
||||
stackName: 'proj', platform: 'linux', model: m, renderable: true, renderError: null, unsetEnvVars: [],
|
||||
literalDollarWarnings: [],
|
||||
missingEnvFiles: [],
|
||||
sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true,
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
@@ -55,6 +56,34 @@ describe('env-unset', () => {
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f.map(x => x.sourcePath)).toEqual(['FOO', 'BAR']);
|
||||
});
|
||||
it('mentions literal-dollar escapes in remediation', () => {
|
||||
const f = ids(runRules(ctx({ unsetEnvVars: ['FOO'] })), 'env-unset');
|
||||
expect(f[0].remediation).toContain('$$');
|
||||
expect(f[0].remediation).toContain('single-quote');
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-literal-dollar', () => {
|
||||
it('emits a safe finding for likely-secret literal dollar warnings', () => {
|
||||
const f = ids(runRules(ctx({
|
||||
literalDollarWarnings: [{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true, service: 'demo' }],
|
||||
})), 'env-literal-dollar');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].severity).toBe('high');
|
||||
expect(f[0].title).toContain('likely secret');
|
||||
expect(f[0].sourcePath).toBe('EXAMPLE_AUTH_HASH');
|
||||
expect(f[0].service).toBe('demo');
|
||||
expect(f[0].title).not.toContain('E6SDEbshpc');
|
||||
expect(f[0].remediation).toContain('$$');
|
||||
});
|
||||
it('omits fragment names from generic literal-dollar findings', () => {
|
||||
const f = ids(runRules(ctx({
|
||||
literalDollarWarnings: [{ likelySecret: false }],
|
||||
})), 'env-literal-dollar');
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].sourcePath).toBeUndefined();
|
||||
expect(f[0].title).toContain('environment value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('env-file-missing', () => {
|
||||
@@ -408,7 +437,7 @@ describe('rule registry completeness', () => {
|
||||
// The canonical rule set. Adding or removing a rule must update this list,
|
||||
// which forces a deliberate pass over the docs and the frontend severity map.
|
||||
const EXPECTED_RULE_IDS = [
|
||||
'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'render-failed', 'env-unset', 'env-literal-dollar', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
|
||||
'node-state-unavailable',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import YAML from 'yaml';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
@@ -103,4 +104,80 @@ describe('TemplateService.getTemplates registry size cap', () => {
|
||||
expect(plex!.env).toEqual([{ name: 'PUID', label: 'User ID', default: '1000' }]);
|
||||
expect(plex!.categories).toEqual(['Media']);
|
||||
});
|
||||
|
||||
it('maps LSIO :ro volume paths and skips optional volumes (fail2ban)', async () => {
|
||||
const service = new TemplateService();
|
||||
service.clearCache();
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: {
|
||||
repositories: {
|
||||
linuxserver: {
|
||||
fail2ban: {
|
||||
name: 'fail2ban',
|
||||
description: 'Ban IPs',
|
||||
config: {
|
||||
volumes: [
|
||||
{ path: '/config', host_path: '/path/to/fail2ban/config', optional: false },
|
||||
{ path: '/var/log:ro', host_path: '/var/log', optional: false },
|
||||
{
|
||||
path: '/remotelogs/nginx:ro',
|
||||
host_path: '/path/to/nginx/log',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const templates = await service.getTemplates();
|
||||
const fail2ban = templates.find(t => t.title === 'fail2ban')!;
|
||||
expect(fail2ban.volumes).toEqual([
|
||||
{ container: '/config', bind: './config' },
|
||||
{ container: '/var/log', bind: '/var/log', readonly: true },
|
||||
]);
|
||||
|
||||
const yaml = service.generateComposeFromTemplate(fail2ban, 'fail2ban');
|
||||
const parsed = YAML.parse(yaml) as { services: { fail2ban: { volumes: string[] } } };
|
||||
expect(parsed.services.fail2ban.volumes).toEqual([
|
||||
'./config:/config',
|
||||
'/var/log:/var/log:ro',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps LSIO :ro volume with placeholder host_path (mame)', async () => {
|
||||
const service = new TemplateService();
|
||||
service.clearCache();
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: {
|
||||
repositories: {
|
||||
linuxserver: {
|
||||
mame: {
|
||||
name: 'mame',
|
||||
description: 'MAME',
|
||||
config: {
|
||||
volumes: [
|
||||
{ path: '/config', host_path: '/path/to/config', optional: false },
|
||||
{ path: '/mame:ro', host_path: '/path/to/mame/assets', optional: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const templates = await service.getTemplates();
|
||||
const mame = templates.find(t => t.title === 'mame');
|
||||
expect(mame!.volumes).toEqual([
|
||||
{ container: '/config', bind: './config' },
|
||||
{ container: '/mame', bind: './mame', readonly: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,29 @@ describe('TemplateService', () => {
|
||||
expect(svc.volumes).toEqual(['./config:/config:ro']);
|
||||
});
|
||||
|
||||
it('generates valid compose for fail2ban-shaped template volumes', () => {
|
||||
const svc = serviceOf(service.generateComposeFromTemplate({
|
||||
title: 'fail2ban',
|
||||
description: 'Ban IPs',
|
||||
image: 'lscr.io/linuxserver/fail2ban:latest',
|
||||
volumes: [
|
||||
{ container: '/config', bind: './config' },
|
||||
{ container: '/var/log', bind: '/var/log', readonly: true },
|
||||
],
|
||||
}, 'fail2ban'), 'fail2ban');
|
||||
expect(svc.volumes).toEqual(['./config:/config', '/var/log:/var/log:ro']);
|
||||
});
|
||||
|
||||
it('repairs stale LSIO volume paths that still embed :ro in container and bind', () => {
|
||||
const svc = serviceOf(service.generateComposeFromTemplate({
|
||||
title: 'fail2ban',
|
||||
description: 'Ban IPs',
|
||||
image: 'lscr.io/linuxserver/fail2ban:latest',
|
||||
volumes: [{ container: '/var/log:ro', bind: './log:ro' }],
|
||||
}, 'fail2ban'), 'fail2ban');
|
||||
expect(svc.volumes).toEqual(['./log:/var/log:ro']);
|
||||
});
|
||||
|
||||
it('includes env_file only when env vars are present', () => {
|
||||
const withEnv = serviceOf(service.generateComposeFromTemplate({
|
||||
title: 'app', description: 'Test', image: 'test:latest',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification';
|
||||
import type { StackEnvSources } from '../helpers/envFileResolution';
|
||||
|
||||
function sources(over: Partial<StackEnvSources> = {}): StackEnvSources {
|
||||
return {
|
||||
stackDir: '/stack',
|
||||
baseDir: '/compose',
|
||||
composeFiles: [],
|
||||
envFiles: [],
|
||||
inlineEnvKeysByService: {},
|
||||
interpolationRefs: [],
|
||||
authoredComposeText: '',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('classifyUnsetEnvVars', () => {
|
||||
it('keeps intentional ${VAR} refs as unset variables', () => {
|
||||
const src = sources({
|
||||
authoredComposeText: 'services:\n web:\n image: nginx\n environment:\n - DB_HOST=${DB_HOST}\n',
|
||||
interpolationRefs: [{ name: 'DB_HOST', required: false, hasDefault: false, alternate: false }],
|
||||
});
|
||||
const result = classifyUnsetEnvVars(['DB_HOST', 'E6SDEbshpc'], src);
|
||||
expect(result.intentional).toEqual(['DB_HOST']);
|
||||
expect(result.literalDollar).toHaveLength(1);
|
||||
expect(result.literalDollar[0].likelySecret).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies bcrypt hash fragments as literal-dollar warnings without exposing fragments', () => {
|
||||
const compose = [
|
||||
'services:',
|
||||
' demo:',
|
||||
' image: alpine:3',
|
||||
' environment:',
|
||||
' - EXAMPLE_AUTH_HASH=$2b$10$E6SDEbshpc$vCSrREDACTED',
|
||||
].join('\n');
|
||||
const src = sources({
|
||||
authoredComposeText: compose,
|
||||
inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] },
|
||||
});
|
||||
const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src);
|
||||
expect(result.intentional).toEqual([]);
|
||||
expect(result.literalDollar).toHaveLength(1);
|
||||
expect(result.literalDollar[0]).toMatchObject({
|
||||
envKey: 'EXAMPLE_AUTH_HASH',
|
||||
likelySecret: true,
|
||||
service: 'demo',
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('E6SDEbshpc');
|
||||
expect(JSON.stringify(result)).not.toContain('vCSr');
|
||||
});
|
||||
|
||||
it('classifies map-form bcrypt hash fragments as literal-dollar warnings', () => {
|
||||
const compose = [
|
||||
'services:',
|
||||
' demo:',
|
||||
' image: alpine:3',
|
||||
' environment:',
|
||||
' EXAMPLE_AUTH_HASH: $2b$10$E6SDEbshpc$vCSrREDACTED',
|
||||
].join('\n');
|
||||
const src = sources({
|
||||
authoredComposeText: compose,
|
||||
inlineEnvKeysByService: { demo: ['EXAMPLE_AUTH_HASH'] },
|
||||
});
|
||||
const result = classifyUnsetEnvVars(['E6SDEbshpc', 'vCSr'], src);
|
||||
expect(result.intentional).toEqual([]);
|
||||
expect(result.literalDollar[0]?.envKey).toBe('EXAMPLE_AUTH_HASH');
|
||||
});
|
||||
|
||||
it('treats bare $VAR references as intentional', () => {
|
||||
const compose = 'services:\n web:\n image: nginx\n environment:\n - TOKEN=$TOKEN\n';
|
||||
const src = sources({ authoredComposeText: compose });
|
||||
const result = classifyUnsetEnvVars(['TOKEN'], src);
|
||||
expect(result.intentional).toEqual(['TOKEN']);
|
||||
expect(result.literalDollar).toEqual([]);
|
||||
});
|
||||
|
||||
it('attributes env-file-only spurious fragments to a lone likely-secret key', () => {
|
||||
const src = sources({ authoredComposeText: 'services:\n web:\n image: nginx\n env_file:\n - ./secrets.env\n' });
|
||||
const result = classifyUnsetEnvVars(['E6SDEbshpc'], src, ['EXAMPLE_AUTH_HASH']);
|
||||
expect(result.intentional).toEqual([]);
|
||||
expect(result.literalDollar).toEqual([{ envKey: 'EXAMPLE_AUTH_HASH', likelySecret: true }]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
healthchecksSignal,
|
||||
preflightSignal,
|
||||
updatePreviewSignal,
|
||||
buildServicesSignal,
|
||||
} from '../services/updateGuard/readiness';
|
||||
import type { ContainerProbe, ReadinessSignal } from '../services/updateGuard/types';
|
||||
import type { UpdatePreviewSummary } from '../services/UpdatePreviewService';
|
||||
@@ -34,22 +35,24 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
|
||||
update_kind: 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('preflightSignal', () => {
|
||||
const cases = [
|
||||
{ status: 'never-run', expected: 'unknown', affects: false },
|
||||
{ status: 'blocker', expected: 'blocked', affects: true },
|
||||
{ status: 'unrenderable', expected: 'attention', affects: true },
|
||||
{ status: 'high', expected: 'attention', affects: true },
|
||||
{ status: 'warning', expected: 'warning', affects: true },
|
||||
{ status: 'pass', expected: 'ok', affects: true },
|
||||
{ status: 'info', expected: 'ok', affects: true },
|
||||
{ activeStatus: 'never-run', expected: 'unknown', affects: false },
|
||||
{ activeStatus: 'blocker', expected: 'blocked', affects: true },
|
||||
{ activeStatus: 'unrenderable', expected: 'attention', affects: true },
|
||||
{ activeStatus: 'high', expected: 'attention', affects: true },
|
||||
{ activeStatus: 'warning', expected: 'warning', affects: true },
|
||||
{ activeStatus: 'pass', expected: 'ok', affects: true },
|
||||
{ activeStatus: 'info', expected: 'ok', affects: true },
|
||||
] as const;
|
||||
|
||||
it.each(cases)('maps preflight status $status to $expected', ({ status, expected, affects }) => {
|
||||
const signal = preflightSignal({ status });
|
||||
it.each(cases)('maps preflight activeStatus $activeStatus to $expected', ({ activeStatus, expected, affects }) => {
|
||||
const signal = preflightSignal({ activeStatus });
|
||||
expect(signal.status).toBe(expected);
|
||||
expect(signal.affectsVerdict).toBe(affects);
|
||||
});
|
||||
@@ -135,11 +138,44 @@ describe('updatePreviewSignal', () => {
|
||||
expect(updatePreviewSignal(summary()).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('warns when only local build services need a rebuild', () => {
|
||||
const signal = updatePreviewSignal(summary({ rebuild_available: true, has_build_services: true }));
|
||||
expect(signal.status).toBe('warning');
|
||||
expect(signal.detail).toContain('rebuild');
|
||||
});
|
||||
|
||||
it('notes build services on a pending registry update', () => {
|
||||
const signal = updatePreviewSignal(summary({
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
has_build_services: true,
|
||||
}));
|
||||
expect(signal.detail).toContain('Local build services');
|
||||
});
|
||||
|
||||
it('degrades a preview failure to a non-verdict-affecting unknown', () => {
|
||||
expect(updatePreviewSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServicesSignal', () => {
|
||||
it('is ok when no build services are declared', () => {
|
||||
expect(buildServicesSignal([]).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('warns with service names when build services exist', () => {
|
||||
const signal = buildServicesSignal(['app', 'worker']);
|
||||
expect(signal.status).toBe('warning');
|
||||
expect(signal.affectsVerdict).toBe(false);
|
||||
expect(signal.detail).toContain('app, worker');
|
||||
});
|
||||
|
||||
it('degrades read failures to unknown', () => {
|
||||
expect(buildServicesSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupSlotSignal', () => {
|
||||
it('is ok with an existing backup and warns without one', () => {
|
||||
expect(backupSlotSignal({ exists: true, timestamp: NOW - 60_000 }, NOW).status).toBe('ok');
|
||||
@@ -182,10 +218,10 @@ describe('aggregateVerdict', () => {
|
||||
});
|
||||
|
||||
it('reaches every verdict from realistic signal sets', () => {
|
||||
expect(aggregateVerdict([preflightSignal({ status: 'blocker' }), driftSignal(0)])).toBe('blocked');
|
||||
expect(aggregateVerdict([preflightSignal({ status: 'high' }), driftSignal(0)])).toBe('review_required');
|
||||
expect(aggregateVerdict([preflightSignal({ activeStatus: 'blocker' }), driftSignal(0)])).toBe('blocked');
|
||||
expect(aggregateVerdict([preflightSignal({ activeStatus: 'high' }), driftSignal(0)])).toBe('review_required');
|
||||
expect(aggregateVerdict([containersSignal('error'), driftSignal(0)])).toBe('unknown');
|
||||
expect(aggregateVerdict([driftSignal(1), preflightSignal({ status: 'pass' })])).toBe('ready_with_warnings');
|
||||
expect(aggregateVerdict([driftSignal(0), preflightSignal({ status: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
|
||||
expect(aggregateVerdict([driftSignal(1), preflightSignal({ activeStatus: 'pass' })])).toBe('ready_with_warnings');
|
||||
expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,23 +135,25 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
|
||||
expect(report.stack).toBe('app');
|
||||
expect(report.signals.map(s => s.id)).toEqual([
|
||||
'preflight', 'drift', 'containers', 'healthchecks', 'update_preview', 'backup_slot', 'disk',
|
||||
'preflight', 'drift', 'containers', 'healthchecks', 'update_preview', 'build_services', 'backup_slot', 'disk',
|
||||
]);
|
||||
// The container probe failure is the verdict-affecting unknown.
|
||||
expect(report.verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('produces a ready verdict from healthy collaborator outputs', async () => {
|
||||
mockGetLatest.mockReturnValue({ status: 'pass' });
|
||||
mockGetLatest.mockReturnValue({ activeStatus: 'pass' });
|
||||
mockGetOpenDriftFindings.mockReturnValue([]);
|
||||
mockListContainers.mockResolvedValue([{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' }]);
|
||||
mockGetContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectResult()) });
|
||||
mockGetPreview.mockResolvedValue({
|
||||
stack_name: 'app',
|
||||
images: [],
|
||||
build_services: [],
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -168,9 +170,11 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () =>
|
||||
const preview = (images: Array<{ current_tag: string }>) => ({
|
||||
stack_name: 'app',
|
||||
images,
|
||||
build_services: [],
|
||||
summary: {
|
||||
has_update: false, primary_image: 'app', current_tag: images[0]?.current_tag ?? null,
|
||||
next_tag: null, semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
},
|
||||
rollback_target: 'app:1.2.3',
|
||||
changelog: null,
|
||||
|
||||
@@ -189,6 +189,26 @@ describe('buildSummary', () => {
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.primary_image).toBeNull();
|
||||
expect(preview.rollback_target).toBeNull();
|
||||
expect(preview.summary.has_build_services).toBe(false);
|
||||
expect(preview.summary.rebuild_available).toBe(false);
|
||||
});
|
||||
|
||||
it('flags rebuild_available for build-only stacks', () => {
|
||||
const preview = buildSummary('build-stack', [], ['app']);
|
||||
expect(preview.build_services).toEqual(['app']);
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.has_build_services).toBe(true);
|
||||
expect(preview.summary.rebuild_available).toBe(true);
|
||||
});
|
||||
|
||||
it('supports mixed image and build services', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('mixed', images, ['worker']);
|
||||
expect(preview.summary.has_update).toBe(true);
|
||||
expect(preview.summary.has_build_services).toBe(true);
|
||||
expect(preview.summary.rebuild_available).toBe(true);
|
||||
});
|
||||
|
||||
it('computes rollback target from current tag of primary', () => {
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface StackEnvSources {
|
||||
inlineEnvKeysByService: Record<string, string[]>;
|
||||
/** `${}` references found across the authored compose source. */
|
||||
interpolationRefs: InterpolationRef[];
|
||||
/** Concatenated authored compose file text (in-memory classification only). */
|
||||
authoredComposeText: string;
|
||||
}
|
||||
|
||||
interface EnvFileEntry {
|
||||
@@ -281,6 +283,7 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
|
||||
envFiles: [...byPath.values(), ...unresolved],
|
||||
inlineEnvKeysByService,
|
||||
interpolationRefs: parseInterpolationRefs(authoredText),
|
||||
authoredComposeText: authoredText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,57 @@ export function parseInterpolationRefs(source: string): InterpolationRef[] {
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
// Bare $VAR (no braces). The leading (?<!\$) skips Compose's $$ literal escape.
|
||||
const BARE_DOLLAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
/** Extract distinct bare `$VAR` references from authored compose text (not `${VAR}`). */
|
||||
export function parseBareDollarRefs(source: string): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const m of source.matchAll(BARE_DOLLAR_RE)) names.add(m[1]);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/** True when a key name follows env-var naming (UPPER_SNAKE), not compose fields like `command`. */
|
||||
function looksLikeEnvKey(key: string): boolean {
|
||||
return key === key.toUpperCase() && /[A-Z]/.test(key);
|
||||
}
|
||||
|
||||
/** Parse an inline environment key/value assignment from a compose source line. */
|
||||
function parseEnvAssignment(line: string): { keyName: string; valuePart: string } | null {
|
||||
const trimmed = line.trim();
|
||||
const listEq = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
||||
if (listEq) return { keyName: listEq[1], valuePart: listEq[2].trim() };
|
||||
const listColon = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/);
|
||||
if (listColon) return { keyName: listColon[1], valuePart: listColon[2].trim() };
|
||||
const mapEq = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
||||
if (mapEq && looksLikeEnvKey(mapEq[1])) return { keyName: mapEq[1], valuePart: mapEq[2].trim() };
|
||||
const mapColon = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s+(.*)$/);
|
||||
if (mapColon && looksLikeEnvKey(mapColon[1])) return { keyName: mapColon[1], valuePart: mapColon[2].trim() };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare `$VAR` references Compose is meant to interpolate: self-references
|
||||
* (`TOKEN=$TOKEN`), whole-value refs (`FOO=$BAR`), and refs outside env values.
|
||||
* Fragments embedded inside a literal value (e.g. bcrypt hashes) are excluded.
|
||||
*/
|
||||
export function parseIntentionalBareDollarRefs(source: string): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const assignment = parseEnvAssignment(line);
|
||||
if (assignment) {
|
||||
const { keyName, valuePart } = assignment;
|
||||
for (const m of valuePart.matchAll(BARE_DOLLAR_RE)) {
|
||||
const name = m[1];
|
||||
if (keyName === name || valuePart === `$${name}`) names.add(name);
|
||||
}
|
||||
} else {
|
||||
for (const m of line.matchAll(BARE_DOLLAR_RE)) names.add(m[1]);
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the KEY name from a single env-file line, or null for a blank/comment line.
|
||||
* Handles `KEY=value`, `export KEY=value`, and a bare `KEY` (value sourced from the
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Classify Compose stderr "unset variable" names into intentional references vs
|
||||
* spurious fragments from literal `$` sequences inside env values (e.g. bcrypt
|
||||
* hashes). Names only; env values are never read or returned.
|
||||
*/
|
||||
|
||||
import type { StackEnvSources } from './envFileResolution';
|
||||
import { parseIntentionalBareDollarRefs } from './envVarParse';
|
||||
import { isLikelySecretKey } from './secretClassification';
|
||||
|
||||
export interface LiteralDollarWarning {
|
||||
envKey?: string;
|
||||
likelySecret: boolean;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export interface UnsetEnvClassification {
|
||||
intentional: string[];
|
||||
literalDollar: LiteralDollarWarning[];
|
||||
}
|
||||
|
||||
function intentionalRefNames(envSources: StackEnvSources): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const ref of envSources.interpolationRefs) names.add(ref.name);
|
||||
for (const name of parseIntentionalBareDollarRefs(envSources.authoredComposeText)) names.add(name);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Parse an inline `environment:` key from a compose source line (names only). */
|
||||
function extractEnvKeyFromComposeLine(line: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
const listMatch = trimmed.match(/^-\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]/);
|
||||
if (listMatch) return listMatch[1];
|
||||
const mapMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/);
|
||||
if (mapMatch) return mapMatch[1];
|
||||
const eqMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (eqMatch) return eqMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function findServiceForEnvKey(
|
||||
inlineEnvKeysByService: Record<string, string[]>,
|
||||
envKey: string,
|
||||
): string | undefined {
|
||||
for (const [service, keys] of Object.entries(inlineEnvKeysByService)) {
|
||||
if (keys.includes(envKey)) return service;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function attributeFragmentToEnvKey(authoredText: string, fragment: string): string | null {
|
||||
const needle = `$${fragment}`;
|
||||
for (const line of authoredText.split(/\r?\n/)) {
|
||||
if (!line.includes(needle)) continue;
|
||||
const key = extractEnvKeyFromComposeLine(line);
|
||||
if (key) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split stderr unset names into intentional Compose variable references and
|
||||
* literal-dollar warnings safe to show in Doctor (no hash/secret fragments).
|
||||
*/
|
||||
export function classifyUnsetEnvVars(
|
||||
unsetNames: string[],
|
||||
envSources: StackEnvSources,
|
||||
envFileKeys: string[] = [],
|
||||
): UnsetEnvClassification {
|
||||
const intentionalSet = intentionalRefNames(envSources);
|
||||
const intentional: string[] = [];
|
||||
const spurious: string[] = [];
|
||||
for (const name of unsetNames) {
|
||||
if (intentionalSet.has(name)) intentional.push(name);
|
||||
else spurious.push(name);
|
||||
}
|
||||
|
||||
if (spurious.length === 0) {
|
||||
return { intentional, literalDollar: [] };
|
||||
}
|
||||
|
||||
const warnings = new Map<string, LiteralDollarWarning>();
|
||||
|
||||
const addWarning = (w: LiteralDollarWarning) => {
|
||||
const id = w.envKey ?? (w.likelySecret ? '__secret__' : '__generic__');
|
||||
if (!warnings.has(id)) warnings.set(id, w);
|
||||
};
|
||||
|
||||
for (const fragment of spurious) {
|
||||
const envKey = attributeFragmentToEnvKey(envSources.authoredComposeText, fragment);
|
||||
if (envKey) {
|
||||
addWarning({
|
||||
envKey,
|
||||
likelySecret: isLikelySecretKey(envKey),
|
||||
service: findServiceForEnvKey(envSources.inlineEnvKeysByService, envKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unattributed = spurious.some(f => !attributeFragmentToEnvKey(envSources.authoredComposeText, f));
|
||||
if (unattributed) {
|
||||
const secretFileKeys = envFileKeys.filter(isLikelySecretKey);
|
||||
if (secretFileKeys.length === 1) {
|
||||
const key = secretFileKeys[0];
|
||||
addWarning({ envKey: key, likelySecret: true });
|
||||
} else {
|
||||
addWarning({ likelySecret: secretFileKeys.length > 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return { intentional, literalDollar: [...warnings.values()] };
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export function startPilotAgent(loopbackPort: number): void {
|
||||
primaryUrl,
|
||||
loopbackPort,
|
||||
initialToken: persistedToken || enrollToken!,
|
||||
enrollToken: enrollToken ?? null,
|
||||
enrolling: !persistedToken,
|
||||
});
|
||||
// Register the agent as MeshService's reverse dialer so outbound
|
||||
@@ -80,13 +81,18 @@ interface AgentOptions {
|
||||
primaryUrl: string;
|
||||
loopbackPort: number;
|
||||
initialToken: string;
|
||||
enrollToken: string | null;
|
||||
enrolling: boolean;
|
||||
}
|
||||
|
||||
export class PilotAgent {
|
||||
private readonly options: AgentOptions;
|
||||
private token: string;
|
||||
/** Fallback credential from SENCHO_ENROLL_TOKEN when the persisted token is rejected. */
|
||||
private readonly enrollToken: string | null;
|
||||
private backoff = RECONNECT_MIN_MS;
|
||||
/** Set when the WS upgrade is rejected with 401 or 404 before the handshake completes. */
|
||||
private upgradeRejected = false;
|
||||
private ws: WebSocket | null = null;
|
||||
private pingTimer?: NodeJS.Timeout;
|
||||
private reconnectTimer?: NodeJS.Timeout;
|
||||
@@ -113,6 +119,7 @@ export class PilotAgent {
|
||||
constructor(options: AgentOptions) {
|
||||
this.options = options;
|
||||
this.token = options.initialToken;
|
||||
this.enrollToken = options.enrollToken;
|
||||
this.agentVersion = getSenchoVersion() || '0.0.0';
|
||||
this.customCa = readPilotCaBundle();
|
||||
}
|
||||
@@ -170,6 +177,8 @@ export class PilotAgent {
|
||||
private connect(): void {
|
||||
if (this.shuttingDown) return;
|
||||
|
||||
this.upgradeRejected = false;
|
||||
|
||||
const wsUrl = httpUrlToWs(this.options.primaryUrl) + '/api/pilot/tunnel';
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
@@ -187,6 +196,22 @@ export class PilotAgent {
|
||||
...(this.customCa ? { ca: this.customCa } : {}),
|
||||
});
|
||||
this.ws = ws;
|
||||
let opened = false;
|
||||
let lastHandshakeError: string | null = null;
|
||||
|
||||
// Do NOT register an 'unexpected-response' listener here. The ws library
|
||||
// skips abortHandshake (and therefore never emits 'error' or 'close')
|
||||
// when a listener exists for that event. Auth rejection is detected via
|
||||
// the error message abortHandshake emits: "Unexpected server response: N".
|
||||
ws.on('error', (err) => {
|
||||
console.warn('[Pilot] Tunnel error:', err.message);
|
||||
lastHandshakeError = err.message;
|
||||
if (/Unexpected server response: (401|404)/.test(err.message)) {
|
||||
this.upgradeRejected = true;
|
||||
}
|
||||
// 'close' will follow; reconnect is scheduled there.
|
||||
});
|
||||
|
||||
this.switchboard = attachTcpStreamSwitchboard({
|
||||
ws,
|
||||
resolveTarget: resolveByComposeLabels,
|
||||
@@ -195,6 +220,7 @@ export class PilotAgent {
|
||||
});
|
||||
|
||||
ws.on('open', () => {
|
||||
opened = true;
|
||||
// Backoff intentionally NOT reset here: a TCP-level connect that
|
||||
// immediately fails the protocol handshake (incompatible version,
|
||||
// bad token consumed at upgrade) would otherwise reset the
|
||||
@@ -222,12 +248,19 @@ export class PilotAgent {
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log('[Pilot] Tunnel closed:', code, reason?.toString?.() ?? '');
|
||||
this.cleanupAfterDisconnect();
|
||||
|
||||
const authRejected = this.upgradeRejected
|
||||
|| (!opened && lastHandshakeError != null && /Unexpected server response: (401|404)/.test(lastHandshakeError));
|
||||
if (authRejected && this.enrollToken && this.token !== this.enrollToken) {
|
||||
clearPersistedToken();
|
||||
console.log('[Pilot] Persisted token rejected; falling back to enroll token');
|
||||
this.token = this.enrollToken;
|
||||
this.backoff = RECONNECT_MIN_MS;
|
||||
}
|
||||
this.upgradeRejected = false;
|
||||
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
console.warn('[Pilot] Tunnel error:', err.message);
|
||||
// 'close' will follow; reconnect is scheduled there.
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupAfterDisconnect(): void {
|
||||
@@ -612,3 +645,23 @@ export function persistToken(token: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted long-lived tunnel token from disk. Used when the
|
||||
* control instance rejects the stored credential at upgrade so the agent
|
||||
* can fall back to SENCHO_ENROLL_TOKEN without re-poisoning on restart.
|
||||
* ENOENT is normal when no file was written yet.
|
||||
*
|
||||
* Exposed for unit tests.
|
||||
*/
|
||||
export function clearPersistedToken(): void {
|
||||
try {
|
||||
fs.unlinkSync(TOKEN_PATH);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return;
|
||||
console.warn(
|
||||
`[Pilot] Failed to remove persisted tunnel token at ${sanitizeForLog(TOKEN_PATH)} (${sanitizeForLog(code ?? 'unknown')}: ${sanitizeForLog((err as Error).message)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import {
|
||||
DatabaseService,
|
||||
@@ -114,7 +114,6 @@ function summarizeBlueprint(blueprintId: number) {
|
||||
}
|
||||
|
||||
blueprintsRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const blueprints = DatabaseService.getInstance().listBlueprints();
|
||||
const summaries = blueprints.map(b => {
|
||||
@@ -131,7 +130,6 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const body = req.body as BlueprintBody;
|
||||
@@ -171,7 +169,6 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
try {
|
||||
@@ -185,7 +182,6 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
@@ -258,7 +254,6 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -314,11 +309,10 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
// Node-to-node atomic blueprint apply. A hub posts here on the node that owns
|
||||
// the stack so the create / write compose+marker / deploy runs under that node's
|
||||
// per-stack lock (a remote node's lock is process-local and cannot be held by
|
||||
// the hub over separate HTTP calls). Gated by paid tier plus per-stack stack:edit
|
||||
// the hub over separate HTTP calls). Gated by per-stack stack:edit
|
||||
// and stack:deploy, the same permissions as the PUT-compose + deploy it bundles;
|
||||
// the node token the hub presents satisfies them.
|
||||
blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const body = (req.body ?? {}) as { stackName?: unknown; composeContent?: unknown; markerContent?: unknown };
|
||||
if (typeof body.stackName !== 'string' || !isValidStackName(body.stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
@@ -359,7 +353,6 @@ blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promi
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -379,7 +372,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -457,7 +449,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -487,7 +478,6 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
try {
|
||||
@@ -517,7 +507,6 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
@@ -558,7 +547,6 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/analyze', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const composeContent = typeof req.body?.compose_content === 'string' ? req.body.compose_content : '';
|
||||
if (!composeContent.trim()) {
|
||||
|
||||
@@ -245,6 +245,9 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) =>
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('A local node already exists')) {
|
||||
return res.status(409).json({ error: message });
|
||||
}
|
||||
if (message.includes('UNIQUE constraint')) {
|
||||
return res.status(409).json({ error: 'A node with that name already exists' });
|
||||
}
|
||||
@@ -308,6 +311,10 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
|
||||
updates.compose_dir = composeDir;
|
||||
}
|
||||
|
||||
if (updates.type !== undefined && !['local', 'remote'].includes(updates.type)) {
|
||||
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
|
||||
}
|
||||
|
||||
if (updates.api_url !== undefined && updates.api_url !== '') {
|
||||
const urlCheck = isValidRemoteUrl(updates.api_url);
|
||||
if (!urlCheck.valid) {
|
||||
@@ -348,9 +355,12 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
|
||||
})
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Node type cannot be changed')) {
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
console.error('Failed to update node:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to update node';
|
||||
res.status(500).json({ error: message });
|
||||
res.status(500).json({ error: message || 'Failed to update node' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -384,8 +394,12 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => {
|
||||
console.log(`[Nodes] Deleted node ${id} ("${sanitizeForLog(existing.name)}")`);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message.includes('Cannot delete the only local node')) {
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
console.error('Failed to delete node:', error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to delete node' });
|
||||
res.status(500).json({ error: message || 'Failed to delete node' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -393,7 +407,6 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
@@ -432,7 +445,6 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
|
||||
@@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
'env_block_deploy_on_missing_required',
|
||||
'image_update_sidebar_indicators',
|
||||
]);
|
||||
|
||||
// Keys whose write requires a paid license, not just an admin role.
|
||||
@@ -62,6 +63,7 @@ const SettingsPatchSchema = z.object({
|
||||
health_gate_enabled: z.enum(['0', '1']),
|
||||
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
|
||||
env_block_deploy_on_missing_required: z.enum(['0', '1']),
|
||||
image_update_sidebar_indicators: z.enum(['0', '1']),
|
||||
}).partial();
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -23,6 +23,9 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { RULE_IDS } from '../services/preflight/rules';
|
||||
import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
import { buildStorageInventory } from '../services/storage/inventory';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
@@ -1249,6 +1252,143 @@ stacksRouter.post('/:stackName/preflight/run', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
const PREFLIGHT_ACK_EXPIRY_MODES = new Set<PreflightAckExpiryMode>([
|
||||
'forever', 'until_compose_change', 'days', 'until_image_change',
|
||||
]);
|
||||
const PREFLIGHT_RULE_ID_SET = new Set(RULE_IDS);
|
||||
|
||||
stacksRouter.get('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
|
||||
const ctx = {
|
||||
renderedHash: run?.rendered_hash ?? null,
|
||||
serviceImages: parseServiceImages(run?.service_images ?? null),
|
||||
};
|
||||
const now = Date.now();
|
||||
const rows = DatabaseService.getInstance().getPreflightAcknowledgements(req.nodeId, stackName).map((ack) => ({
|
||||
...ack,
|
||||
active: isPreflightAckActive(ack, ctx, now),
|
||||
}));
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to list preflight acknowledgements for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to load preflight acknowledgements' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/preflight/acknowledgements', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const body = req.body ?? {};
|
||||
const ruleId = typeof body.ruleId === 'string' ? body.ruleId.trim() : '';
|
||||
if (!PREFLIGHT_RULE_ID_SET.has(ruleId)) {
|
||||
res.status(400).json({ error: 'ruleId must be a known Compose Doctor rule id' });
|
||||
return;
|
||||
}
|
||||
const serviceRaw = body.service == null || body.service === ''
|
||||
? null
|
||||
: String(body.service).trim();
|
||||
if (serviceRaw !== null && !isValidServiceName(serviceRaw)) {
|
||||
res.status(400).json({ error: 'service must be a valid service name' });
|
||||
return;
|
||||
}
|
||||
const expiryMode = typeof body.expiryMode === 'string' ? body.expiryMode.trim() : 'forever';
|
||||
if (!PREFLIGHT_ACK_EXPIRY_MODES.has(expiryMode as PreflightAckExpiryMode)) {
|
||||
res.status(400).json({ error: 'expiryMode must be forever, until_compose_change, days, or until_image_change' });
|
||||
return;
|
||||
}
|
||||
if (expiryMode === 'until_image_change' && !serviceRaw) {
|
||||
res.status(400).json({ error: 'until_image_change requires a specific service' });
|
||||
return;
|
||||
}
|
||||
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
|
||||
if (reason.length > 2000) {
|
||||
res.status(400).json({ error: 'reason is too long' });
|
||||
return;
|
||||
}
|
||||
let expiresAt: number | null = null;
|
||||
if (expiryMode === 'days') {
|
||||
const days = Number(body.expiresInDays ?? 30);
|
||||
if (!Number.isFinite(days) || days <= 0 || days > 3650) {
|
||||
res.status(400).json({ error: 'expiresInDays must be between 1 and 3650' });
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + Math.round(days * 86_400_000);
|
||||
}
|
||||
const run = DatabaseService.getInstance().getLatestPreflightRun(req.nodeId, stackName);
|
||||
if (!run) {
|
||||
res.status(400).json({ error: 'Run Compose Doctor before acknowledging a finding' });
|
||||
return;
|
||||
}
|
||||
const serviceImages = parseServiceImages(run.service_images);
|
||||
let anchorRenderedHash: string | null = null;
|
||||
let anchorImageRef: string | null = null;
|
||||
if (expiryMode === 'until_compose_change') {
|
||||
if (!run.rendered_hash) {
|
||||
res.status(400).json({ error: 'The latest preflight run has no compose fingerprint to anchor against' });
|
||||
return;
|
||||
}
|
||||
anchorRenderedHash = run.rendered_hash;
|
||||
}
|
||||
if (expiryMode === 'until_image_change') {
|
||||
const imageRef = serviceImages[serviceRaw!] ?? null;
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'The latest preflight run has no image reference for that service' });
|
||||
return;
|
||||
}
|
||||
anchorImageRef = imageRef;
|
||||
}
|
||||
try {
|
||||
const ack = DatabaseService.getInstance().upsertPreflightAcknowledgement({
|
||||
node_id: req.nodeId,
|
||||
stack_name: stackName,
|
||||
rule_id: ruleId,
|
||||
service: serviceRaw,
|
||||
reason,
|
||||
expiry_mode: expiryMode as PreflightAckExpiryMode,
|
||||
expires_at: expiresAt,
|
||||
anchor_rendered_hash: anchorRenderedHash,
|
||||
anchor_image_ref: anchorImageRef,
|
||||
created_by: req.user?.username ?? 'unknown',
|
||||
created_at: Date.now(),
|
||||
});
|
||||
res.status(201).json(ack);
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to create preflight acknowledgement for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to create preflight acknowledgement' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.delete('/:stackName/preflight/acknowledgements/:id', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid acknowledgement id' });
|
||||
return;
|
||||
}
|
||||
const existing = DatabaseService.getInstance().getPreflightAcknowledgement(id);
|
||||
if (!existing || existing.node_id !== req.nodeId || existing.stack_name !== stackName) {
|
||||
res.status(404).json({ error: 'Acknowledgement not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
DatabaseService.getInstance().deletePreflightAcknowledgement(id);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to delete preflight acknowledgement for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to delete preflight acknowledgement' });
|
||||
}
|
||||
});
|
||||
|
||||
// Compose Network Inspector: per-stack networking facts (network map, service
|
||||
// membership, published ports/bindings, network_mode, extra_hosts, runtime
|
||||
// drift) derived from the authored effective model + live snapshot. Read-only
|
||||
|
||||
@@ -15,12 +15,14 @@ import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './pref
|
||||
import type {
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
|
||||
} from './preflight/types';
|
||||
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';
|
||||
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification';
|
||||
|
||||
const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error
|
||||
|
||||
@@ -40,6 +42,55 @@ function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
function activeFields(
|
||||
renderable: boolean,
|
||||
findings: PreflightFinding[],
|
||||
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
|
||||
const active = findings.filter(f => !f.acknowledged);
|
||||
const acknowledgedCount = findings.length - active.length;
|
||||
const activeHighestSeverity = highestOf(active);
|
||||
const activeStatus: PreflightStatus = !renderable
|
||||
? 'unrenderable'
|
||||
: (activeHighestSeverity ?? 'pass');
|
||||
return {
|
||||
activeStatus,
|
||||
activeHighestSeverity,
|
||||
activeCount: active.length,
|
||||
acknowledgedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildServiceImages(model: EffectiveModel | null): string | null {
|
||||
if (!model) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const svc of model.services) {
|
||||
if (svc.image) map[svc.name] = svc.image;
|
||||
}
|
||||
return Object.keys(map).length > 0 ? JSON.stringify(map) : null;
|
||||
}
|
||||
|
||||
function enrichReport(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
report: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
|
||||
): PreflightReport {
|
||||
const db = DatabaseService.getInstance();
|
||||
const acks = db.getPreflightAcknowledgements(nodeId, stackName);
|
||||
const serviceImages = parseServiceImages(
|
||||
db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null,
|
||||
);
|
||||
const findings = applyPreflightAcknowledgements(
|
||||
report.findings,
|
||||
{ renderedHash: report.renderedHash, serviceImages },
|
||||
acks,
|
||||
);
|
||||
return {
|
||||
...report,
|
||||
findings,
|
||||
...activeFields(report.renderable, findings),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose Doctor: renders the effective model and runs the deterministic
|
||||
* preflight rule registry against the active node. Advisory only (it never
|
||||
@@ -81,6 +132,7 @@ export class ComposeDoctorService {
|
||||
const findings = sortFindings(runRules(ctx));
|
||||
const highestSeverity = highestOf(findings);
|
||||
const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
|
||||
const serviceImages = buildServiceImages(ctx.model);
|
||||
|
||||
const report: PreflightReport = {
|
||||
stack: stackName,
|
||||
@@ -93,9 +145,13 @@ export class ComposeDoctorService {
|
||||
sourceHash: hashes.sourceHash,
|
||||
renderedHash: hashes.renderedHash,
|
||||
findings,
|
||||
activeStatus: status,
|
||||
activeHighestSeverity: highestSeverity,
|
||||
activeCount: findings.length,
|
||||
acknowledgedCount: 0,
|
||||
};
|
||||
this.persist(nodeId, report);
|
||||
return report;
|
||||
this.persist(nodeId, report, serviceImages);
|
||||
return enrichReport(nodeId, stackName, report);
|
||||
}
|
||||
|
||||
/** Read the last stored run for a stack, mapped to the report shape. */
|
||||
@@ -106,6 +162,7 @@ export class ComposeDoctorService {
|
||||
return {
|
||||
stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
|
||||
status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
|
||||
activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0,
|
||||
};
|
||||
}
|
||||
const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
|
||||
@@ -120,7 +177,7 @@ export class ComposeDoctorService {
|
||||
const renderable = run.status !== 'unrenderable';
|
||||
// The render error is carried by the render-failed finding, not a column.
|
||||
const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
|
||||
return {
|
||||
const base: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
|
||||
stack: stackName,
|
||||
ranAt: run.created_at,
|
||||
ranBy: run.created_by,
|
||||
@@ -132,6 +189,7 @@ export class ComposeDoctorService {
|
||||
renderedHash: run.rendered_hash,
|
||||
findings,
|
||||
};
|
||||
return enrichReport(nodeId, stackName, base);
|
||||
}
|
||||
|
||||
private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
|
||||
@@ -142,13 +200,43 @@ export class ComposeDoctorService {
|
||||
let renderError: string | null = null;
|
||||
let model: EffectiveModel | null = null;
|
||||
let unsetEnvVars: string[] = [];
|
||||
let literalDollarWarnings: LiteralDollarWarning[] = [];
|
||||
let missingEnvFiles: MissingEnvFile[] = [];
|
||||
|
||||
let envSources: Awaited<ReturnType<typeof resolveStackEnvSources>> | null = null;
|
||||
try {
|
||||
envSources = await resolveStackEnvSources(nodeId, stackName);
|
||||
missingEnvFiles = envSources.envFiles
|
||||
.filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
|
||||
.map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
|
||||
} catch (err) {
|
||||
console.warn('[ComposeDoctor] env-file resolution failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (result.rendered !== null) {
|
||||
// Unset-variable warnings come from stderr and do not depend on the
|
||||
// model parsing, so capture them before attempting the parse, so a parse
|
||||
// failure does not also suppress the env-unset findings.
|
||||
unsetEnvVars = parseUnsetEnvVars(result.stderr);
|
||||
const rawUnset = parseUnsetEnvVars(result.stderr);
|
||||
if (envSources) {
|
||||
const envFileKeys: string[] = [];
|
||||
for (const file of envSources.envFiles) {
|
||||
if (!file.resolvedPath || file.existence !== 'present') continue;
|
||||
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir);
|
||||
if (!unverifiable) envFileKeys.push(...keys);
|
||||
}
|
||||
const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys);
|
||||
unsetEnvVars = classified.intentional;
|
||||
literalDollarWarnings = classified.literalDollar;
|
||||
} else {
|
||||
// Without authored env context, never surface raw stderr names as unset
|
||||
// variables; they may be literal-dollar fragments from secret values.
|
||||
unsetEnvVars = [];
|
||||
literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : [];
|
||||
}
|
||||
try {
|
||||
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
renderable = true;
|
||||
@@ -179,20 +267,6 @@ export class ComposeDoctorService {
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName);
|
||||
|
||||
// Required `env_file:` declarations whose file is absent. Optional
|
||||
// (required: false) and interpolated/escaping paths are excluded. Fail-soft:
|
||||
// a resolution error simply yields no env-file findings.
|
||||
let missingEnvFiles: MissingEnvFile[] = [];
|
||||
try {
|
||||
const envSources = await resolveStackEnvSources(nodeId, stackName);
|
||||
missingEnvFiles = envSources.envFiles
|
||||
.filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
|
||||
.map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
|
||||
} catch (err) {
|
||||
console.warn('[ComposeDoctor] env-file resolution failed for %s:',
|
||||
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
|
||||
}
|
||||
|
||||
return {
|
||||
stackName,
|
||||
platform: process.platform,
|
||||
@@ -200,6 +274,7 @@ export class ComposeDoctorService {
|
||||
renderable,
|
||||
renderError,
|
||||
unsetEnvVars,
|
||||
literalDollarWarnings,
|
||||
missingEnvFiles,
|
||||
sourceServiceNames,
|
||||
sourceReadable,
|
||||
@@ -305,7 +380,7 @@ export class ComposeDoctorService {
|
||||
}
|
||||
|
||||
/** Persist the run, replacing any prior run for this stack. Best-effort. */
|
||||
private persist(nodeId: number, report: PreflightReport): void {
|
||||
private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void {
|
||||
if (report.ranAt === null) return;
|
||||
try {
|
||||
const runId = randomUUID();
|
||||
@@ -316,6 +391,7 @@ export class ComposeDoctorService {
|
||||
stack_name: report.stack,
|
||||
source_hash: report.sourceHash,
|
||||
rendered_hash: report.renderedHash,
|
||||
service_images: serviceImages,
|
||||
status: report.status,
|
||||
highest_severity: report.highestSeverity,
|
||||
created_at: report.ranAt,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { MeshService } from './MeshService';
|
||||
import { LogFormatter } from './LogFormatter';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { DriftLedgerService } from './DriftLedgerService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { parseEffectiveModel } from './preflight/effectiveModel';
|
||||
import { deriveStackExposure } from './preflight/exposure';
|
||||
|
||||
@@ -22,8 +22,9 @@ import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
|
||||
import { loadStackBuildServices } from './ImageUpdateService';
|
||||
|
||||
export class ComposeRollbackError extends Error {
|
||||
public readonly rollbackAttempted: boolean;
|
||||
@@ -397,7 +398,7 @@ export class ComposeService {
|
||||
* no env value is materialized. Default off and any settings-read failure both
|
||||
* fall through without blocking.
|
||||
*/
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
|
||||
@@ -413,49 +414,49 @@ export class ComposeService {
|
||||
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
|
||||
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
|
||||
if (process.env.SENCHO_MODE !== 'pilot') return;
|
||||
|
||||
let mounts: Array<{ source: string; destination: string }> | null;
|
||||
try {
|
||||
mounts = await SelfIdentityService.getInstance().getBindMounts();
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
if (mounts === null) return;
|
||||
|
||||
const composeDir = path.resolve(this.baseDir);
|
||||
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
|
||||
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
|
||||
|
||||
const rendered = await this.renderConfig(stackName);
|
||||
if (rendered.rendered === null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rendered.rendered);
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
const model = parseEffectiveModel(parsed, stackName);
|
||||
const unsafeBind = model.services
|
||||
.flatMap((service) => service.binds)
|
||||
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
|
||||
if (!unsafeBind) return;
|
||||
|
||||
throw new Error(
|
||||
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
|
||||
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
}
|
||||
|
||||
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
|
||||
if (process.env.SENCHO_MODE !== 'pilot') return;
|
||||
|
||||
let mounts: Array<{ source: string; destination: string }> | null;
|
||||
try {
|
||||
mounts = await SelfIdentityService.getInstance().getBindMounts();
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
if (mounts === null) return;
|
||||
|
||||
const composeDir = path.resolve(this.baseDir);
|
||||
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
|
||||
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
|
||||
|
||||
const rendered = await this.renderConfig(stackName);
|
||||
if (rendered.rendered === null) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rendered.rendered);
|
||||
} catch (error) {
|
||||
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
return;
|
||||
}
|
||||
const model = parseEffectiveModel(parsed, stackName);
|
||||
const unsafeBind = model.services
|
||||
.flatMap((service) => service.binds)
|
||||
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
|
||||
if (!unsafeBind) return;
|
||||
|
||||
throw new Error(
|
||||
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
|
||||
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -471,10 +472,10 @@ export class ComposeService {
|
||||
try {
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(this.nodeId);
|
||||
const legacyContainers = await dockerController.getContainersByStack(stackName);
|
||||
if (legacyContainers && legacyContainers.length > 0) {
|
||||
sendOutput(`=== Cleaning up existing containers for clean deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
const legacyOrphans = await dockerController.getLegacyOrphanContainersByStack(stackName);
|
||||
if (legacyOrphans.length > 0) {
|
||||
sendOutput(`=== Cleaning up legacy orphan containers before deployment ===\n`);
|
||||
await dockerController.removeContainers(legacyOrphans.map((c) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
|
||||
@@ -647,9 +648,9 @@ export class ComposeService {
|
||||
startStream();
|
||||
}
|
||||
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
|
||||
await this.assertRequiredEnvPresent(stackName);
|
||||
await this.assertSafePilotBindMapping(stackName);
|
||||
const stackDir = path.join(this.baseDir, stackName);
|
||||
const debug = isDebugEnabled();
|
||||
const t0 = Date.now();
|
||||
@@ -674,9 +675,20 @@ export class ComposeService {
|
||||
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
|
||||
const buildServices = await loadStackBuildServices(this.nodeId, stackName);
|
||||
const buildAware = buildServices.length > 0;
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
if (buildAware) {
|
||||
sendOutput('=== Building images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['build', '--pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
sendOutput('=== Pulling registry images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull', '--ignore-buildable']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
} else {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['pull']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface PreflightRunRow {
|
||||
stack_name: string;
|
||||
source_hash: string | null;
|
||||
rendered_hash: string | null;
|
||||
/** JSON map of service name to image ref at run time (for until_image_change acks). */
|
||||
service_images: string | null;
|
||||
status: string;
|
||||
highest_severity: string | null;
|
||||
created_at: number;
|
||||
@@ -168,6 +170,24 @@ export interface PreflightFindingRow {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
|
||||
/** Operator acknowledgement of a specific Compose Doctor finding on a stack. */
|
||||
export interface PreflightAcknowledgement {
|
||||
id: number;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
rule_id: string;
|
||||
service: string | null;
|
||||
reason: string;
|
||||
expiry_mode: PreflightAckExpiryMode;
|
||||
expires_at: number | null;
|
||||
anchor_rendered_hash: string | null;
|
||||
anchor_image_ref: string | null;
|
||||
created_by: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** A persisted drift finding: one service-scoped divergence, open until resolved. */
|
||||
export interface StackDriftFindingRow {
|
||||
id: number;
|
||||
@@ -1470,6 +1490,26 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_findings_run
|
||||
ON preflight_findings(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS preflight_acknowledgements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
service TEXT,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
expiry_mode TEXT NOT NULL DEFAULT 'forever'
|
||||
CHECK (expiry_mode IN ('forever','until_compose_change','days','until_image_change')),
|
||||
expires_at INTEGER,
|
||||
anchor_rendered_hash TEXT,
|
||||
anchor_image_ref TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_preflight_ack_unique
|
||||
ON preflight_acknowledgements(node_id, stack_name, rule_id, COALESCE(service,''));
|
||||
CREATE INDEX IF NOT EXISTS idx_preflight_ack_stack
|
||||
ON preflight_acknowledgements(node_id, stack_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_exposure (
|
||||
node_id INTEGER NOT NULL DEFAULT 0,
|
||||
stack_name TEXT NOT NULL,
|
||||
@@ -1583,6 +1623,8 @@ export class DatabaseService {
|
||||
maybeAddCol('cve_suppressions', 'status', "TEXT NOT NULL DEFAULT 'accepted'");
|
||||
maybeAddCol('cve_suppressions', 'justification', 'TEXT');
|
||||
|
||||
maybeAddCol('preflight_runs', 'service_images', 'TEXT');
|
||||
|
||||
// Scheduled operations migrations
|
||||
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
@@ -1665,6 +1707,7 @@ export class DatabaseService {
|
||||
stmt.run('image_update_check_interval_minutes', '120');
|
||||
stmt.run('image_update_check_mode', 'interval');
|
||||
stmt.run('image_update_check_cron', '');
|
||||
stmt.run('image_update_sidebar_indicators', '1');
|
||||
stmt.run('env_block_deploy_on_missing_required', '0');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
@@ -1674,6 +1717,36 @@ export class DatabaseService {
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
|
||||
}
|
||||
|
||||
this.logLocalNodeWarnings();
|
||||
}
|
||||
|
||||
/** Count nodes with type='local'. */
|
||||
public getLocalNodeCount(): number {
|
||||
const row = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE type = 'local'").get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warnings when the local-node count is not exactly one. Extracted as a
|
||||
* public method so tests can drive it against known database states without
|
||||
* re-running the full schema migration.
|
||||
*/
|
||||
public logLocalNodeWarnings(): void {
|
||||
const count = this.getLocalNodeCount();
|
||||
if (count > 1) {
|
||||
console.warn(
|
||||
`[Startup] Found ${count} local nodes (expected 1). ` +
|
||||
'Extra local nodes can be removed in Settings → Nodes. ' +
|
||||
'Deleting a local node removes its schedules, labels, dossiers, ' +
|
||||
'and other node-scoped data; containers on the host are not affected.'
|
||||
);
|
||||
} else if (count === 0) {
|
||||
console.warn(
|
||||
'[Startup] No local node found. ' +
|
||||
'Create one in Settings → Nodes to manage this instance\'s Docker engine.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAdminToUsersTable(): void {
|
||||
@@ -2883,9 +2956,12 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ? AND stack_name = ?').run(run.node_id, run.stack_name);
|
||||
this.db.prepare(
|
||||
`INSERT INTO preflight_runs
|
||||
(id, node_id, stack_name, source_hash, rendered_hash, status, highest_severity, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash, run.status, run.highest_severity, run.created_at, run.created_by);
|
||||
(id, node_id, stack_name, source_hash, rendered_hash, service_images, status, highest_severity, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
run.id, run.node_id, run.stack_name, run.source_hash, run.rendered_hash,
|
||||
run.service_images ?? null, run.status, run.highest_severity, run.created_at, run.created_by,
|
||||
);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO preflight_findings
|
||||
(id, run_id, rule_id, severity, title, message, source_path, remediation, service, created_at)
|
||||
@@ -2911,6 +2987,55 @@ export class DatabaseService {
|
||||
).all(runId) as PreflightFindingRow[];
|
||||
}
|
||||
|
||||
public getPreflightAcknowledgements(nodeId: number, stackName: string): PreflightAcknowledgement[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM preflight_acknowledgements WHERE node_id = ? AND stack_name = ? ORDER BY created_at DESC, id DESC',
|
||||
).all(nodeId, stackName) as PreflightAcknowledgement[];
|
||||
}
|
||||
|
||||
public getPreflightAcknowledgement(id: number): PreflightAcknowledgement | null {
|
||||
return (
|
||||
(this.db.prepare('SELECT * FROM preflight_acknowledgements WHERE id = ?')
|
||||
.get(id) as PreflightAcknowledgement | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public upsertPreflightAcknowledgement(
|
||||
ack: Omit<PreflightAcknowledgement, 'id'>,
|
||||
): PreflightAcknowledgement {
|
||||
const existing = this.db.prepare(
|
||||
`SELECT id FROM preflight_acknowledgements
|
||||
WHERE node_id = ? AND stack_name = ? AND rule_id = ? AND COALESCE(service, '') = COALESCE(?, '')`,
|
||||
).get(ack.node_id, ack.stack_name, ack.rule_id, ack.service) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
this.db.prepare(
|
||||
`UPDATE preflight_acknowledgements
|
||||
SET reason = ?, expiry_mode = ?, expires_at = ?, anchor_rendered_hash = ?,
|
||||
anchor_image_ref = ?, created_by = ?, created_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
ack.reason, ack.expiry_mode, ack.expires_at, ack.anchor_rendered_hash,
|
||||
ack.anchor_image_ref, ack.created_by, ack.created_at, existing.id,
|
||||
);
|
||||
return this.getPreflightAcknowledgement(existing.id)!;
|
||||
}
|
||||
const result = this.db.prepare(
|
||||
`INSERT INTO preflight_acknowledgements
|
||||
(node_id, stack_name, rule_id, service, reason, expiry_mode, expires_at,
|
||||
anchor_rendered_hash, anchor_image_ref, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
ack.node_id, ack.stack_name, ack.rule_id, ack.service, ack.reason, ack.expiry_mode,
|
||||
ack.expires_at, ack.anchor_rendered_hash, ack.anchor_image_ref, ack.created_by, ack.created_at,
|
||||
);
|
||||
return { ...ack, id: result.lastInsertRowid as number };
|
||||
}
|
||||
|
||||
public deletePreflightAcknowledgement(id: number): boolean {
|
||||
const result = this.db.prepare('DELETE FROM preflight_acknowledgements WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// --- Health Gate Runs ---
|
||||
|
||||
public insertHealthGateRun(run: HealthGateRunRow): void {
|
||||
@@ -3247,24 +3372,38 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode' | 'cordoned' | 'cordoned_at' | 'cordoned_reason'> & { mode?: NodeMode }): number {
|
||||
if (node.is_default) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
const isLocal = node.type === 'local';
|
||||
// Guard against duplicate local nodes before any mutation so a throw
|
||||
// cannot leave the table in a broken state (e.g. default cleared).
|
||||
if (isLocal && this.getLocalNodeCount() > 0) {
|
||||
throw new Error('A local node already exists. Only one local node is allowed per instance.');
|
||||
}
|
||||
const crypto = CryptoService.getInstance();
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(
|
||||
node.name,
|
||||
node.type,
|
||||
node.compose_dir || '/app/compose',
|
||||
node.is_default ? 1 : 0,
|
||||
'unknown',
|
||||
Date.now(),
|
||||
node.api_url || '',
|
||||
node.api_token ? crypto.encrypt(node.api_token) : '',
|
||||
node.mode || 'proxy'
|
||||
);
|
||||
|
||||
// When creating a local node and none exists (zero-local recovery),
|
||||
// make it the default so documentation remains accurate.
|
||||
const shouldBeDefault = node.is_default || isLocal;
|
||||
|
||||
const runInsert = () => {
|
||||
if (shouldBeDefault) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
const crypto = CryptoService.getInstance();
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
return stmt.run(
|
||||
node.name,
|
||||
node.type,
|
||||
node.compose_dir || '/app/compose',
|
||||
shouldBeDefault ? 1 : 0,
|
||||
'unknown',
|
||||
Date.now(),
|
||||
node.api_url || '',
|
||||
node.api_token ? crypto.encrypt(node.api_token) : '',
|
||||
node.mode || 'proxy'
|
||||
);
|
||||
};
|
||||
const result = this.db.transaction(runInsert)();
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
@@ -3272,6 +3411,10 @@ export class DatabaseService {
|
||||
const node = this.getNode(id);
|
||||
if (!node) throw new Error(`Node with id ${id} not found`);
|
||||
|
||||
if (updates.type !== undefined && updates.type !== node.type) {
|
||||
throw new Error('Node type cannot be changed after creation.');
|
||||
}
|
||||
|
||||
if (updates.is_default) {
|
||||
this.db.prepare('UPDATE nodes SET is_default = 0').run();
|
||||
}
|
||||
@@ -3301,6 +3444,10 @@ export class DatabaseService {
|
||||
|
||||
public deleteNode(id: number): void {
|
||||
const node = this.getNode(id);
|
||||
// Protect the last local node regardless of is_default flag.
|
||||
if (node && node.type === 'local' && this.getLocalNodeCount() <= 1) {
|
||||
throw new Error('Cannot delete the only local node. Each Sencho instance must retain its local identity.');
|
||||
}
|
||||
if (node?.is_default) {
|
||||
throw new Error('Cannot delete the default node');
|
||||
}
|
||||
@@ -3315,6 +3462,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM stack_exposure_intent WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_findings WHERE run_id IN (SELECT id FROM preflight_runs WHERE node_id = ?)').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
||||
|
||||
@@ -16,6 +16,16 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
|
||||
/** Parsed row from `docker compose ps --format json`. */
|
||||
interface ComposePsContainer {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Service?: string;
|
||||
State?: string;
|
||||
Status?: string;
|
||||
Publishers?: { URL?: string; TargetPort?: number; PublishedPort?: number; Protocol?: string }[];
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
|
||||
@@ -1460,6 +1470,70 @@ class DockerController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers visible to `docker compose ps` for this stack. Empty when Compose
|
||||
* does not manage any containers (including first deploy or mislabeled legacy).
|
||||
*/
|
||||
private async fetchComposePsContainers(stackName: string, stackDir: string): Promise<ComposePsContainer[]> {
|
||||
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId);
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'docker',
|
||||
['compose', ...filePrefix, ...envFileArgs, 'ps', '--format', 'json', '-a'],
|
||||
{
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let containers: ComposePsContainer[] = [];
|
||||
if (stdout && stdout.trim() !== '') {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
containers = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (parseError) {
|
||||
try {
|
||||
const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
|
||||
containers = lines.map(line => JSON.parse(line) as ComposePsContainer);
|
||||
} catch (innerError) {
|
||||
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy orphan containers that Compose ps cannot see but would block a deploy
|
||||
* (wrong project labels). When Compose already manages the stack, returns [] so
|
||||
* deploy can rely on selective `compose up` recreation.
|
||||
*/
|
||||
public async getLegacyOrphanContainersByStack(stackName: string): Promise<Array<{ Id: string }>> {
|
||||
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
|
||||
const toIds = (list: Array<{ Id?: string }>) =>
|
||||
list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0)
|
||||
.map((c) => ({ Id: c.Id }));
|
||||
|
||||
try {
|
||||
const composeContainers = await this.fetchComposePsContainers(stackName, stackDir);
|
||||
if (composeContainers.length > 0) return [];
|
||||
return toIds(await this.smartFallback(stackName, stackDir));
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & { stderr?: string };
|
||||
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
|
||||
const detail = execError.stderr || mapped.message;
|
||||
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
|
||||
try {
|
||||
return toIds(await this.smartFallback(stackName, stackDir));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getContainersByStack(stackName: string) {
|
||||
// Resolve the compose dir and the authored prefix for THIS controller's node,
|
||||
// not the process default, so a non-default local node sees its own stack dir
|
||||
@@ -1467,55 +1541,7 @@ class DockerController {
|
||||
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
|
||||
|
||||
try {
|
||||
// Splice the authored multi-file prefix (-f files + -p + --project-directory)
|
||||
// so a Git stack's override-only services are listed; single-file stacks get an
|
||||
// empty prefix and behave exactly as before. execFile avoids shell quoting on
|
||||
// the absolute --project-directory path.
|
||||
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
|
||||
const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId);
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'docker',
|
||||
['compose', ...filePrefix, ...envFileArgs, 'ps', '--format', 'json', '-a'],
|
||||
{
|
||||
cwd: stackDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
|
||||
// Docker Compose v2 may return either format depending on version
|
||||
interface ComposeContainer {
|
||||
ID?: string;
|
||||
Name?: string;
|
||||
Service?: string;
|
||||
State?: string;
|
||||
Status?: string;
|
||||
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number, Protocol?: string }[];
|
||||
}
|
||||
|
||||
let containers: ComposeContainer[] = [];
|
||||
|
||||
// Only parse if stdout has content
|
||||
if (stdout && stdout.trim() !== '') {
|
||||
try {
|
||||
// Try parsing as a standard JSON array
|
||||
const parsed = JSON.parse(stdout);
|
||||
containers = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch (parseError) {
|
||||
// Fallback: parse newline-separated JSON objects, filtering out empty lines
|
||||
try {
|
||||
const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
|
||||
containers = lines.map(line => JSON.parse(line) as ComposeContainer);
|
||||
} catch (innerError) {
|
||||
// Log parsing failure with stderr for debugging
|
||||
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
|
||||
// Don't return empty - trigger smart fallback below
|
||||
}
|
||||
}
|
||||
}
|
||||
const containers = await this.fetchComposePsContainers(stackName, stackDir);
|
||||
|
||||
// If containers found via docker compose ps, return them
|
||||
if (containers.length > 0) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ComposeService } from './ComposeService';
|
||||
import { parseEffectiveModel } from './preflight/effectiveModel';
|
||||
import { resolveStackEnvSources, type EnvFileExistence } from '../helpers/envFileResolution';
|
||||
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
|
||||
import { classifyUnsetEnvVars } from '../helpers/unsetEnvClassification';
|
||||
import { isLikelySecretKey } from '../helpers/secretClassification';
|
||||
|
||||
export type EnvSource = 'compose-inline' | 'env-file' | 'dotenv' | 'process-env' | 'compose-ref';
|
||||
@@ -78,7 +79,14 @@ export async function buildEnvInventory(nodeId: number, stackName: string): Prom
|
||||
const effectiveKeys = new Set<string>();
|
||||
const effectiveKeysByService = new Map<string, Set<string>>();
|
||||
if (result.rendered !== null) {
|
||||
unsetVars = new Set(parseUnsetEnvVars(result.stderr));
|
||||
const rawUnset = parseUnsetEnvVars(result.stderr);
|
||||
const envFileKeys: string[] = [];
|
||||
for (const file of sources.envFiles) {
|
||||
if (!file.resolvedPath || file.existence !== 'present') continue;
|
||||
const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, sources.baseDir);
|
||||
if (!unverifiable) envFileKeys.push(...keys);
|
||||
}
|
||||
unsetVars = new Set(classifyUnsetEnvVars(rawUnset, sources, envFileKeys).intentional);
|
||||
try {
|
||||
const model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
|
||||
for (const svc of model.services) {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface ImageUpdateStatus {
|
||||
manualCooldownRemainingMs: number;
|
||||
mode: 'interval' | 'cron';
|
||||
cronExpression: string | null;
|
||||
sidebarIndicators: boolean;
|
||||
}
|
||||
|
||||
// ─── Compose file helpers ────────────────────────────────────────────────────
|
||||
@@ -165,6 +166,83 @@ export async function loadEffectiveServiceImages(nodeId: number, stackName: stri
|
||||
return extractServiceImagesFromRenderedConfig(rendered.rendered);
|
||||
}
|
||||
|
||||
/** True when a service declares a non-empty `build:` section (string path or object). */
|
||||
function serviceHasBuild(build: unknown): boolean {
|
||||
if (build === undefined || build === null) return false;
|
||||
if (typeof build === 'string') return build.trim().length > 0;
|
||||
if (typeof build === 'object') return Object.keys(build as Record<string, unknown>).length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Service names that declare `build:` in raw compose YAML (single-file path). */
|
||||
export function extractBuildServicesFromCompose(yamlContent: string): string[] {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
||||
|
||||
const out: string[] = [];
|
||||
for (const [service, svc] of Object.entries(parsed.services as Record<string, unknown>)) {
|
||||
if (!svc || typeof svc !== 'object') continue;
|
||||
if (serviceHasBuild((svc as Record<string, unknown>).build)) {
|
||||
out.push(service);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names with a `build:` section from a `docker compose config --format json`
|
||||
* render (merged + interpolated; no env substitution needed).
|
||||
*/
|
||||
export function extractBuildServicesFromRenderedConfig(renderedJson: string): string[] {
|
||||
let parsed: { services?: Record<string, { build?: unknown }> };
|
||||
try {
|
||||
parsed = JSON.parse(renderedJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
||||
const out: string[] = [];
|
||||
for (const [service, svc] of Object.entries(parsed.services)) {
|
||||
if (serviceHasBuild(svc?.build)) out.push(service);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names that use `build:` for a stack. For a Git stack with an applied
|
||||
* multi-file / context-dir spec, reads the effective merged model so override-only
|
||||
* build services are included. Returns null for single-file stacks (and on render
|
||||
* failure) so the caller falls back to the root-compose parse.
|
||||
*/
|
||||
export async function loadEffectiveBuildServices(nodeId: number, stackName: string): Promise<string[] | null> {
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
if (!spec || spec.files.length === 0) return null;
|
||||
const { ComposeService } = await import('./ComposeService');
|
||||
const rendered = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
if (!rendered.rendered) {
|
||||
console.warn(
|
||||
`[ImageUpdateService] effective build render failed for "${sanitizeForLog(stackName)}" (code=${rendered.code} timedOut=${rendered.timedOut}); falling back to root-compose parse: ${sanitizeForLog(rendered.stderr)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return extractBuildServicesFromRenderedConfig(rendered.rendered);
|
||||
}
|
||||
|
||||
/** Resolved build-service names for any stack (effective model or root compose). */
|
||||
export async function loadStackBuildServices(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const effective = await loadEffectiveBuildServices(nodeId, stackName);
|
||||
if (effective) return effective;
|
||||
|
||||
const fs = FileSystemService.getInstance(nodeId);
|
||||
const composeContent = await fs.getStackContent(stackName);
|
||||
return extractBuildServicesFromCompose(composeContent);
|
||||
}
|
||||
|
||||
// ─── Service ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class ImageUpdateService {
|
||||
@@ -390,6 +468,13 @@ export class ImageUpdateService {
|
||||
}
|
||||
|
||||
public getStatus(): ImageUpdateStatus {
|
||||
let sidebarIndicators = false;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
sidebarIndicators = settings.image_update_sidebar_indicators === '1';
|
||||
} catch (e) {
|
||||
console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e);
|
||||
}
|
||||
return {
|
||||
checking: this.isRunning,
|
||||
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
|
||||
@@ -399,6 +484,7 @@ export class ImageUpdateService {
|
||||
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
||||
mode: this.mode,
|
||||
cronExpression: this.cronExpression,
|
||||
sidebarIndicators,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,12 @@ interface ComposeServiceDefinition {
|
||||
|
||||
// Typed shapes for the LinuxServer.io API response
|
||||
interface LsioPort { external?: number; internal: number; protocol?: string }
|
||||
interface LsioVolume { path: string }
|
||||
interface LsioVolume {
|
||||
path: string;
|
||||
host_path?: string;
|
||||
desc?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
interface LsioEnvVar { name: string; desc?: string; default?: string }
|
||||
interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] }
|
||||
interface LsioApp {
|
||||
@@ -223,6 +228,45 @@ function getCategoriesForApp(name: string): string[] {
|
||||
return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other'];
|
||||
}
|
||||
|
||||
function lastPathSegment(path: string): string {
|
||||
return path.split('/').filter(Boolean).pop() || 'data';
|
||||
}
|
||||
|
||||
function parseLsioVolumePath(path: string): { container: string; readonly: boolean } {
|
||||
const match = path.match(/:(ro|rw)$/);
|
||||
if (match) {
|
||||
return { container: path.slice(0, -match[0].length), readonly: match[1] === 'ro' };
|
||||
}
|
||||
return { container: path, readonly: false };
|
||||
}
|
||||
|
||||
function defaultBindForLsioVolume(containerPath: string, hostPath?: string): string {
|
||||
if (hostPath && !hostPath.startsWith('/path/to/')) {
|
||||
return hostPath;
|
||||
}
|
||||
return `./${lastPathSegment(containerPath)}`;
|
||||
}
|
||||
|
||||
function stripErroneousBindMode(bind: string): string {
|
||||
if (/^[a-zA-Z]:/.test(bind)) {
|
||||
return bind;
|
||||
}
|
||||
const match = bind.match(/^(.*):(ro|rw)$/);
|
||||
return match ? match[1] : bind;
|
||||
}
|
||||
|
||||
function mapLsioVolume(v: LsioVolume): TemplateVolume | null {
|
||||
if (v.optional) {
|
||||
return null;
|
||||
}
|
||||
const { container, readonly } = parseLsioVolumePath(v.path);
|
||||
return {
|
||||
container,
|
||||
bind: defaultBindForLsioVolume(container, v.host_path),
|
||||
...(readonly ? { readonly: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
private static readonly CACHE_KEY = 'templates:all';
|
||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -280,13 +324,9 @@ export class TemplateService {
|
||||
source: 'linuxserver',
|
||||
// Map configs if available, otherwise default to empty arrays
|
||||
ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
||||
volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => {
|
||||
const folderName = v.path.split('/').filter(Boolean).pop() || 'data';
|
||||
return {
|
||||
container: v.path,
|
||||
bind: `./${folderName}`
|
||||
};
|
||||
}),
|
||||
volumes: (app.config?.volumes ?? [])
|
||||
.map((v: LsioVolume) => mapLsioVolume(v))
|
||||
.filter((v): v is TemplateVolume => v !== null),
|
||||
env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({
|
||||
name: e.name,
|
||||
label: e.desc || e.name,
|
||||
@@ -343,10 +383,9 @@ export class TemplateService {
|
||||
// handles any escaping the raw value needs.
|
||||
volumes.push(vol);
|
||||
} else if (vol.container) {
|
||||
const containerPath = vol.container;
|
||||
const containerFolder = containerPath.split('/').filter(Boolean).pop() || 'data';
|
||||
const hostPath = vol.bind ? vol.bind : `./${containerFolder}`;
|
||||
const options = vol.readonly ? ':ro' : '';
|
||||
const { container: containerPath, readonly: pathReadonly } = parseLsioVolumePath(vol.container);
|
||||
const hostPath = stripErroneousBindMode(vol.bind ?? defaultBindForLsioVolume(containerPath));
|
||||
const options = (vol.readonly === true || pathReadonly) ? ':ro' : '';
|
||||
volumes.push(`${hostPath}:${containerPath}${options}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
aggregateVerdict,
|
||||
backupSlotSignal,
|
||||
buildRollbackItems,
|
||||
buildServicesSignal,
|
||||
containersSignal,
|
||||
diskSignal,
|
||||
driftSignal,
|
||||
@@ -109,6 +110,7 @@ export class UpdateGuardService {
|
||||
containersSignal(containers),
|
||||
healthchecksSignal(containers),
|
||||
updatePreviewSignal(preview === 'error' ? 'error' : preview.summary),
|
||||
buildServicesSignal(preview === 'error' ? 'error' : preview.build_services),
|
||||
backupSlotSignal(backup, now),
|
||||
diskSignal(typeof disk === 'number' ? { usePercent: disk, limitPercent } : 'error'),
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
extractServiceImagesFromCompose,
|
||||
loadDotEnv,
|
||||
loadEffectiveServiceImages,
|
||||
loadStackBuildServices,
|
||||
type ComposeServiceImage,
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
@@ -42,11 +43,16 @@ export interface UpdatePreviewSummary {
|
||||
update_kind: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
/** True when one or more services declare `build:` in the effective model. */
|
||||
has_build_services: boolean;
|
||||
/** True when a manual update can rebuild local build services (always when has_build_services). */
|
||||
rebuild_available: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePreview {
|
||||
stack_name: string;
|
||||
images: UpdatePreviewImage[];
|
||||
build_services: string[];
|
||||
summary: UpdatePreviewSummary;
|
||||
rollback_target: string | null;
|
||||
changelog: string | null;
|
||||
@@ -226,7 +232,11 @@ function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
return `${base}:${currentTag}`;
|
||||
}
|
||||
|
||||
export function buildSummary(stackName: string, images: UpdatePreviewImage[]): UpdatePreview {
|
||||
export function buildSummary(
|
||||
stackName: string,
|
||||
images: UpdatePreviewImage[],
|
||||
buildServices: string[] = [],
|
||||
): UpdatePreview {
|
||||
const updated = images.filter(i => i.has_update);
|
||||
const hasUpdate = updated.length > 0;
|
||||
const primary = updated[0] ?? images[0] ?? null;
|
||||
@@ -235,6 +245,7 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
'none',
|
||||
);
|
||||
const blocked = overallBump === 'major';
|
||||
const hasBuildServices = buildServices.length > 0;
|
||||
// 'tag' means at least one image has a strictly newer tag; 'digest' means
|
||||
// the only updates available are same-tag rebuilds (digest changed); 'none'
|
||||
// means there is nothing to apply.
|
||||
@@ -246,6 +257,7 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images,
|
||||
build_services: buildServices,
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: primary ? primary.image : null,
|
||||
@@ -255,6 +267,8 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U
|
||||
update_kind: updateKind,
|
||||
blocked,
|
||||
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
|
||||
has_build_services: hasBuildServices,
|
||||
rebuild_available: hasBuildServices,
|
||||
},
|
||||
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
|
||||
changelog: null,
|
||||
@@ -272,9 +286,12 @@ export class UpdatePreviewService {
|
||||
}
|
||||
|
||||
public async getPreview(nodeId: number, stackName: string): Promise<UpdatePreview> {
|
||||
const stackImages = await loadStackImages(nodeId, stackName);
|
||||
const [stackImages, buildServices] = await Promise.all([
|
||||
loadStackImages(nodeId, stackName),
|
||||
loadStackBuildServices(nodeId, stackName),
|
||||
]);
|
||||
if (stackImages.length === 0) {
|
||||
return buildSummary(stackName, []);
|
||||
return buildSummary(stackName, [], buildServices);
|
||||
}
|
||||
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
@@ -301,6 +318,6 @@ export class UpdatePreviewService {
|
||||
const results = await Promise.all(
|
||||
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
|
||||
);
|
||||
return buildSummary(stackName, results);
|
||||
return buildSummary(stackName, results, buildServices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +82,37 @@ const envUnset: PreflightRule = {
|
||||
title: `Unset variable ${name}`,
|
||||
message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`,
|
||||
sourcePath: name,
|
||||
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}.`,
|
||||
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}. If the value is a literal secret or hash containing \`$\`, escape \`$\` as \`$$\` in Compose YAML or single-quote the value in an env file.`,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const LITERAL_DOLLAR_REMEDIATION =
|
||||
'If this was intended as a Compose variable, define it in the project environment or give it a default. '
|
||||
+ 'If it is part of a literal secret or hash, escape literal dollar signs as `$$` in Compose YAML or single-quote the value in an env file.';
|
||||
|
||||
const envLiteralDollar: PreflightRule = {
|
||||
id: 'env-literal-dollar',
|
||||
run(ctx) {
|
||||
return ctx.literalDollarWarnings.map(w => {
|
||||
const likelySecret = w.likelySecret;
|
||||
const title = likelySecret
|
||||
? 'Literal dollar sign in likely secret value may be interpolated'
|
||||
: 'Literal dollar sign in environment value may be interpolated';
|
||||
const keyHint = w.envKey ? ` for "${w.envKey}"` : '';
|
||||
return {
|
||||
ruleId: 'env-literal-dollar',
|
||||
severity: 'high' as const,
|
||||
title,
|
||||
message: `Compose treated a literal $ sequence inside an environment value${keyHint} as variable interpolation and may substitute an empty string for part of the value.`,
|
||||
sourcePath: w.envKey,
|
||||
remediation: LITERAL_DOLLAR_REMEDIATION,
|
||||
service: w.service,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const envFileMissing: PreflightRule = {
|
||||
id: 'env-file-missing',
|
||||
run(ctx) {
|
||||
@@ -730,6 +756,7 @@ const sensitiveServiceBroadExposure: PreflightRule = {
|
||||
export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
renderFailed,
|
||||
envUnset,
|
||||
envLiteralDollar,
|
||||
envFileMissing,
|
||||
portConflictNode,
|
||||
portConflictInternal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EffectiveModel } from './effectiveModel';
|
||||
import type { ExposureIntent } from '../network/types';
|
||||
import type { LiteralDollarWarning } from '../../helpers/unsetEnvClassification';
|
||||
|
||||
/** Graded severity of a single preflight finding. */
|
||||
export type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
@@ -25,6 +26,11 @@ export interface PreflightFinding {
|
||||
remediation?: string;
|
||||
/** Service the finding is scoped to, when applicable. */
|
||||
service?: string;
|
||||
/** True when an active acknowledgement covers this finding. */
|
||||
acknowledged?: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
}
|
||||
|
||||
/** The full report returned by both the GET (latest) and POST (run) routes. */
|
||||
@@ -41,6 +47,11 @@ export interface PreflightReport {
|
||||
sourceHash: string | null;
|
||||
renderedHash: string | null;
|
||||
findings: PreflightFinding[];
|
||||
/** Severity/status after filtering acknowledged findings. */
|
||||
activeStatus: PreflightStatus;
|
||||
activeHighestSeverity: PreflightSeverity | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
}
|
||||
|
||||
/** A declared `env_file:` that is required and absent on disk (names only). */
|
||||
@@ -87,8 +98,10 @@ export interface PreflightContext {
|
||||
renderable: boolean;
|
||||
/** Redacted + truncated render error, or null. */
|
||||
renderError: string | null;
|
||||
/** Variable names Compose reported as unset (defaulted to empty string). */
|
||||
/** Variable names Compose reported as unset (intentional references only). */
|
||||
unsetEnvVars: string[];
|
||||
/** Literal `$` sequences misread as variables; never includes fragment names. */
|
||||
literalDollarWarnings: LiteralDollarWarning[];
|
||||
/** Declared `env_file:` paths that are required but absent on disk (names only). */
|
||||
missingEnvFiles: MissingEnvFile[];
|
||||
/** Service names parsed from the literal source file (pre-render). */
|
||||
|
||||
@@ -27,13 +27,13 @@ const formatAge = (timestamp: number, now: number): string => {
|
||||
};
|
||||
|
||||
export function preflightSignal(
|
||||
input: { status: PreflightStatus } | Errored,
|
||||
input: { activeStatus: PreflightStatus } | Errored,
|
||||
): ReadinessSignal {
|
||||
const base = { id: 'preflight' as const, title: 'Compose Doctor' };
|
||||
if (input === 'error') {
|
||||
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The stored preflight report could not be read.' };
|
||||
}
|
||||
switch (input.status) {
|
||||
switch (input.activeStatus) {
|
||||
case 'never-run':
|
||||
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'Compose Doctor has not been run for this stack yet. Run it for a deeper pre-update check.' };
|
||||
case 'blocker':
|
||||
@@ -128,11 +128,40 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): Read
|
||||
}
|
||||
if (input.has_update) {
|
||||
const kind = input.update_kind === 'digest' ? 'a same-tag image refresh' : `a ${input.semver_bump} update`;
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.` };
|
||||
const buildNote = input.has_build_services
|
||||
? ' Local build services will also be rebuilt from source.'
|
||||
: '';
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.${buildNote}` };
|
||||
}
|
||||
if (input.rebuild_available) {
|
||||
const n = input.has_build_services ? 'Local build service(s)' : 'Build';
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
affectsVerdict: true,
|
||||
detail: `${n} require a rebuild from source; the update rebuilds images and recreates containers.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: 'No pending image update detected; the update re-pulls and recreates with current tags.' };
|
||||
}
|
||||
|
||||
export function buildServicesSignal(buildServices: string[] | Errored): ReadinessSignal {
|
||||
const base = { id: 'build_services' as const, title: 'Local build services', affectsVerdict: false };
|
||||
if (buildServices === 'error') {
|
||||
return { ...base, status: 'unknown', detail: 'Build services could not be detected from the compose model.' };
|
||||
}
|
||||
if (buildServices.length === 0) {
|
||||
return { ...base, status: 'ok', detail: 'No services declare a local build; the update pulls registry images only.' };
|
||||
}
|
||||
const plural = buildServices.length === 1 ? 'service' : 'services';
|
||||
const names = buildServices.join(', ');
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
detail: `${buildServices.length} ${plural} (${names}) rebuild from source. This may take longer and depends on the local Dockerfile context, network access, and base-image availability.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function backupSlotSignal(
|
||||
input: { exists: boolean; timestamp: number | null } | Errored,
|
||||
now: number,
|
||||
|
||||
@@ -6,7 +6,7 @@ export type SignalStatus = 'ok' | 'warning' | 'attention' | 'blocked' | 'unknown
|
||||
|
||||
/** One input to the readiness verdict (preflight, drift, containers, ...). */
|
||||
export interface ReadinessSignal {
|
||||
id: 'preflight' | 'drift' | 'containers' | 'healthchecks' | 'update_preview' | 'backup_slot' | 'disk';
|
||||
id: 'preflight' | 'drift' | 'containers' | 'healthchecks' | 'update_preview' | 'build_services' | 'backup_slot' | 'disk';
|
||||
status: SignalStatus;
|
||||
/** Short headline ("Compose Doctor", "Running containers"). */
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Read-time Compose Doctor acknowledgement filter.
|
||||
*
|
||||
* Acknowledgements never modify stored finding rows. They are applied at read time
|
||||
* so clearing an ack resurfaces findings without re-running preflight.
|
||||
*/
|
||||
import type { PreflightAckExpiryMode, PreflightAcknowledgement } from '../services/DatabaseService';
|
||||
import type { PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
export interface PreflightAcknowledgementDecision {
|
||||
acknowledged: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: PreflightAckExpiryMode;
|
||||
}
|
||||
|
||||
export interface PreflightAckFilterContext {
|
||||
renderedHash: string | null;
|
||||
/** Parsed service name to image ref from the latest stored run. */
|
||||
serviceImages: Record<string, string>;
|
||||
}
|
||||
|
||||
function matchesService(ackService: string | null, findingService: string | undefined): boolean {
|
||||
if (ackService === null) return true;
|
||||
return ackService === (findingService ?? null);
|
||||
}
|
||||
|
||||
function isActive(
|
||||
ack: PreflightAcknowledgement,
|
||||
ctx: PreflightAckFilterContext,
|
||||
findingService: string | undefined,
|
||||
now: number,
|
||||
): boolean {
|
||||
switch (ack.expiry_mode) {
|
||||
case 'forever':
|
||||
return true;
|
||||
case 'until_compose_change':
|
||||
return ctx.renderedHash !== null
|
||||
&& ack.anchor_rendered_hash !== null
|
||||
&& ctx.renderedHash === ack.anchor_rendered_hash;
|
||||
case 'days':
|
||||
return ack.expires_at !== null && ack.expires_at > now;
|
||||
case 'until_image_change': {
|
||||
const svc = findingService ?? ack.service ?? null;
|
||||
if (!svc) return false;
|
||||
const current = ctx.serviceImages[svc] ?? null;
|
||||
return current !== null
|
||||
&& ack.anchor_image_ref !== null
|
||||
&& current === ack.anchor_image_ref;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function specificityScore(ack: PreflightAcknowledgement): number {
|
||||
return ack.service ? 1 : 0;
|
||||
}
|
||||
|
||||
function pickFromBucket(
|
||||
bucket: PreflightAcknowledgement[],
|
||||
findingService: string | undefined,
|
||||
ctx: PreflightAckFilterContext,
|
||||
now: number,
|
||||
): PreflightAcknowledgement | null {
|
||||
let best: PreflightAcknowledgement | null = null;
|
||||
let bestScore = -1;
|
||||
for (const ack of bucket) {
|
||||
if (!matchesService(ack.service, findingService)) continue;
|
||||
if (!isActive(ack, ctx, findingService, now)) continue;
|
||||
const score = specificityScore(ack);
|
||||
if (score > bestScore) {
|
||||
best = ack;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseServiceImages(json: string | null | undefined): Record<string, string> {
|
||||
if (!json) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === 'string' && value.length > 0) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPreflightAcknowledgements<T extends PreflightFinding>(
|
||||
findings: T[],
|
||||
ctx: PreflightAckFilterContext,
|
||||
acks: PreflightAcknowledgement[],
|
||||
now: number = Date.now(),
|
||||
): Array<T & PreflightAcknowledgementDecision> {
|
||||
if (findings.length === 0) return [];
|
||||
const buckets = new Map<string, PreflightAcknowledgement[]>();
|
||||
for (const ack of acks) {
|
||||
const existing = buckets.get(ack.rule_id);
|
||||
if (existing) {
|
||||
existing.push(ack);
|
||||
} else {
|
||||
buckets.set(ack.rule_id, [ack]);
|
||||
}
|
||||
}
|
||||
return findings.map((f) => {
|
||||
const bucket = buckets.get(f.ruleId);
|
||||
const match = bucket ? pickFromBucket(bucket, f.service, ctx, now) : null;
|
||||
if (!match) return { ...f, acknowledged: false };
|
||||
return {
|
||||
...f,
|
||||
acknowledged: true,
|
||||
acknowledgementId: match.id,
|
||||
acknowledgementReason: match.reason,
|
||||
acknowledgementExpiry: match.expiry_mode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function isPreflightAckActive(
|
||||
ack: PreflightAcknowledgement,
|
||||
ctx: PreflightAckFilterContext,
|
||||
now: number = Date.now(),
|
||||
): boolean {
|
||||
return isActive(ack, ctx, ack.service ?? undefined, now);
|
||||
}
|
||||
@@ -148,10 +148,10 @@ Every alert Sencho dispatches carries a category that you can filter on in the b
|
||||
| `monitor_alert` | Monitor alert | Per-stack threshold breach, host CPU/RAM/disk warning, or healthcheck failure |
|
||||
| `scan_finding` | Scan finding | Vulnerability-scan completion, per-violation alert, post-deploy scan failure, or auto-update gate block |
|
||||
| `system` | System | Sencho version, Trivy auto-update, fleet sync, daemon connectivity, scheduled-task lifecycle, cloud-backup upload failure |
|
||||
| `blueprint_deployed` | `blueprint_deployed` | Blueprint provisioned a new deployment (Admiral) |
|
||||
| `blueprint_deployment_failed` | `blueprint_deployment_failed` | Blueprint deployment errored out (Admiral) |
|
||||
| `blueprint_drift_detected` | `blueprint_drift_detected` | Blueprint drift detected in `suggest` or `enforce` mode (Admiral) |
|
||||
| `blueprint_drift_correction_failed` | `blueprint_drift_correction_failed` | Blueprint enforce-mode redeploy failed (Admiral) |
|
||||
| `blueprint_deployed` | `blueprint_deployed` | Blueprint provisioned a new deployment |
|
||||
| `blueprint_deployment_failed` | `blueprint_deployment_failed` | Blueprint deployment errored out |
|
||||
| `blueprint_drift_detected` | `blueprint_drift_detected` | Blueprint drift detected in `suggest` or `enforce` mode |
|
||||
| `blueprint_drift_correction_failed` | `blueprint_drift_correction_failed` | Blueprint enforce-mode redeploy failed |
|
||||
|
||||
The four `blueprint_*` categories are accepted by routing rules but render as raw category strings in the bell because the frontend label map omits them.
|
||||
|
||||
@@ -364,7 +364,7 @@ See [Vulnerability scanning](/features/vulnerability-scanning).
|
||||
|
||||
`warning`/`system`: `Cloud backup failed for scheduled snapshot <id>: <message>`. See [Fleet backups](/features/fleet-backups).
|
||||
|
||||
### Blueprints (Admiral)
|
||||
### Blueprints
|
||||
|
||||
- `warning`/`blueprint_drift_detected`: `Blueprint "<n>" drifted on node "<n2>": <reason>` (suggest mode), with stateful-safeguard variants when enforce mode declines to redeploy.
|
||||
- `error`/`blueprint_drift_correction_failed`: `Auto-fix for "<n>" on node "<n2>" failed: <err>`.
|
||||
|
||||
@@ -8,7 +8,7 @@ A **Blueprint** bundles a `docker-compose.yml` with a node selector and a drift
|
||||
Blueprints live under **Fleet · Deployments**.
|
||||
|
||||
<Note>
|
||||
Blueprints require a Sencho **Admiral** license. Creating, editing, withdrawing, and pinning blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet.
|
||||
Blueprints are available on every tier as Sencho's Compose-first orchestration model. Creating, editing, withdrawing, and applying blueprints requires an admin role; operators and viewers can read the catalog and the detail sheet. Pinning a blueprint to a specific node lives under **Fleet · Federation** on every tier (admin role required).
|
||||
</Note>
|
||||
|
||||
<Frame caption="Fleet · Deployments catalog with blueprint tiles, the All / Drifted / Observe / Suggest / Enforce filter chips, and the New Blueprint action in the top-right.">
|
||||
@@ -37,7 +37,7 @@ Drift detection runs on every tick for every Active deployment regardless of pol
|
||||
|
||||
**Stateful safety rails.** Stateful and state-unknown blueprints enter **Awaiting confirmation** on every fresh node before the first deploy, and **Evict blocked** when a node falls out of the selector while still hosting a stateful deployment. The reconciler never deploys empty volumes or destroys named volumes without a human acknowledging the action.
|
||||
|
||||
**Pin override and cordon respect.** Admiral users can pin a blueprint to a single node from **Fleet · Federation**. A pin replaces the selector entirely, deploys only to the pinned node, and overrides the cordon flag on that node. Cordoning a node otherwise prevents the reconciler from picking it for new placements; existing deployments on a cordoned node keep running and stay drift-checked.
|
||||
**Pin override and cordon respect.** Admins can pin a blueprint to a single node from **Fleet · Federation**. A pin replaces the selector entirely, deploys only to the pinned node, and overrides the cordon flag on that node. Cordoning a node otherwise prevents the reconciler from picking it for new placements; existing deployments on a cordoned node keep running and stay drift-checked.
|
||||
|
||||
**Vulnerability-policy participation.** Local blueprint deploys evaluate against the same pre-deploy policy gate that the per-stack deploy lane uses. If an enabled policy blocks one of the blueprint's image references, the deployment row moves to **Failed** and the stack is never written to disk. Remote blueprint deploys are routed through the remote node's stack deploy endpoint, so policy enforcement runs on the remote instance with that node's credentials and scanner state.
|
||||
|
||||
@@ -47,8 +47,8 @@ Drift detection runs on every tick for every Active deployment regardless of pol
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| License tier | **Admiral** to read, create, edit, withdraw, and pin blueprints. |
|
||||
| User role | **Admin** to create, edit, withdraw, accept, and pin. Operators and viewers can read the catalog and the detail sheet. |
|
||||
| License tier | **Community** to read, create, edit, withdraw, apply, and pin blueprints. |
|
||||
| User role | **Admin** to create, edit, withdraw, accept, and apply. Operators and viewers can read the catalog and the detail sheet. Pinning requires admin. |
|
||||
| Nodes | At least one node that the selector resolves to. Remote nodes need a healthy proxy connection; see [Multi-node management](/features/multi-node) and [Pilot Agent](/features/pilot-agent) for enrollment. |
|
||||
| Compose YAML | Valid `docker-compose.yml`, 96 KiB or fewer. |
|
||||
| Blueprint name | 1 to 64 characters matching `^[a-z0-9][a-z0-9_-]*$`. The name doubles as the stack directory on every targeted node and is immutable after creation. |
|
||||
@@ -228,7 +228,7 @@ Stateless blueprints withdraw all deployments and then delete in a single click.
|
||||
|
||||
## Federation: pin a blueprint to a single node
|
||||
|
||||
Admiral users can pin a blueprint to a specific node from **Fleet · Federation**. A pinned blueprint deploys only to its pinned node, regardless of the configured selector, and overrides the cordon flag on that node. The Blueprint detail sheet shows a read-only `Pin` section when a pin is in place; pin management itself lives in the Federation tab.
|
||||
Admins can pin a blueprint to a specific node from **Fleet · Federation**. A pinned blueprint deploys only to its pinned node, regardless of the configured selector, and overrides the cordon flag on that node. The Blueprint detail sheet shows a read-only `Pin` section when a pin is in place; pin management itself lives in the Federation tab.
|
||||
|
||||
<Frame caption="Fleet · Federation, Blueprints subsection. Each row shows the blueprint, its configured selector, the Pinned to dropdown, and the effective placement that the reconciler will use.">
|
||||
<img src="/images/blueprint-model/federation-pin.png" alt="Federation tab pin policy table with one blueprint pinned and one unpinned" />
|
||||
@@ -251,7 +251,7 @@ Both events route through the standard alert pipeline. Configure delivery channe
|
||||
|
||||
## Security and trust boundaries
|
||||
|
||||
**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status requires Admiral. Creating, editing, withdrawing, accepting a stateful deploy, applying on demand, and pinning a blueprint require the admin role on top of the Admiral tier.
|
||||
**Who can do what.** The license tier and the user role together determine the available actions. Reading the catalog, the detail sheet, and the deployment status is available on every tier. Creating, editing, withdrawing, accepting a stateful deploy, and applying on demand require the admin role. Pinning a blueprint requires the admin role.
|
||||
|
||||
**The marker file is the trust root.** The reconciler will only deploy into, modify, or withdraw a directory that carries a `.blueprint.json` marker whose blueprint ID matches. A pre-existing directory with no marker, or a marker referencing a different blueprint, surfaces as **Name conflict** and is never modified.
|
||||
|
||||
@@ -299,7 +299,7 @@ A future Volume Migration feature will automate this with app-aware backup tooli
|
||||
|
||||
**Single-node managed Postgres.** A stateful `pg-fleet` blueprint with a `nodes` selector pointing at one database node and drift mode **Suggest**. The first deploy enters **Awaiting confirmation** so the operator chooses **Deploy fresh**. Subsequent compose changes (image bump, config change) re-enter **Awaiting confirmation** on the same node so the operator can decide whether the new revision is safe for the existing volume. Drift on the running container fires a `blueprint_drift_detected` notification but never auto-redeploys.
|
||||
|
||||
**Pin a blueprint to a specific node despite the selector.** Admiral users open **Fleet · Federation**, find the blueprint in the pin policy table, and pick the target node from the **Pinned to** dropdown. The pin overrides the selector for that blueprint, deploys only to the pinned node, and also overrides cordon on that node. Useful for relocating a stateful service to a specific host without rewriting the selector. Clear the pin to restore selector-driven placement.
|
||||
**Pin a blueprint to a specific node despite the selector.** Open **Fleet · Federation**, find the blueprint in the pin policy table, and pick the target node from the **Pinned to** dropdown. The pin overrides the selector for that blueprint, deploys only to the pinned node, and also overrides cordon on that node. Useful for relocating a stateful service to a specific host without rewriting the selector. Clear the pin to restore selector-driven placement.
|
||||
|
||||
**Observe-only audit blueprint.** A stateless monitoring stack (Vector, Promtail, a Prometheus exporter) with drift mode **Observe**. Drift is recorded silently in the deployment table; no notification fires and no auto-fix runs. Useful when you want Sencho to track placement and detect divergence on a low-signal stack without paging anyone.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Run a preflight check on a stack before you deploy. Compose Doctor
|
||||
|
||||
The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to.
|
||||
|
||||
The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 30 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
The check is advisory. It never blocks a deploy and never changes a stack. It runs on demand: press **run preflight** and Sencho renders the model, runs all 31 checks, and stores the result so the tab still shows it the next time you open the stack.
|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -24,7 +24,7 @@ Every preflight run follows three steps:
|
||||
|
||||
1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model.
|
||||
2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node.
|
||||
3. **Run 30 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
3. **Run 31 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.
|
||||
|
||||
Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history.
|
||||
|
||||
@@ -72,7 +72,7 @@ A small colored dot appears on the **Doctor** tab label when the last run found
|
||||
|
||||
## What it checks
|
||||
|
||||
All 30 rules are listed below, organized by topic.
|
||||
All 31 rules are listed below, organized by topic.
|
||||
|
||||
### Model rendering
|
||||
|
||||
@@ -84,7 +84,8 @@ All 30 rules are listed below, organized by topic.
|
||||
|
||||
| Rule | Severity | What it detects |
|
||||
|------|----------|----------------|
|
||||
| Unset variable | High | A variable referenced by the model has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. |
|
||||
| Unset variable | High | An intentional `${VAR}` or `$VAR` reference has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. |
|
||||
| Literal dollar in value | High | A literal `$` inside an environment value (common in bcrypt hashes and other secrets) was treated as Compose interpolation. Sencho reports the env key when it can identify one, never a fragment of the value. |
|
||||
| Missing env file | High | A path listed under `env_file:` does not exist in the stack directory. Compose fails to start the stack when a required env file is absent. |
|
||||
|
||||
### Port conflicts
|
||||
@@ -203,6 +204,23 @@ Running preflight before updating gives the readiness check the most accurate si
|
||||
|
||||
Preflight runs against the **active node**, so selecting a remote node checks the stack against that machine's live Docker state. On mobile, the same report appears under the **Compose** section of the stack detail view.
|
||||
|
||||
## Acknowledging findings
|
||||
|
||||
When a finding is valid in general but intentional for your stack, you can acknowledge it so it no longer counts toward the active warning total, the Doctor tab badge, or the update readiness check.
|
||||
|
||||
1. Open the **Doctor** tab and run preflight if you have not already.
|
||||
2. On any active finding row, click **acknowledge**.
|
||||
3. Optionally add a note explaining why the finding is acceptable.
|
||||
4. Choose when Sencho should show the finding again:
|
||||
- **Forever** until you clear the acknowledgement manually.
|
||||
- **Until Compose changes** when the stack's compose fingerprint changes (for example after you edit `compose.yaml`).
|
||||
- **30 days** on a rolling timer.
|
||||
- **Until image changes** for a specific service when that service's image reference in the effective model changes. This tracks tag or reference changes, not silent digest re-pulls of the same tag.
|
||||
|
||||
Acknowledged findings move to a collapsible **Acknowledged** section at the bottom of the report. You can clear an acknowledgement there to restore the finding to the active list immediately.
|
||||
|
||||
Acknowledgements are stored per node, stack, rule, and service. They do not sync across nodes in a fleet. Clearing an acknowledgement or letting an expiry mode lapse restores the finding on the next preflight read without re-running checks.
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="/images/compose-doctor/compose-doctor-findings.png"
|
||||
@@ -250,6 +268,9 @@ There is no tier gate: Compose Doctor is available on all plans.
|
||||
<Accordion title="Exposure intent rules are firing unexpectedly">
|
||||
The five exposure intent rules activate when the stack publishes at least one host port. The "unclassified" warning clears as soon as you set an exposure intent in the Networking tab. The "port not in dossier" warning clears once you add a matching access URL in the Stack Dossier. If an intent is already set and the finding still fires, check that the intent is configured on the correct node and for the correct service.
|
||||
</Accordion>
|
||||
<Accordion title="Doctor warns about literal dollar signs in a hash or secret">
|
||||
Compose treats unescaped `$` in environment values as variable references. Bcrypt hashes and other secrets often contain `$` characters, so an unquoted value in Compose YAML or a double-quoted `.env` line can be partially blanked out at runtime. Doctor flags this as **Literal dollar in value** and names the env key when it can, without showing part of the secret. Fix it by escaping each literal `$` as `$$` in Compose YAML, or by single-quoting the value in an env file.
|
||||
</Accordion>
|
||||
<Accordion title="I want to see who ran preflight last">
|
||||
The summary card shows the username and relative time: "ran 5 minutes ago by admin". This reflects the most recent run stored for this stack on the currently active node.
|
||||
</Accordion>
|
||||
|
||||
@@ -62,7 +62,7 @@ A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorte
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% |
|
||||
| **STACK** | Stack name, derived from the compose file (extension stripped) |
|
||||
| **STACK** | Stack name with an orange "Update available" badge when a newer image has been detected. The badge appears regardless of the sidebar indicator setting. |
|
||||
| **HOST** | Active node this stack belongs to |
|
||||
| **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` |
|
||||
| **CPU** | Latest aggregate CPU across the stack's containers |
|
||||
|
||||
@@ -97,7 +97,7 @@ The Anatomy tab lists:
|
||||
- **network** and its driver
|
||||
- **source** with `git · <host>/<repo>#<branch>` when the stack is linked to a Git repository, or `local` when it is not. Clicking the row opens the Git source dialog. A pulsing brand-color dot means an upstream change is queued.
|
||||
|
||||
When an image update is available, an inline banner appears at the top of the panel. Its tone follows the version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. The banner has an inline **apply** button that runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
||||
When an image update is available, or the stack declares services with a local `build:` section, an inline banner appears at the top of the panel. Registry updates follow version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button. The banner runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
||||
|
||||
## Editor mode
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Federation lives under **Fleet → Federation**.
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
Federation is an Admiral feature. Cordon and pin actions require an admin user role.
|
||||
Federation placement controls (cordon, uncordon, pin, and unpin) are available on every tier. Cordon and pin mutations require an admin user role, or the node-admin role for cordon when scoped to that node.
|
||||
</Note>
|
||||
|
||||
## Placement control, not placement automation
|
||||
@@ -32,7 +32,7 @@ In practice, Federation hands you four operator decisions:
|
||||
|
||||
### Cordon a node
|
||||
|
||||
Cordon marks a node as unschedulable. From the moment a node is cordoned, the reconciler skips it for new placements; existing stacks keep running, drift checks keep running, and revision bumps still redeploy in place. The cordon is visible to every tier (the read-only `Cordoned` pill on the node card) so non-Admiral operators understand why a node is not picking up new work. Uncordon to lift the restriction.
|
||||
Cordon marks a node as unschedulable. From the moment a node is cordoned, the reconciler skips it for new placements; existing stacks keep running, drift checks keep running, and revision bumps still redeploy in place. The cordon is visible to every tier (the read-only `Cordoned` pill on the node card) so operators can see why a node is not picking up new work. Uncordon to lift the restriction.
|
||||
|
||||
The cordon reason is free-form text (up to 256 characters). It surfaces in the Federation tab summary, in the cordon pill's tooltip on the node card, and on the audit log row for the action.
|
||||
|
||||
@@ -50,8 +50,8 @@ Cordon, uncordon, and pin actions all flow through Sencho's standard audit log.
|
||||
|
||||
| Requirement | Why it matters |
|
||||
|-------------|----------------|
|
||||
| **Admiral license active on the control instance** | Federation enforcement, the tab itself, and the cordon and pin controls all gate on the control instance's tier. Remote nodes inherit Admiral through the [proxy's tier assertion](/features/multi-node#license-enforcement-across-nodes), so a paid control plane covers the whole fleet. |
|
||||
| **Admin role for the active user** | Operator and viewer roles can read cordon state but cannot toggle it. Pin policy edits are also admin-only. |
|
||||
| **Community or Admiral on the control instance** | Federation placement is part of the Compose-first control plane. Remote nodes inherit the control instance tier through the [proxy tier assertion](/features/multi-node#license-enforcement-across-nodes). |
|
||||
| **Admin or node-admin role** | Pin policy edits require admin. Cordon and uncordon require `node:manage` (admin, or node-admin when scoped to that node). Operator and viewer roles can read cordon state but cannot toggle it. |
|
||||
| **At least one Blueprint defined** | The Pin policy table is empty until you create a blueprint under **Fleet → Deployments**. Cordon does not require any blueprints; it only suppresses *new* placements from blueprints that exist later. |
|
||||
| **Active connection to each remote node** | Cordon state is written to the control instance's local database, but it only takes effect once the reconciler runs against the fleet's current node set. A remote node that is `Offline` still carries its cordon flag and resumes honouring it as soon as it comes back. |
|
||||
|
||||
@@ -117,7 +117,7 @@ The **Effective** column in the Pin policy table is computed live each time the
|
||||
|
||||
## Security and audit
|
||||
|
||||
Federation actions require both an Admiral license and an admin user role. The `Cordoned` pill on the node card stays visible at every tier as a read-only signal, so a non-Admiral operator can still understand why a node is skipping new work.
|
||||
Federation mutations require an admin user role, or the node-admin role for cordon when scoped to that node. The `Cordoned` pill on the node card stays visible at every tier as a read-only signal, so operators without placement permissions can still see why a node is skipping new work.
|
||||
|
||||
Every cordon, uncordon, and pin action is captured in the audit log. Each row carries the actor, the action (`node.cordon`, `node.uncordon`, `blueprint.pin`), the affected resource, and the cordon reason where applicable. Filter the **Audit** view by these action names for the full history.
|
||||
|
||||
@@ -176,7 +176,7 @@ Pinning a stateful blueprint that is currently deployed on multiple nodes shrink
|
||||
The Fleet Overview grid polls every 30 seconds, so the `Cordoned` pill can lag the action by up to that long. The Federation tab summary and the audit log update immediately; if the audit log shows the cordon action but the badge is still missing, click **Refresh** on the Fleet page to force an immediate re-fetch.
|
||||
</Accordion>
|
||||
<Accordion title="The Federation tab is not visible">
|
||||
Federation requires an Admiral license. The rest of the blueprint surface (catalog, deployments, drift) is available on lower tiers, and the read-only `Cordoned` pill on a node card is visible everywhere. If Federation is missing on an Admiral license, sign in as an admin user; cordon and pin actions are admin-only.
|
||||
Federation is available on every tier under **Fleet → Federation**. Cordon and pin mutations require an admin user (or node-admin for cordon on nodes they manage). If the tab is missing, refresh the Fleet page; if controls are read-only, sign in as an admin.
|
||||
</Accordion>
|
||||
<Accordion title="The Pin policy table is empty even though I created blueprints">
|
||||
Federation reads from the same Blueprints registry that powers **Fleet → Deployments**. If the Deployments tab shows blueprints but Federation does not, refresh the Fleet page (the Pin policy table caches its source list when the tab mounts). If Deployments is also empty, no blueprint has been saved yet; create one there first.
|
||||
@@ -199,6 +199,6 @@ Federation is one tab in a larger Fleet view, and it focuses on a narrow slice o
|
||||
| [Fleet Actions](/features/fleet-actions) | Bulk operations across labelled nodes (restart, stop). | Fleet Actions runs imperative operations on existing deployments; Federation steers declarative placement decisions. |
|
||||
| [Fleet Sync](/features/fleet-sync) | Push-only replication of security policies (scan policies, CVE suppressions) from a control instance to replica instances. | Fleet Sync replicates *security state*, not placement; the two features do not interact. |
|
||||
| [Blueprints](/features/blueprint-model) | The declarative deployment model whose reconciler Federation steers. | Required reading: without Blueprints, Federation has nothing to do. |
|
||||
| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | The single source of truth for the Admiral requirement called out at the top of this page. |
|
||||
| [Licensing](/features/licensing) | The full tier matrix and what each tier unlocks. | The single source of truth for which fleet capabilities require Admiral. |
|
||||
|
||||
Federation is not Fleet Sync, not Mesh, and not the remote-node proxy. The cordon flag affects only declarative blueprint deployments, not manually deployed stacks. If you are looking for a way to take an entire node fully out of service (existing deployments included), see the deployment table's withdraw flow on each affected blueprint; cordon by itself is intentionally non-destructive.
|
||||
|
||||
@@ -38,7 +38,7 @@ A single rail summarises the state of every registered node so you can read the
|
||||
|
||||
### Tabs
|
||||
|
||||
The Fleet view is a tab strip. Five tab triggers are visible to every tier; the Deployments, Routing, Federation, and Secrets triggers only render when the active license unlocks them. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs.
|
||||
The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Deployments, and Actions. Snapshots appears for admins. Deployments is available on Community. Routing and Secrets render when the active license unlocks them. Federation is available on every tier. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs.
|
||||
|
||||
| Tab | Tier | What it does |
|
||||
|-----|------|--------------|
|
||||
@@ -46,9 +46,9 @@ The Fleet view is a tab strip. Five tab triggers are visible to every tier; the
|
||||
| **Snapshots** | Community | Snapshot every compose file across the fleet. See [Fleet-Wide Backups](/features/fleet-backups). |
|
||||
| **Status** | Community | One card per node summarising which automations and security features are configured. Covered below. |
|
||||
| **Map** | Community | A read-only map of how stacks, services, networks, volumes, and ports relate across the fleet, with anomaly flags. Covered below. |
|
||||
| **Deployments** | Admiral | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). |
|
||||
| **Deployments** | Community | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). |
|
||||
| **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). |
|
||||
| **Federation** | Admiral | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). |
|
||||
| **Federation** | Community | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). |
|
||||
| **Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). |
|
||||
| **Secrets** | Admiral | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). |
|
||||
|
||||
@@ -90,7 +90,7 @@ Every node renders as a card. The local node is pinned at the top of the grid wi
|
||||
| **Version badge** | The node's Sencho version in mono tabular numerals (e.g. `v0.76.3`). Hidden if the node cannot report a version. |
|
||||
| **Update available** badge | Warning pill shown when a newer Sencho release is published for this node. |
|
||||
| **Critical** badge | Destructive pill with a triangle icon, surfaced when the online node is above 90% CPU or 90% disk. |
|
||||
| **Cordoned** badge | Warning pill with a Ban icon, surfaced when an Admiral has cordoned the node. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. |
|
||||
| **Cordoned** badge | Warning pill with a Ban icon, surfaced when the node is cordoned. The badge tooltip carries the cordon reason or the default *Unschedulable: new blueprint deployments skip this node*. See [Fleet Federation](/features/fleet-federation) for the full cordon and pin flow. |
|
||||
| **Updating / Updated / Failed** badge | Update progress indicator, shown only while or just after an update flows through. Failed states surface inline retry and dismiss buttons and a cursor-following error tooltip. |
|
||||
| **Container stats grid** | Three cells: **Running** (active containers), **Stopped** (exited containers), **Stacks** (count, or `-` if the node has not reported). Hidden on offline nodes. |
|
||||
| **CPU / RAM / Disk bars** | Each row shows the metric icon, the percent (CPU) or `used / total` (RAM, Disk), and a horizontal bar that tints amber at 60%, destructive at 80% (CPU/RAM), or amber at 75% / destructive at 90% (Disk). Hidden on offline nodes. |
|
||||
@@ -107,9 +107,9 @@ Every card carries a three-dot **Node actions** kebab in the top-right corner. T
|
||||
|--------|-------|
|
||||
| **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. |
|
||||
| **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. |
|
||||
| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it (Admiral). Existing deployments keep running. |
|
||||
| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it. Existing deployments keep running. Requires the `node:manage` permission (admin, or node-admin when scoped to that node). |
|
||||
|
||||
The menu is admin-only. Non-admin users see no kebab on the card.
|
||||
Edit and delete remain admin-only. Users without `node:manage` and without edit/delete affordances see no kebab on the card.
|
||||
|
||||
### Topology view
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
|
||||
- Real-time container stats, global logs, the interactive network topology graph (Hub, Grouped, and Free layouts), node labels, and stack labels
|
||||
- Git sources for compose stacks
|
||||
- Multi-node management in both Proxy and Pilot Agent modes
|
||||
- Blueprints: fleet-wide compose templates with drift detection (Observe, Suggest, and Enforce), reconciliation, and pin-to-node placement
|
||||
- Fleet Federation: cordon and uncordon nodes and pin blueprints to specific hosts
|
||||
- Fleet View with search, sort, filter, and node-card drill-down
|
||||
- Manual and scheduled fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**)
|
||||
- Actions tab (stop stacks fleet-wide by label, assign a label to stacks across nodes, prune Docker resources fleet-wide; admin role required), plus fleet-wide bulk Sencho restart
|
||||
@@ -48,7 +50,7 @@ See [the pricing page](https://sencho.io/pricing) for current pricing.
|
||||
|
||||
- **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window
|
||||
- **Security:** Fleet Secrets, AWS ECR registry credentials, and LDAP / Active Directory authentication
|
||||
- **Fleet operations:** node cordon, Blueprints, and Sencho Mesh (cross-node container networking)
|
||||
- **Fleet operations:** Sencho Mesh (cross-node container networking)
|
||||
- **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance)
|
||||
- **Operator access:** the Host Console (a browser-based terminal on the Sencho host)
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ Remote nodes connect in one of two modes. Pick the one that matches your network
|
||||
|
||||
## The local node
|
||||
|
||||
Your control Sencho instance is always listed as **Local**. It is the default node, marked with a star icon, and cannot be deleted. All operations on the local node run directly against the host's Docker socket.
|
||||
Only one local node can exist per Sencho instance. The local node cannot be deleted because each instance must retain its own Docker engine identity. All operations on the local node run directly against the host's Docker socket.
|
||||
|
||||
If multiple local nodes exist from an older version, the extra rows show a Delete action so you can clean them up. Deleting a local node removes its schedules, labels, dossiers, findings, and other node-scoped data; containers and compose files on the host are not affected. The last remaining local row hides Delete because it cannot be removed.
|
||||
|
||||
## Choose a remote mode
|
||||
|
||||
@@ -172,7 +174,7 @@ The Nodes table surfaces routing, status, and per-node automation at a glance fo
|
||||
| **Labels** | Per-node label palette. The cell shows the label picker; an empty cell reads `No labels` with an Add label control. |
|
||||
| **Schedules** | Number of active scheduled tasks targeting this node, plus a `next X` countdown to the next run. Click the count or the calendar icon in the Actions column to filter the Schedules view to that node. |
|
||||
| **Updates** | `Auto` if at least one enabled `Auto-update Stack` or `Auto-update All Stacks on Node` schedule targets the node; `Off` otherwise. A pulsing dot and count appear when stacks have pending image updates. |
|
||||
| **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The local row hides Delete because the local node cannot be removed. |
|
||||
| **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The last local row hides Delete because the local node cannot be removed. When multiple local nodes exist from an older version, the extra rows show Delete so you can clean them up. |
|
||||
|
||||
Clicking the schedules-link icon opens the Schedules view filtered to the selected node. From there you can create, edit, or manage scheduled tasks scoped to that node. The filter bar shows which node you are viewing, with a Clear filter control to return to the full list.
|
||||
|
||||
@@ -218,7 +220,7 @@ This means:
|
||||
|
||||
Click the pencil icon on any row to edit its name, URL, token, or compose directory. The API Token field opens blank for security: leave it blank to keep the current token, or paste a new one to rotate it. For a Pilot Agent row, the Edit modal also surfaces the **Regenerate enrollment token** card described earlier.
|
||||
|
||||
Click the trash icon to remove a remote node. The local row hides this icon because the default node cannot be deleted. Removing a node only deletes the routing entry on the control instance; the remote Sencho instance and its containers are not touched.
|
||||
Click the trash icon to remove a remote node. The local row hides this icon when it is the only local node. When multiple local nodes exist from an older version, the extra rows expose Delete so you can clean them up. Deleting a local node removes its schedules, labels, dossiers, findings, and other node-scoped data; containers and compose files on the host are not affected.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Browse pre-configured application templates. Filter by category (Media, Automati
|
||||
|
||||
### Blueprints
|
||||
|
||||
Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get a confirmation prompt before first deploy and before eviction. Admiral. [Learn more →](/features/blueprint-model)
|
||||
Fleet-wide compose templates that Sencho keeps in sync across the nodes you choose. One declaration covers many nodes via label selectors, drift detection always runs, and stateful blueprints get a confirmation prompt before first deploy and before eviction. [Learn more →](/features/blueprint-model)
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -149,7 +149,7 @@ Export your entire homelab as a single Markdown archive. The **Export Dossier**
|
||||
|
||||
### Fleet Federation
|
||||
|
||||
Operator-driven placement controls for fleets running Blueprints. Cordon a node to mark it unschedulable for new work, or pin a blueprint to a specific node to override selector matches. Cordon affects new placements only; existing deployments keep running. Admiral. [Learn more →](/features/fleet-federation)
|
||||
Operator-driven placement controls for fleets running Blueprints. Cordon a node to mark it unschedulable for new work, or pin a blueprint to a specific node to override selector matches. Cordon affects new placements only; existing deployments keep running. [Learn more →](/features/fleet-federation)
|
||||
|
||||
### Fleet Actions
|
||||
|
||||
@@ -243,4 +243,4 @@ Personalize Sencho's look: choose a theme (Dim, OLED, Light, or Auto) and one of
|
||||
|
||||
### Licensing & billing
|
||||
|
||||
Community is the complete self-hosted control plane, free forever, including the full vulnerability-scanning and deploy-enforcement suite. Admiral adds governance and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, Blueprints, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing)
|
||||
Community is the complete self-hosted control plane, free forever, including the full vulnerability-scanning and deploy-enforcement suite. Admiral adds governance and fleet control for teams: advanced RBAC, audit log export and retention, Fleet Secrets, Sencho Mesh, and more. Manage your license, view subscription details, and access the billing portal from Settings. [Learn more →](/features/licensing)
|
||||
|
||||
@@ -151,7 +151,7 @@ Deploying through Compose also lets the control instance push over-the-air updat
|
||||
|
||||
The agent boots, reads its configuration, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
|
||||
|
||||
The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The original enrollment token is now useless: the agent does not need it again, and the control instance refuses to accept it a second time.
|
||||
The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The control instance will not accept the same enrollment token again, but leave `SENCHO_ENROLL_TOKEN` in the compose file: the agent uses it as a fallback if the persisted tunnel JWT is ever rejected at upgrade (for example after a secret rotation), clearing the stale file and re-enrolling automatically.
|
||||
|
||||
The tunnel is now active. The Endpoint column in the Nodes table flips from `tunnel (waiting)` to `tunnel (seen Xs ago)` and the node's status badge turns Online.
|
||||
|
||||
@@ -205,7 +205,7 @@ The agent dials `wss://`. Certificate validation is on by default against the sy
|
||||
|
||||
### What rotating the JWT secret does
|
||||
|
||||
The tunnel JWTs are signed with the control instance's `auth_jwt_secret`. If that secret rotates (because the control instance was rebuilt from scratch, restored into a different environment, or manually rotated), every existing tunnel JWT stops verifying. The agents will reconnect, fail authentication, and back off. Re-enroll each affected node (regenerate the enrollment token, then redeploy the agent on the remote with the refreshed compose file) to issue a new tunnel JWT signed by the current secret.
|
||||
The tunnel JWTs are signed with the control instance's `auth_jwt_secret`. If that secret rotates (because the control instance was rebuilt from scratch, restored into a different environment, or manually rotated), every existing tunnel JWT stops verifying. Regenerate enrollment for each affected node, update the agent compose file with the fresh token, and restart the agent containers. When `SENCHO_ENROLL_TOKEN` is present in the running container, the agent clears the stale `pilot.jwt` and re-enrolls on its own; you do not need to delete the data volume by hand.
|
||||
|
||||
## Self-signed control-instance TLS certificates
|
||||
|
||||
@@ -261,7 +261,7 @@ These environment variables are read by the **agent** container at boot.
|
||||
|---|---|---|---|
|
||||
| `SENCHO_MODE` | Yes | none | Must be `pilot`. Putting any other value here causes the container to start as a normal Sencho instance, not as an agent. |
|
||||
| `SENCHO_PRIMARY_URL` | Yes | none | The base URL of your control instance (e.g. `https://sencho.example.com`). The agent appends `/api/pilot/tunnel` and dials `wss://`. |
|
||||
| `SENCHO_ENROLL_TOKEN` | First boot only | none | The 15-minute enrollment token issued by the control instance. Ignored on subsequent boots once `pilot.jwt` is on disk. |
|
||||
| `SENCHO_ENROLL_TOKEN` | First boot; keep for recovery | none | The 15-minute enrollment token issued by the control instance. During normal operation the agent dials with `pilot.jwt` instead. If that persisted token is rejected at upgrade (for example after a secret rotation or node re-registration), the agent falls back to this value automatically when it is still present in the container environment, clears the stale file, and re-enrolls. Update it in compose after regenerating enrollment on the control instance. |
|
||||
| `SENCHO_PILOT_CA_FILE` | Optional | unset | Absolute path inside the container to a PEM bundle. Use when your control instance's TLS chain is rooted in a private CA. |
|
||||
| `DATA_DIR` | Optional | `/app/data` | Where the persisted `pilot.jwt` is stored. Override only if you are mounting a different volume layout. |
|
||||
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory where compose stack folders live. Enrollment sets it to the absolute path selected for the node and mounts that path identically on the host and in the agent. |
|
||||
@@ -281,7 +281,7 @@ These are the boundaries operators should know about before designing a fleet ar
|
||||
- **No mode conversion.** The Edit dialog shows a Mode field for an enrolled node, but switching a node between Pilot Agent and Distributed API Proxy after enrollment leaves the credentials and connection state inconsistent. To change modes, delete the node and re-create it in the desired mode.
|
||||
- **No audit log entries for enrollment lifecycle.** Node creation, enrollment regeneration, and node deletion do not write to the audit log today. This is on the roadmap.
|
||||
- **`pilot.jwt` is not cleaned up on node deletion.** When you delete a node from the control instance, the agent's persisted token stays on the remote's data volume. The agent will fail to reconnect on next restart, but the file persists. If you are repurposing the host, tear the agent down with `docker compose -f compose.yaml down -v` to remove the `sencho-agent-data` volume.
|
||||
- **JWT-secret rotation invalidates every tunnel.** Rebuilding the control instance from scratch or rotating `auth_jwt_secret` requires re-enrolling every agent. There is no out-of-band re-issuance flow.
|
||||
- **JWT-secret rotation invalidates existing tunnel JWTs.** Rebuilding the control instance from scratch or rotating `auth_jwt_secret` requires a fresh enrollment token on each agent. When that token is still in the container environment, the agent re-enrolls automatically without manual deletion of `pilot.jwt`.
|
||||
- **One tunnel per node.** Splitting a node's load across multiple control instances or running multiple agent containers against the same control instance for the same node is not supported.
|
||||
- **Mesh and pilot share the per-tunnel stream pool.** A node that runs heavy Sencho Mesh traffic counts those streams against the same 1024-stream cap as HTTP and WebSocket traffic.
|
||||
- **The agent has no UI of its own.** All operation flows through the control instance. The agent's container logs (`docker logs sencho-agent`) are the only direct visibility into agent-side behaviour.
|
||||
@@ -320,7 +320,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Control instance was restored from backup and the agent will not reconnect">
|
||||
The persisted tunnel credential is signed with the control instance's `auth_jwt_secret`. If that secret was not part of the backup (or has been rotated for any other reason), existing tunnels stop verifying. Regenerate enrollment for each affected node from Settings → Nodes and restart the agent containers so they consume the fresh tokens. There is no global re-issuance command.
|
||||
The persisted tunnel credential is signed with the control instance's `auth_jwt_secret`. If that secret was not part of the backup (or has been rotated for any other reason), existing tunnels stop verifying. Regenerate enrollment for each affected node from Settings → Nodes, update the agent compose file with the fresh `SENCHO_ENROLL_TOKEN`, and restart the agent containers. The agent removes the stale `pilot.jwt` and completes enrollment automatically when the new token is in the container environment.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="I deleted the node on the control instance but the agent keeps trying to reconnect">
|
||||
|
||||
@@ -28,7 +28,7 @@ Four chips sit below the search box. Each shows a live count to the right of its
|
||||
- **All**: every stack on the node.
|
||||
- **Up**: stacks that are running with nothing crashed. A stack whose only stopped container finished cleanly (an init job that exited without error) still counts as up.
|
||||
- **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below).
|
||||
- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance.
|
||||
- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. The Updates chip and the trailing update indicators on stack rows are controlled by the Image Update Checks [sidebar setting](/reference/settings#image-update-checks---sidebar). When the setting is off, the chip and indicators are hidden.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/sidebar/sidebar-filter-chips.png" alt="Filter chip row showing ALL (15), Up (15), Down (0), and Updates (1) with the Updates chip highlighted in orange and a collapse toggle icon on the right" />
|
||||
|
||||
@@ -203,7 +203,7 @@ The same links button appears on each container row and on the update cards in [
|
||||
|
||||
## Container health strip
|
||||
|
||||
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
|
||||
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" When the stack has multiple containers, a summary strip appears above the list showing total, running, paused, and unhealthy counts, along with a **Compact / Detailed** toggle. Compact mode shows status, name, uptime, port, and action buttons; detailed mode (the default) adds CPU, memory, and network I/O sparklines.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-view/containers.png" alt="CONTAINERS section showing a container card with health badge, uptime, port mapping, open link, and CPU, memory, and network stat tiles" />
|
||||
@@ -269,7 +269,9 @@ Each row maps one compose concept to the value it resolves to right now:
|
||||
|
||||
A footer card under the rows surfaces the first published port as a clickable **EXPOSED** link, so you can jump straight to the running app.
|
||||
|
||||
If an image update is available for the primary service, an inline banner appears below the rows with the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Major bumps show a rose banner and require explicit review before applying.
|
||||
If an image update is available for the primary service, or the stack declares one or more services with a local `build:` section, an inline banner appears below the rows. Registry updates show the version bump (`27.1.4 → 27.1.5`), risk classification (`safe · patch`, `minor`, or `major · review required`), and an **apply** button. Build-only stacks show **Rebuild available** with a **Rebuild & Update** button instead of a version bump. Mixed stacks (registry images plus local builds) show both signals. Major bumps show a rose banner and require explicit review before applying.
|
||||
|
||||
Rebuilds can take longer than a registry pull and depend on the local Dockerfile context, network access, and base-image availability. Atomic rollback restores compose and env files only; previously built image layers are not rolled back automatically.
|
||||
|
||||
### Activity
|
||||
|
||||
@@ -344,7 +346,7 @@ The stack header groups actions by frequency of use. The most common action is t
|
||||
|-----------|--------|---------|--------------|
|
||||
| Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. |
|
||||
| Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
|
||||
| Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. |
|
||||
| Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). |
|
||||
| Overflow | **Delete** | `down --volumes` + removes files | Stops and removes containers and volumes, then deletes the stack directory. |
|
||||
@@ -354,7 +356,7 @@ The stack header groups actions by frequency of use. The most common action is t
|
||||
| Placement | Button | Command | What it does |
|
||||
|-----------|--------|---------|--------------|
|
||||
| Primary | **Start** | `docker compose up -d` | Starts the stack. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` | Pulls the latest image tags and recreates containers. |
|
||||
| Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. |
|
||||
| Overflow | **Delete** | Removes files | Deletes the stack directory. |
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -64,7 +64,7 @@ The **Fleet** view is the multi-node command center. The masthead summarizes onl
|
||||
|
||||
The Fleet toolbar includes **Check Updates**, **Refresh**, and **Add node** for admins. The **Overview** tab supports search, sort, status filters, label filters, and a Grid or Topology view. Node cards show online state, resource use, container counts, version state, update actions, and direct drill-down into stacks on that node.
|
||||
|
||||
Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, mesh **Routing**, **Federation**, fleet **Actions**, and **Secrets**. Some fleet tabs require Admiral. See [Licensing](/features/licensing) for the full tier breakdown.
|
||||
Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, mesh **Routing**, **Federation**, fleet **Actions**, and **Secrets**. Routing and Secrets require Admiral. Federation placement (cordon and pin) is available on every tier. See [Licensing](/features/licensing) for the full tier breakdown.
|
||||
|
||||
## Resources, templates, and logs
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 257 KiB |
+17
-6
@@ -686,6 +686,9 @@ components:
|
||||
cronExpression:
|
||||
type: ["string", "null"]
|
||||
description: 5-field cron expression when mode is 'cron', null otherwise.
|
||||
sidebarIndicators:
|
||||
type: boolean
|
||||
description: Whether sidebar update-status indicators are enabled. Controlled by the `image_update_sidebar_indicators` global setting. Default is `false`.
|
||||
|
||||
responses:
|
||||
Unauthorized:
|
||||
@@ -2271,9 +2274,11 @@ paths:
|
||||
tags: [Nodes]
|
||||
summary: Register a new node
|
||||
description: |
|
||||
Adds a new local or remote node. Remote nodes require an API URL pointing to
|
||||
another Sencho instance and an API token for authentication.
|
||||
Requires `node:manage` permission.
|
||||
Adds a new local or remote node. Only one local node is allowed per instance;
|
||||
attempting to create a second local node returns 409. Remote nodes require
|
||||
an API URL pointing to another Sencho instance and an API token for
|
||||
authentication.
|
||||
Requires `node:manage` permission. Node type is immutable after creation.
|
||||
|
||||
**Note:** API tokens cannot manage nodes.
|
||||
requestBody:
|
||||
@@ -2329,7 +2334,7 @@ paths:
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"409":
|
||||
description: Node name already exists.
|
||||
description: Node name already exists, or a local node already exists.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -2359,7 +2364,7 @@ paths:
|
||||
operationId: updateNode
|
||||
tags: [Nodes]
|
||||
summary: Update node
|
||||
description: Updates node configuration. Requires `node:manage` permission.
|
||||
description: Updates node configuration. Node type is immutable after creation. Requires `node:manage` permission.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/idPath"
|
||||
requestBody:
|
||||
@@ -2406,7 +2411,7 @@ paths:
|
||||
operationId: deleteNode
|
||||
tags: [Nodes]
|
||||
summary: Delete node
|
||||
description: Removes a node from the fleet. Requires `node:manage` permission.
|
||||
description: Removes a node from the fleet. The last local node cannot be deleted. Requires `node:manage` permission.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/idPath"
|
||||
responses:
|
||||
@@ -2416,6 +2421,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessBoolean"
|
||||
"400":
|
||||
description: Cannot delete the only local node (or the default node).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"500":
|
||||
|
||||
@@ -465,6 +465,16 @@ Configure how often this node polls container registries to detect available ima
|
||||
|
||||
The section footer shows the last-checked timestamp and when the next check is scheduled.
|
||||
|
||||
### Sidebar
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Show update status in sidebar** | On | When on, the sidebar shows a pulsing dot on stacks with an available update, a warning icon when a check fails, and an Updates filter chip. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected. |
|
||||
|
||||
<Note>
|
||||
Nodes running older versions of Sencho do not expose this setting. Upgrade the node to enable the toggle.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
+13
-4
@@ -25,7 +25,7 @@ export function totpNow(secret: string): string {
|
||||
}
|
||||
|
||||
/** Selector for the dashboard - only present in EditorLayout, not on login/setup pages */
|
||||
const DASHBOARD_INDICATOR = 'img[src*="sencho-logo"]';
|
||||
const DASHBOARD_INDICATOR = 'img[src*="sencho-logo"], button:has-text("Create Stack")';
|
||||
|
||||
/** Returns true if the current page is the first-run setup screen */
|
||||
async function isSetupPage(page: Page): Promise<boolean> {
|
||||
@@ -39,7 +39,8 @@ async function isLoginPage(page: Page): Promise<boolean> {
|
||||
|
||||
/** Returns true if the dashboard (EditorLayout) is loaded */
|
||||
export async function isDashboard(page: Page): Promise<boolean> {
|
||||
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
|
||||
const indicator = page.locator(DASHBOARD_INDICATOR).first();
|
||||
return indicator.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +75,7 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
const enterButton = page.getByRole('button', { name: /enter sencho/i });
|
||||
await expect(enterButton).toBeVisible({ timeout: 10_000 });
|
||||
await enterButton.click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
await usernameField.fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
}
|
||||
// Fall through to the dashboard check below.
|
||||
@@ -104,6 +105,14 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
return;
|
||||
}
|
||||
|
||||
// Cookie session may still be restoring after a hard reload.
|
||||
try {
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'loginAs: could not determine page state - expected setup, login, or dashboard. ' +
|
||||
'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Sidebar stack-row truncation E2E tests.
|
||||
*
|
||||
* Verifies long stack names ellipsize instead of pushing trailing indicators
|
||||
* (update dot, check-failed icon, git-pending icon) past the sidebar edge.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
const LONG_STACK = 'e2e-tick-grafana-docker-observability-monitoring';
|
||||
const SHORT_STACK = 'e2e-z';
|
||||
const UPDATE_STACK = 'e2e-long-stack-update-dot-indicator';
|
||||
const FAILED_STACK = 'e2e-long-stack-check-failed-indicator';
|
||||
const GIT_STACK = 'e2e-long-stack-git-pending-indicator';
|
||||
|
||||
const TEST_STACKS = [LONG_STACK, SHORT_STACK, UPDATE_STACK, FAILED_STACK, GIT_STACK];
|
||||
|
||||
interface RowLayoutMetrics {
|
||||
stackName: string;
|
||||
sidebarRight: number;
|
||||
rowRight: number;
|
||||
rowWithinSidebar: boolean;
|
||||
nameTruncated: boolean;
|
||||
nameOverflowHidden: boolean;
|
||||
rowHasMinWidthZero: boolean;
|
||||
trailingRight: number | null;
|
||||
trailingWithinSidebar: boolean;
|
||||
trailingKind: 'update' | 'failed' | 'git' | 'none';
|
||||
}
|
||||
|
||||
async function createStack(page: Page, stackName: string): Promise<void> {
|
||||
const res = await page.evaluate(async (name) => {
|
||||
const response = await fetch('/api/stacks', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stackName: name }),
|
||||
});
|
||||
return { ok: response.ok, status: response.status };
|
||||
}, stackName);
|
||||
expect(res.ok || res.status === 409, `create ${stackName} failed: ${res.status}`).toBeTruthy();
|
||||
}
|
||||
|
||||
async function deleteStack(page: Page, stackName: string): Promise<void> {
|
||||
await page.evaluate(async (name) => {
|
||||
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).catch(() => undefined);
|
||||
}, stackName);
|
||||
}
|
||||
|
||||
async function deleteAllTestStacks(page: Page): Promise<void> {
|
||||
for (const name of TEST_STACKS) {
|
||||
await deleteStack(page, name);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTestStacks(page: Page): Promise<void> {
|
||||
await deleteAllTestStacks(page);
|
||||
for (const name of TEST_STACKS) {
|
||||
await createStack(page, name);
|
||||
}
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
}
|
||||
|
||||
function sidebarLocator(page: Page) {
|
||||
return page.locator('.w-64.border-r').first();
|
||||
}
|
||||
|
||||
async function measureRow(page: Page, stackName: string): Promise<RowLayoutMetrics> {
|
||||
const row = page.locator('[data-testid="stack-row"]').filter({ hasText: stackName });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
return row.evaluate((rowEl) => {
|
||||
const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r, .bg-sidebar');
|
||||
const sidebarRect = sidebar?.getBoundingClientRect();
|
||||
if (!sidebarRect) {
|
||||
throw new Error('Could not locate sidebar container');
|
||||
}
|
||||
|
||||
const nameEl = rowEl.querySelector('.truncate') as HTMLElement | null;
|
||||
const updateDot = rowEl.querySelector('[data-testid="stack-trailing-update"]');
|
||||
const failedIcon = rowEl.querySelector('[data-testid="stack-trailing-check-failed"]');
|
||||
const gitIcon = rowEl.querySelector('[data-testid="stack-trailing-git-pending"]');
|
||||
const trailing = updateDot ?? failedIcon ?? gitIcon;
|
||||
|
||||
let trailingKind: RowLayoutMetrics['trailingKind'] = 'none';
|
||||
if (updateDot) trailingKind = 'update';
|
||||
else if (failedIcon) trailingKind = 'failed';
|
||||
else if (gitIcon) trailingKind = 'git';
|
||||
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
const trailingRect = trailing?.getBoundingClientRect() ?? null;
|
||||
|
||||
return {
|
||||
stackName: nameEl?.textContent ?? '',
|
||||
sidebarRight: sidebarRect.right,
|
||||
rowRight: rowRect.right,
|
||||
rowWithinSidebar: rowRect.right <= sidebarRect.right + 0.5,
|
||||
nameTruncated: nameEl ? nameEl.scrollWidth > nameEl.clientWidth + 1 : false,
|
||||
nameOverflowHidden: nameEl ? getComputedStyle(nameEl).overflow === 'hidden' : false,
|
||||
rowHasMinWidthZero: rowEl.classList.contains('min-w-0'),
|
||||
trailingRight: trailingRect?.right ?? null,
|
||||
trailingWithinSidebar: trailingRect ? trailingRect.right <= sidebarRect.right + 0.5 : true,
|
||||
trailingKind,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRowLayout(
|
||||
page: Page,
|
||||
stackName: string,
|
||||
opts: { expectTruncated: boolean; trailingKind?: RowLayoutMetrics['trailingKind'] },
|
||||
): Promise<RowLayoutMetrics> {
|
||||
const metrics = await measureRow(page, stackName);
|
||||
expect(metrics.rowWithinSidebar, `${stackName} row clips past sidebar`).toBe(true);
|
||||
expect(metrics.nameOverflowHidden, `${stackName} name missing overflow hidden`).toBe(true);
|
||||
expect(metrics.rowHasMinWidthZero, `${stackName} row missing min-w-0`).toBe(true);
|
||||
expect(metrics.nameTruncated, `${stackName} truncation state`).toBe(opts.expectTruncated);
|
||||
if (opts.trailingKind) {
|
||||
expect(metrics.trailingKind, `${stackName} trailing indicator`).toBe(opts.trailingKind);
|
||||
expect(metrics.trailingWithinSidebar, `${stackName} trailing indicator clips`).toBe(true);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
test.describe('Sidebar stack name truncation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
await ensureTestStacks(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteAllTestStacks(page);
|
||||
});
|
||||
|
||||
test('long stack names truncate and stay within the sidebar', async ({ page }) => {
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('short stack names do not truncate unnecessarily', async ({ page }) => {
|
||||
await assertRowLayout(page, SHORT_STACK, { expectTruncated: false });
|
||||
});
|
||||
|
||||
test('update dot stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[UPDATE_STACK]: {
|
||||
hasUpdate: true,
|
||||
checkStatus: 'ok',
|
||||
lastError: null,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const detailResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/image-updates/detail') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await detailResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: UPDATE_STACK }).locator('[data-testid="stack-trailing-update"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, UPDATE_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
test('check-failed icon stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[FAILED_STACK]: {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
lastError: 'Registry unreachable',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const detailResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/image-updates/detail') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await detailResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: FAILED_STACK }).locator('[data-testid="stack-trailing-check-failed"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, FAILED_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('update dot wins over check-failed on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[UPDATE_STACK]: {
|
||||
hasUpdate: true,
|
||||
checkStatus: 'failed',
|
||||
lastError: 'Registry unreachable',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await assertRowLayout(page, UPDATE_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
test('git-pending icon stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/git-sources', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ stack_name: GIT_STACK, pending_commit_sha: 'abc123def456' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const gitResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/git-sources') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await gitResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: GIT_STACK }).locator('[data-testid="stack-trailing-git-pending"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, GIT_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
test('active row with a long name still truncates', async ({ page }) => {
|
||||
const row = page.locator('[data-testid="stack-row"]').filter({ hasText: LONG_STACK });
|
||||
await row.click();
|
||||
await expect(row).toHaveClass(/bg-accent/);
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('bulk mode checkbox does not break truncation on long names', async ({ page }) => {
|
||||
const bulkToggle = page.locator('button[aria-pressed]').filter({ has: page.locator('.lucide-layout-list') });
|
||||
await bulkToggle.click();
|
||||
await expect(bulkToggle).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const row = page.locator('[data-testid="stack-row"][data-bulk="true"]').filter({ hasText: LONG_STACK });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('[role="checkbox"]')).toBeVisible();
|
||||
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('all stack rows stay within the sidebar width', async ({ page }) => {
|
||||
const metrics = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r');
|
||||
const sidebarRect = sidebar?.getBoundingClientRect();
|
||||
if (!sidebarRect) return { ok: false, offenders: ['sidebar-missing'] };
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="stack-row"]'));
|
||||
const offenders: string[] = [];
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.right > sidebarRect.right + 0.5) {
|
||||
offenders.push(row.textContent?.trim().slice(0, 40) ?? 'unknown-row');
|
||||
}
|
||||
}
|
||||
return { ok: offenders.length === 0, offenders };
|
||||
});
|
||||
|
||||
expect(metrics.ok, `rows overflow sidebar: ${metrics.offenders.join(', ')}`).toBe(true);
|
||||
});
|
||||
|
||||
test('ScrollArea block mode constrains list width', async ({ page }) => {
|
||||
const viewportUsesBlock = await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-radix-scroll-area-viewport]');
|
||||
const inner = viewport?.firstElementChild as HTMLElement | null;
|
||||
if (!inner) return false;
|
||||
return getComputedStyle(inner).display === 'block';
|
||||
});
|
||||
expect(viewportUsesBlock).toBe(true);
|
||||
});
|
||||
|
||||
test('mobile viewport keeps long names truncated', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
const metrics = await measureRow(page, LONG_STACK);
|
||||
expect(metrics.nameTruncated).toBe(true);
|
||||
expect(metrics.rowWithinSidebar).toBe(true);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,8 @@ export default function EditorLayout() {
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
stackUpdates,
|
||||
fetchImageUpdates,
|
||||
sidebarIndicators,
|
||||
sidebarStackUpdates,
|
||||
pinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
@@ -669,7 +671,7 @@ export default function EditorLayout() {
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackCounts,
|
||||
stackUpdates,
|
||||
stackUpdates: sidebarStackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
isCollapsed,
|
||||
@@ -697,6 +699,7 @@ export default function EditorLayout() {
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkAction={handleBulkAction}
|
||||
showUpdatesChip={sidebarIndicators}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -777,6 +780,7 @@ export default function EditorLayout() {
|
||||
fleetTab={fleetTab}
|
||||
onFleetTabConsumed={() => setFleetTab(null)}
|
||||
renderEditor={renderEditor}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import ErrorBoundary from '../ErrorBoundary';
|
||||
import StackAnatomyPanel from '../StackAnatomyPanel';
|
||||
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
|
||||
import { MobileStackDetail } from './MobileStackDetail';
|
||||
import { RecoveryChip } from './RecoveryChip';
|
||||
@@ -355,7 +356,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Command Center Card (identity + health strip). Hidden when
|
||||
the logs are expanded so the logs pane fills the column. */}
|
||||
{!logsExpanded && (
|
||||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||||
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -407,6 +408,23 @@ export function EditorView(props: EditorViewProps) {
|
||||
panelStartedAt={panelStartedAt}
|
||||
variant="band"
|
||||
/>
|
||||
{safeContainers.length > 1 ? (
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
isAdmin={isAdmin}
|
||||
activeNode={activeNode}
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent className="p-4 pt-2">
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
@@ -417,12 +435,17 @@ export function EditorView(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Logs Section (fills remaining left-column height) */}
|
||||
{/* Logs Section (fills remaining left-column height). On multi-
|
||||
container stacks a min-h guarantees logs are never hidden. */}
|
||||
{safeContainers.length > 1 ? (
|
||||
<div className="flex-1 min-h-[180px] flex flex-col">
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
@@ -430,6 +453,16 @@ export function EditorView(props: EditorViewProps) {
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<StackLogsSection
|
||||
stackName={stackName}
|
||||
logsMode={logsMode}
|
||||
setLogsMode={setLogsMode}
|
||||
logsExpanded={logsExpanded}
|
||||
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
@@ -99,6 +100,7 @@ export interface ViewRouterProps {
|
||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||
// not on every parent render that lands on a different view.
|
||||
renderEditor: () => ReactNode;
|
||||
stackUpdates: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
export function ViewRouter({
|
||||
@@ -128,6 +130,7 @@ export function ViewRouter({
|
||||
fleetTab,
|
||||
onFleetTabConsumed,
|
||||
renderEditor,
|
||||
stackUpdates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
if (activeView === 'settings') {
|
||||
@@ -251,6 +254,7 @@ export function ViewRouter({
|
||||
onOpenSettingsSection={onOpenSettingsSection}
|
||||
notifications={notifications}
|
||||
onClearNotifications={onClearNotifications}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,3 +80,140 @@ describe('ContainersHealth published port link', () => {
|
||||
expect(screen.getByText(/8080 → 80\/tcp/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('density toggle and summary strip', () => {
|
||||
function makeContainer(overrides: Partial<ContainerInfo> = {}): ContainerInfo {
|
||||
return {
|
||||
Id: overrides.Id || 'abc',
|
||||
Names: overrides.Names || ['/app'],
|
||||
State: overrides.State || 'running',
|
||||
Status: overrides.Status || 'Up 1 hour',
|
||||
Image: overrides.Image || 'nginx',
|
||||
...overrides,
|
||||
} as unknown as ContainerInfo;
|
||||
}
|
||||
|
||||
function renderMany(containers: ContainerInfo[]) {
|
||||
return render(
|
||||
<ContainersHealth
|
||||
safeContainers={containers}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('does not render summary strip or density toggle for a single container', () => {
|
||||
renderMany([makeContainer()]);
|
||||
expect(screen.queryByText(/container/)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders summary counts for multiple containers', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running' }),
|
||||
makeContainer({ Id: 'b', State: 'running' }),
|
||||
makeContainer({ Id: 'c', State: 'paused' }),
|
||||
]);
|
||||
expect(screen.getByText(/3 containers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 up/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 paused/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows unhealthy count in summary', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running', healthStatus: 'healthy' }),
|
||||
makeContainer({ Id: 'b', State: 'running', healthStatus: 'unhealthy' }),
|
||||
]);
|
||||
expect(screen.getByText(/1 unhealthy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders density toggle buttons for multiple containers', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
expect(screen.getByRole('button', { name: 'Compact view' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Detailed view' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('detailed mode is the default', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
const detailed = screen.getByRole('button', { name: 'Detailed view' });
|
||||
expect(detailed).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('hides sparkline grids in compact mode', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
// Sparklines visible by default in detailed mode (two containers, two cpu labels)
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
// Sparkline labels hidden in compact mode
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows sparkline grids again when switching back to detailed', () => {
|
||||
renderMany([makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detailed view' }));
|
||||
expect(screen.getAllByText('cpu')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps header row actions visible in compact mode', () => {
|
||||
renderMany([
|
||||
makeContainer({ Id: 'a', State: 'running', Service: 'web' }),
|
||||
makeContainer({ Id: 'b', State: 'running' }),
|
||||
]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
// View logs button still present
|
||||
expect(screen.getAllByRole('button', { name: 'View logs' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders empty state for zero containers without summary strip', () => {
|
||||
renderMany([]);
|
||||
expect(screen.getByText(/no containers running/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Detailed view' })).toBeNull();
|
||||
});
|
||||
|
||||
it('resets density to detailed on remount (key change)', () => {
|
||||
const { unmount } = render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Id: 'a' }), makeContainer({ Id: 'b' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Switch to compact
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Compact view' }));
|
||||
expect(screen.queryByText('cpu')).toBeNull();
|
||||
|
||||
// Simulate navigating to a single-container stack (new key)
|
||||
unmount();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[makeContainer({ Id: 'x' })]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Density reset; single container shows sparklines
|
||||
expect(screen.getByText('cpu')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Compact view' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
CloudDownload,
|
||||
Layers,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -339,6 +341,9 @@ export function ContainersHealth({
|
||||
}: ContainersHealthProps) {
|
||||
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
|
||||
const copiedUrlTimerRef = useRef<number | null>(null);
|
||||
// Compact mode hides sparkline grids across all containers for a denser
|
||||
// list. Detailed mode (default) shows CPU / Mem / Net per container.
|
||||
const [density, setDensity] = useState<'compact' | 'detailed'>('detailed');
|
||||
useEffect(() => () => {
|
||||
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
|
||||
}, []);
|
||||
@@ -368,7 +373,46 @@ export function ContainersHealth({
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<>
|
||||
{/* Summary strip + density toggle appear only for multi-container
|
||||
stacks; single-container stacks keep the original layout. */}
|
||||
{safeContainers.length > 1 && (() => {
|
||||
const total = safeContainers.length;
|
||||
const running = safeContainers.filter(c => c.State === 'running').length;
|
||||
const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length;
|
||||
const paused = safeContainers.filter(c => c.State === 'paused').length;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-1 px-1">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
<span>{total} container{total !== 1 ? 's' : ''}</span>
|
||||
<span className="text-success/80">{running} up</span>
|
||||
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
|
||||
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
|
||||
</div>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('compact')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'compact'}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<List className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDensity('detailed')}
|
||||
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
|
||||
aria-pressed={density === 'detailed'}
|
||||
aria-label="Detailed view"
|
||||
>
|
||||
<Layers className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
@@ -517,7 +561,7 @@ export function ContainersHealth({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
{isActive && density === 'detailed' ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
@@ -552,6 +596,7 @@ export function ContainersHealth({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
|
||||
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
|
||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import type { StackAction, StackActionResult } from '../EditorView';
|
||||
import type { Label as StackLabel } from '../../label-types';
|
||||
@@ -60,6 +61,8 @@ export interface RemoteResult {
|
||||
files: Array<{ file: string; status: StackRowStatus }>;
|
||||
}
|
||||
|
||||
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
|
||||
|
||||
export function useStackListState() {
|
||||
const { nodes, activeNode } = useNodes();
|
||||
|
||||
@@ -96,7 +99,8 @@ export function useStackListState() {
|
||||
const [bulkMode, setBulkMode] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id);
|
||||
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
|
||||
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
|
||||
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
|
||||
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
||||
const { runBulk } = useBulkStackActions();
|
||||
@@ -295,16 +299,16 @@ export function useStackListState() {
|
||||
all: filteredFiles.length,
|
||||
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
||||
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
||||
updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length,
|
||||
}), [filteredFiles, stackStatuses, stackUpdates]);
|
||||
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
|
||||
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
const chipFilteredFiles = useMemo(() => {
|
||||
if (filterChip === 'all') return filteredFiles;
|
||||
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
||||
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate);
|
||||
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
|
||||
return filteredFiles;
|
||||
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
|
||||
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
|
||||
|
||||
const toggleBulkMode = useCallback(() => {
|
||||
setBulkMode(prev => {
|
||||
@@ -381,6 +385,15 @@ export function useStackListState() {
|
||||
});
|
||||
}, [remoteStackResults, nodes]);
|
||||
|
||||
// When the sidebar indicator toggle is turned off, reset an active Updates
|
||||
// filter to 'all' so the user is not stuck in a filter that shows nothing.
|
||||
useEffect(() => {
|
||||
if (!sidebarIndicators && filterChip === 'updates') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFilterChip('all');
|
||||
}
|
||||
}, [sidebarIndicators, filterChip]);
|
||||
|
||||
return {
|
||||
files, setFiles, filesNodeId,
|
||||
selectedFile, setSelectedFile,
|
||||
@@ -410,6 +423,7 @@ export function useStackListState() {
|
||||
scheduleStateInvalidateRefresh,
|
||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||
stackUpdates, fetchImageUpdates,
|
||||
sidebarIndicators, sidebarStackUpdates,
|
||||
pinned, pin, unpin, isPinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
|
||||
@@ -145,13 +145,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="deployments">
|
||||
<TabsHighlightItem value="deployments">
|
||||
<TabsTrigger value="deployments">
|
||||
<Send className="w-4 h-4 mr-1.5" />Deployments
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
@@ -159,13 +157,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsTrigger value="federation">
|
||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="actions">
|
||||
<TabsTrigger value="actions">
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
||||
@@ -274,11 +270,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="deployments">
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="routing">
|
||||
<PaidGate>
|
||||
@@ -286,13 +280,9 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="federation">
|
||||
<PaidGate>
|
||||
<FederationTab canManage={isAdmin} />
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="federation">
|
||||
<FederationTab canManage={isAdmin} />
|
||||
</TabsContent>
|
||||
<TabsContent value="actions">
|
||||
{/* Fleet Actions runs against the whole fleet, so it takes the
|
||||
unfiltered node list rather than the overview-filtered view. */}
|
||||
|
||||
@@ -21,8 +21,8 @@ import { formatBytes } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -71,15 +71,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const [cordonReason, setCordonReason] = useState('');
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
|
||||
// Cordon is a paid feature AND requires node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
|
||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
|
||||
// Cordon requires the paid tier AND node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid).
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
|
||||
@@ -61,6 +61,13 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a Community admin with node:manage when cordon requires Admiral', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// Cordon is Admiral-only; without edit/delete props, no menu items are available to Community.
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
@@ -69,7 +76,7 @@ describe('NodeCard', () => {
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
|
||||
it('exposes the cordon control for an Admiral node-admin via the node:manage permission', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import {
|
||||
HealthStatusBar,
|
||||
ResourceGauges,
|
||||
@@ -16,11 +17,12 @@ interface HomeDashboardProps {
|
||||
onOpenSettingsSection?: (section: SectionId) => void;
|
||||
notifications: NotificationItem[];
|
||||
onClearNotifications: () => void | Promise<void>;
|
||||
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) {
|
||||
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications, stackUpdates = {} }: HomeDashboardProps) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
@@ -49,6 +51,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
|
||||
metrics={data.metrics}
|
||||
stackCpuSeries={data.stackCpuSeries}
|
||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||
stackUpdates={stackUpdates}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
@@ -288,7 +288,7 @@ export function NodeManager() {
|
||||
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
|
||||
</Button>
|
||||
)}
|
||||
{node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
{node && !node.is_default && nodes.filter(n => n.type === 'local').length > 1 && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -324,6 +324,7 @@ export function NodeManager() {
|
||||
<TableBody>
|
||||
{nodes.map((node) => {
|
||||
const canManageThis = isAdmin || can('node:manage', 'node', String(node.id));
|
||||
const isLastLocal = node.type === 'local' && nodes.filter(n => n.type === 'local').length <= 1;
|
||||
return (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
@@ -493,7 +494,7 @@ export function NodeManager() {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{!node.is_default && canManageThis && (
|
||||
{!node.is_default && !isLastLocal && canManageThis && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -25,7 +25,7 @@ beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/preflight')) {
|
||||
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, findings: [] });
|
||||
return jsonRes({ stack: 'web', ranAt: 1, ranBy: 'x', renderable: true, renderError: null, status: 'high', highestSeverity: badgeSeverity, activeStatus: badgeSeverity === 'warning' ? 'warning' : 'high', activeHighestSeverity: badgeSeverity, activeCount: 0, acknowledgedCount: 0, findings: [] });
|
||||
}
|
||||
return jsonRes(null, false); // git-source, update-preview, scan-status
|
||||
});
|
||||
|
||||
@@ -23,16 +23,21 @@ import StackAnatomyPanel from './StackAnatomyPanel';
|
||||
|
||||
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
|
||||
|
||||
function previewBody(hasUpdate: boolean) {
|
||||
function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
|
||||
const hasBuild = buildServices.length > 0;
|
||||
return {
|
||||
build_services: buildServices,
|
||||
summary: {
|
||||
has_update: hasUpdate,
|
||||
primary_image: 'nginx',
|
||||
current_tag: '1.25',
|
||||
next_tag: '1.26',
|
||||
semver_bump: 'minor',
|
||||
update_kind: hasUpdate ? 'tag' : 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: hasBuild,
|
||||
rebuild_available: hasBuild,
|
||||
},
|
||||
changelog: null,
|
||||
};
|
||||
@@ -86,6 +91,21 @@ describe('StackAnatomyPanel update banner', () => {
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows Rebuild & Update for build-only stacks', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/update-preview')) return jsonRes(previewBody(false, ['app']));
|
||||
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
|
||||
return jsonRes(null, false);
|
||||
});
|
||||
|
||||
render(panel(false));
|
||||
|
||||
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Rebuild available/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Rebuild & Update' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the apply button and shows progress while applying', async () => {
|
||||
const onApply = vi.fn();
|
||||
const { rerender } = render(panel(false, onApply));
|
||||
|
||||
@@ -40,6 +40,7 @@ interface StackAnatomyPanelProps {
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
|
||||
interface UpdatePreviewSummary {
|
||||
has_update: boolean;
|
||||
@@ -47,12 +48,16 @@ interface UpdatePreviewSummary {
|
||||
current_tag: string | null;
|
||||
next_tag: string | null;
|
||||
semver_bump: SemverBump;
|
||||
update_kind?: UpdateKind;
|
||||
blocked: boolean;
|
||||
blocked_reason: string | null;
|
||||
has_build_services: boolean;
|
||||
rebuild_available: boolean;
|
||||
}
|
||||
|
||||
interface UpdatePreview {
|
||||
summary: UpdatePreviewSummary;
|
||||
build_services?: string[];
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
@@ -143,8 +148,9 @@ export default function StackAnatomyPanel({
|
||||
if (cancelled || !res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled) {
|
||||
setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
||||
setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined);
|
||||
setPreflightSeverity(typeof data?.activeHighestSeverity === 'string' ? data.activeHighestSeverity : null);
|
||||
const findings = Array.isArray(data?.findings) ? data.findings : undefined;
|
||||
setPreflightFindings(findings?.filter((f: { acknowledged?: boolean }) => !f.acknowledged));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); }
|
||||
@@ -337,6 +343,10 @@ export default function StackAnatomyPanel({
|
||||
|
||||
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
|
||||
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
|
||||
const showUpdateBanner = hasUpdate || rebuildAvailable;
|
||||
const updateKind = updatePreview?.summary.update_kind ?? 'none';
|
||||
const blocked = Boolean(updatePreview?.summary.blocked);
|
||||
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
|
||||
? 'danger'
|
||||
@@ -354,13 +364,29 @@ export default function StackAnatomyPanel({
|
||||
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
|
||||
const bannerLeadIn = blocked
|
||||
? 'review required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
: hasUpdate && updateKind === 'digest'
|
||||
? 'same-tag digest rebuild'
|
||||
: hasUpdate && hasBuildServices
|
||||
? 'registry update + local rebuild'
|
||||
: rebuildAvailable && !hasUpdate
|
||||
? 'local build / rebuild required'
|
||||
: bump === 'patch'
|
||||
? 'safe to apply'
|
||||
: bump === 'minor'
|
||||
? 'review recommended'
|
||||
: bump === 'major'
|
||||
? 'breaking changes possible'
|
||||
: '';
|
||||
const buildServiceNames = updatePreview?.build_services ?? [];
|
||||
const buildHint = hasBuildServices
|
||||
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
|
||||
: '';
|
||||
const gitRebuildHint = hasBuildServices && activeGitSource
|
||||
? 'After applying Git source changes, use Rebuild & Update to deploy the updated source.'
|
||||
: '';
|
||||
const applyLabel = hasBuildServices
|
||||
? (applying ? 'rebuilding...' : 'Rebuild & Update')
|
||||
: (applying ? 'applying...' : 'apply');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
@@ -531,13 +557,13 @@ export default function StackAnatomyPanel({
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{hasUpdate && updatePreview && (
|
||||
{showUpdateBanner && updatePreview && (
|
||||
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs uppercase tracking-wide">
|
||||
Update available
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
|
||||
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'}
|
||||
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && (
|
||||
<span className="text-foreground">
|
||||
{' · '}
|
||||
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
|
||||
@@ -550,6 +576,8 @@ export default function StackAnatomyPanel({
|
||||
{[
|
||||
bumpLabel,
|
||||
bannerLeadIn,
|
||||
buildHint,
|
||||
gitRebuildHint,
|
||||
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
@@ -567,7 +595,7 @@ export default function StackAnatomyPanel({
|
||||
onClick={onApplyUpdate}
|
||||
>
|
||||
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
|
||||
{applying ? 'applying...' : 'apply'}
|
||||
{applyLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -646,7 +674,7 @@ export default function StackAnatomyPanel({
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} />
|
||||
<PreflightPanel stackName={stackName} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{storageEnabled && (
|
||||
|
||||
@@ -16,13 +16,11 @@ import { BlueprintEmptyState } from './BlueprintEmptyState';
|
||||
import { FleetTabHeading, FleetEmptyState } from '../fleet/FleetEmptyState';
|
||||
import { BlueprintDetail } from './BlueprintDetail';
|
||||
import { BlueprintEditor } from './BlueprintEditor';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export function DeploymentsTab() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const canEdit = isPaid && isAdmin;
|
||||
const canEdit = isAdmin;
|
||||
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
|
||||
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline';
|
||||
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||
import { classifyRow, type RowState } from './classifyRow';
|
||||
|
||||
@@ -12,6 +13,7 @@ interface StackHealthTableProps {
|
||||
metrics: MetricPoint[];
|
||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||
}
|
||||
|
||||
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
|
||||
@@ -90,6 +92,7 @@ export function StackHealthTable({
|
||||
metrics,
|
||||
stackCpuSeries,
|
||||
onNavigateToStack,
|
||||
stackUpdates = {},
|
||||
}: StackHealthTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
// null = the default health-state ordering (worst first); a SortKey switches
|
||||
@@ -127,9 +130,10 @@ export function StackHealthTable({
|
||||
runningSince: entry.runningSince ?? null,
|
||||
source: entry.source ?? 'local',
|
||||
mainPort: entry.mainPort ?? null,
|
||||
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
|
||||
};
|
||||
});
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries]);
|
||||
}, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const list = [...baseRows];
|
||||
@@ -246,7 +250,14 @@ export function StackHealthTable({
|
||||
className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
|
||||
<span className="truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||
{row.hasUpdate && (
|
||||
<span className="shrink-0 rounded-full bg-update/15 px-2 py-0.5 font-mono text-[10px] leading-none text-update tracking-wide">
|
||||
Update available
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
|
||||
{row.source === 'git' ? 'Git' : 'Local'}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
@@ -159,9 +158,8 @@ function NodeDetail({
|
||||
onInspectStack: (nodeId: number, stackName: string) => void;
|
||||
onCordonChange: () => void;
|
||||
}) {
|
||||
const { isPaid } = useLicense();
|
||||
const { can } = useAuth();
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
|
||||
@@ -275,40 +275,55 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node-type">Type</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isEdit ? (
|
||||
<div className="flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-muted/50">
|
||||
{formData.type === 'local' ? (
|
||||
<><Monitor className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Local</span></>
|
||||
) : (
|
||||
<><Globe className="w-4 h-4 text-muted-foreground" /><span className="text-sm text-muted-foreground">Remote</span></>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.type}
|
||||
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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!nodes.some(n => n.type === 'local') && (
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{isEdit && (
|
||||
<p className="text-xs text-muted-foreground">Node type cannot be changed after creation.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.type === 'remote' && (
|
||||
@@ -581,9 +596,15 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
{deletingNode?.type === 'local' ? (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Deleting local node <span className="font-medium text-stat-value">{deletingNode?.name}</span> removes its schedules, labels, dossiers, findings, and other node-scoped data. Containers and compose files on the host are <strong>not</strong> affected. This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes <span className="font-medium text-stat-value">{deletingNode?.name}</span> from this console. The remote instance and its containers are not affected.
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,6 +13,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
||||
@@ -61,6 +63,49 @@ export function UpdatesSection() {
|
||||
|
||||
const intervalMinutes = status?.intervalMinutes ?? null;
|
||||
|
||||
// Mirror activeNode.id in a ref so the PATCH handler can detect a node
|
||||
// switch mid-flight and discard a stale write.
|
||||
const activeNodeIdRef = useRef(activeNode?.id ?? null);
|
||||
activeNodeIdRef.current = activeNode?.id ?? null;
|
||||
|
||||
// Derive toggle state from the current status. When the field is missing
|
||||
// (older remote node) the toggle is disabled with a helpful message.
|
||||
const sidebarIndicators = status?.sidebarIndicators ?? false;
|
||||
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
|
||||
|
||||
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
|
||||
const targetNodeId = activeNodeIdRef.current;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
nodeId: targetNodeId ?? null,
|
||||
body: JSON.stringify({ image_update_sidebar_indicators: next ? '1' : '0' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
// Guard: if the active node changed while the PATCH was in flight,
|
||||
// discard the response — it belongs to a different node.
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: ['image_update_sidebar_indicators'] },
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Only surface the error if the active node hasn't changed. A
|
||||
// stale failure from node A must not toast while the user views
|
||||
// node B.
|
||||
if (activeNodeIdRef.current === targetNodeId) {
|
||||
toast.error((e as Error)?.message || 'Failed to update sidebar indicator setting.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMastheadStats(
|
||||
isLoading || intervalMinutes == null
|
||||
? null
|
||||
@@ -70,6 +115,7 @@ export function UpdatesSection() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
setStatus(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/status');
|
||||
@@ -280,6 +326,25 @@ export function UpdatesSection() {
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Sidebar" kicker="node-scoped">
|
||||
<SettingsField
|
||||
label="Show update status in sidebar"
|
||||
helper={
|
||||
status !== null && status.sidebarIndicators === undefined
|
||||
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
|
||||
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
|
||||
}
|
||||
htmlFor="sidebar-indicators-toggle"
|
||||
>
|
||||
<TogglePill
|
||||
id="sidebar-indicators-toggle"
|
||||
checked={sidebarIndicators}
|
||||
onChange={handleSidebarIndicatorsChange}
|
||||
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
group: 'automation',
|
||||
label: 'Image update checks',
|
||||
description: 'How often this node polls registries to detect available image updates and raise notifications.',
|
||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'],
|
||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PatchableSettings {
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
env_block_deploy_on_missing_required?: '0' | '1';
|
||||
image_update_sidebar_indicators?: '0' | '1';
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
image_update_sidebar_indicators: '1',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
@@ -15,6 +15,7 @@ interface SidebarFilterChipsProps {
|
||||
onChange: (chip: FilterChip) => void;
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
showUpdatesChip?: boolean;
|
||||
}
|
||||
|
||||
const chips: { id: FilterChip; label: string }[] = [
|
||||
@@ -24,12 +25,13 @@ const chips: { id: FilterChip; label: string }[] = [
|
||||
{ id: 'updates', label: 'Updates' },
|
||||
];
|
||||
|
||||
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) {
|
||||
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle, showUpdatesChip = true }: SidebarFilterChipsProps) {
|
||||
const visibleChips = showUpdatesChip ? chips : chips.filter(c => c.id !== 'updates');
|
||||
return (
|
||||
<div className="flex items-center pb-1.5 pt-0.5 pl-2">
|
||||
{visible ? (
|
||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||
{chips.map(({ id, label }) => {
|
||||
{visibleChips.map(({ id, label }) => {
|
||||
const count = counts[id];
|
||||
const displayCount = count > 99 ? '99+' : count;
|
||||
const isActive = active === id;
|
||||
|
||||
@@ -205,7 +205,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
<CommandItem
|
||||
value={file}
|
||||
onSelect={() => onSelectFile(file)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
className="min-w-0 w-full p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<StackRow
|
||||
file={file}
|
||||
|
||||
@@ -104,11 +104,11 @@ export function StackRow(props: StackRowProps) {
|
||||
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
|
||||
|
||||
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
|
||||
{hasUpdate ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<span className="relative inline-flex w-2 h-2">
|
||||
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
|
||||
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
|
||||
<span className="relative w-2 h-2 rounded-full bg-update" />
|
||||
</span>
|
||||
@@ -117,12 +117,12 @@ export function StackRow(props: StackRowProps) {
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
<RowTooltip
|
||||
trigger={<span><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
trigger={<span data-testid="stack-trailing-check-failed"><AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} /></span>}
|
||||
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
|
||||
/>
|
||||
) : hasGitPending ? (
|
||||
<RowTooltip
|
||||
trigger={<span><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
|
||||
trigger={<span data-testid="stack-trailing-git-pending"><GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} /></span>}
|
||||
label="Git source update pending"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface StackSidebarProps {
|
||||
onToggleSelect: (file: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkAction: (action: BulkAction) => void;
|
||||
showUpdatesChip?: boolean;
|
||||
}
|
||||
|
||||
export function StackSidebar(props: StackSidebarProps) {
|
||||
@@ -41,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
||||
list, activitySummary, onActivityAction,
|
||||
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||
showUpdatesChip = true,
|
||||
} = props;
|
||||
|
||||
const [filtersVisible, setFiltersVisible] = useState(() => {
|
||||
@@ -84,6 +86,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
onChange={onFilterChipChange}
|
||||
visible={filtersVisible}
|
||||
onToggle={handleToggleFilters}
|
||||
showUpdatesChip={showUpdatesChip}
|
||||
/>
|
||||
{selectedFiles.size > 0 && (
|
||||
<SidebarBulkBar
|
||||
@@ -92,7 +95,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
onClear={onClearSelection}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="flex-1 px-2 pb-2">
|
||||
<ScrollArea block className="flex-1 px-2 pb-2">
|
||||
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
|
||||
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
|
||||
</div>
|
||||
|
||||
@@ -121,4 +121,11 @@ describe('StackRow', () => {
|
||||
expect(container.querySelector('.lucide-alert-circle')).toBeNull();
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
|
||||
it('constrains long stack names so trailing indicators stay in the row', () => {
|
||||
const longName = 'tick-grafana-docker-observability-stack';
|
||||
render(<StackRow {...base({ displayName: longName })} />);
|
||||
expect(screen.getByTestId('stack-row')).toHaveClass('min-w-0');
|
||||
expect(screen.getByText(longName)).toHaveClass('truncate');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const sidebarRowBase = cn(
|
||||
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
|
||||
'relative flex items-center gap-2 w-full min-w-0 px-2 py-1.5 rounded-md mb-0.5',
|
||||
// 44px tap target on touch viewports without changing desktop density.
|
||||
'max-md:min-h-11 max-md:py-2.5',
|
||||
'font-mono text-[13px] text-muted-foreground',
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Finding {
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
acknowledged?: boolean;
|
||||
}
|
||||
interface Report {
|
||||
stack: string;
|
||||
@@ -31,11 +32,30 @@ interface Report {
|
||||
renderError: string | null;
|
||||
status: string;
|
||||
highestSeverity: string | null;
|
||||
activeStatus: string;
|
||||
activeHighestSeverity: string | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
findings: Finding[];
|
||||
}
|
||||
|
||||
function report(partial: Partial<Report>): Report {
|
||||
return { stack: 'web', ranAt: 1000, ranBy: 'admin', renderable: true, renderError: null, status: 'pass', highestSeverity: null, findings: [], ...partial };
|
||||
const base: Report = {
|
||||
stack: 'web', ranAt: 1000, ranBy: 'admin',
|
||||
renderable: true, renderError: null,
|
||||
status: 'pass', highestSeverity: null,
|
||||
activeStatus: 'pass', activeHighestSeverity: null,
|
||||
activeCount: 0, acknowledgedCount: 0,
|
||||
findings: [],
|
||||
};
|
||||
const merged = { ...base, ...partial };
|
||||
// Derive activeStatus/activeHighestSeverity/activeCount from the old
|
||||
// fields when the caller only set those (so existing tests work without
|
||||
// every call site listing the new field names).
|
||||
if (partial.status !== undefined && partial.activeStatus === undefined) merged.activeStatus = merged.status;
|
||||
if (partial.highestSeverity !== undefined && partial.activeHighestSeverity === undefined) merged.activeHighestSeverity = merged.highestSeverity;
|
||||
if (partial.findings !== undefined && partial.activeCount === undefined) merged.activeCount = merged.findings.filter(f => !f.acknowledged).length;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function jsonRes(body: unknown, ok = true) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
|
||||
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X,
|
||||
ChevronDown, ChevronRight, ShieldCheck, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,10 +9,14 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
|
||||
type PreflightStatus = 'never-run' | 'pass' | 'unrenderable' | PreflightSeverity;
|
||||
type PreflightAckExpiryMode = 'forever' | 'until_compose_change' | 'days' | 'until_image_change';
|
||||
|
||||
interface PreflightFinding {
|
||||
ruleId: string;
|
||||
@@ -21,6 +26,10 @@ interface PreflightFinding {
|
||||
sourcePath?: string;
|
||||
remediation?: string;
|
||||
service?: string;
|
||||
acknowledged?: boolean;
|
||||
acknowledgementId?: number;
|
||||
acknowledgementReason?: string;
|
||||
acknowledgementExpiry?: PreflightAckExpiryMode;
|
||||
}
|
||||
|
||||
interface PreflightReport {
|
||||
@@ -31,10 +40,15 @@ interface PreflightReport {
|
||||
renderError: string | null;
|
||||
status: PreflightStatus;
|
||||
highestSeverity: PreflightSeverity | null;
|
||||
activeStatus: PreflightStatus;
|
||||
activeHighestSeverity: PreflightSeverity | null;
|
||||
activeCount: number;
|
||||
acknowledgedCount: number;
|
||||
findings: PreflightFinding[];
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const MODAL_FIELD_LABEL = LABEL_CLASS;
|
||||
const ACTION_CLASS =
|
||||
'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40';
|
||||
const CARD_CLASS = 'rounded-lg border px-3 py-2.5';
|
||||
@@ -48,7 +62,13 @@ const SEVERITY_META: Record<PreflightSeverity, { label: string; icon: LucideIcon
|
||||
|
||||
const GROUP_ORDER: PreflightSeverity[] = ['blocker', 'high', 'warning', 'info'];
|
||||
|
||||
/** The header summary card: a single read on the overall result. */
|
||||
const EXPIRY_LABELS: Record<PreflightAckExpiryMode, string> = {
|
||||
forever: 'Forever',
|
||||
until_compose_change: 'Until Compose changes',
|
||||
days: '30 days',
|
||||
until_image_change: 'Until image changes',
|
||||
};
|
||||
|
||||
function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon; tone: string; line: string } {
|
||||
if (!report.renderable) {
|
||||
return {
|
||||
@@ -58,26 +78,61 @@ function summaryMeta(report: PreflightReport): { label: string; icon: LucideIcon
|
||||
line: report.renderError ?? 'Sencho could not render the effective Compose model.',
|
||||
};
|
||||
}
|
||||
if (report.findings.length === 0) {
|
||||
if (report.activeCount === 0 && report.acknowledgedCount === 0) {
|
||||
return { label: 'all clear', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'No issues found in the effective model.' };
|
||||
}
|
||||
const meta = SEVERITY_META[report.highestSeverity ?? 'info'];
|
||||
const counts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => f.severity === sev).length }))
|
||||
const meta = SEVERITY_META[report.activeHighestSeverity ?? 'info'];
|
||||
const activeParts = GROUP_ORDER
|
||||
.map(sev => ({ sev, n: report.findings.filter(f => !f.acknowledged && f.severity === sev).length }))
|
||||
.filter(c => c.n > 0)
|
||||
.map(c => `${c.n} ${SEVERITY_META[c.sev].label}`)
|
||||
.join(' · ');
|
||||
return { label: meta.label, icon: meta.icon, tone: meta.tone, line: counts };
|
||||
const line = report.acknowledgedCount > 0
|
||||
? `${report.activeCount} active${activeParts ? ` (${activeParts})` : ''} · ${report.acknowledgedCount} acknowledged`
|
||||
: (activeParts || `${report.activeCount} active`);
|
||||
return { label: report.activeCount === 0 ? 'acknowledged' : meta.label, icon: report.activeCount === 0 ? ShieldCheck : meta.icon, tone: report.activeCount === 0 ? 'border-muted bg-card/40 text-stat-subtitle' : meta.tone, line };
|
||||
}
|
||||
|
||||
function FindingRow({ finding }: { finding: PreflightFinding }) {
|
||||
function expiryComboboxOptions(finding: PreflightFinding): ComboboxOption[] {
|
||||
const options: ComboboxOption[] = [
|
||||
{ value: 'forever', label: EXPIRY_LABELS.forever },
|
||||
{ value: 'until_compose_change', label: EXPIRY_LABELS.until_compose_change },
|
||||
{ value: 'days', label: EXPIRY_LABELS.days },
|
||||
];
|
||||
if (finding.service) {
|
||||
options.push({ value: 'until_image_change', label: EXPIRY_LABELS.until_image_change });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function FindingRow({
|
||||
finding,
|
||||
canEdit,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
finding: PreflightFinding;
|
||||
canEdit: boolean;
|
||||
onAcknowledge?: (finding: PreflightFinding) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{finding.service}</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
|
||||
</div>
|
||||
{canEdit && onAcknowledge && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`preflight-ack-btn-${finding.ruleId}-${finding.service ?? 'stack'}`}
|
||||
onClick={() => onAcknowledge(finding)}
|
||||
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
|
||||
>
|
||||
acknowledge
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/90">{finding.title}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-relaxed text-foreground/80">{finding.message}</div>
|
||||
{finding.remediation && (
|
||||
@@ -92,7 +147,50 @@ function FindingRow({ finding }: { finding: PreflightFinding }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
function AcknowledgedRow({
|
||||
finding,
|
||||
canEdit,
|
||||
onClear,
|
||||
}: {
|
||||
finding: PreflightFinding;
|
||||
canEdit: boolean;
|
||||
onClear: (finding: PreflightFinding) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-muted py-2 first:border-t-0 opacity-80">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{finding.service && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-stat-subtitle">{finding.service}</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-foreground/80">{finding.title}</span>
|
||||
</div>
|
||||
{finding.acknowledgementReason && (
|
||||
<div className="mt-1 text-[11px] text-stat-subtitle">{finding.acknowledgementReason}</div>
|
||||
)}
|
||||
{finding.acknowledgementExpiry && (
|
||||
<div className="mt-0.5 font-mono text-[10px] text-stat-subtitle">
|
||||
expires: {EXPIRY_LABELS[finding.acknowledgementExpiry]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && finding.acknowledgementId != null && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`preflight-clear-ack-${finding.acknowledgementId}`}
|
||||
onClick={() => onClear(finding)}
|
||||
className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PreflightPanel({ stackName, canEdit = false }: { stackName: string; canEdit?: boolean }) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [report, setReport] = useState<PreflightReport | null>(null);
|
||||
@@ -100,9 +198,29 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [ackOpen, setAckOpen] = useState(false);
|
||||
const [ackTarget, setAckTarget] = useState<PreflightFinding | null>(null);
|
||||
const [ackReason, setAckReason] = useState('');
|
||||
const [ackExpiry, setAckExpiry] = useState<PreflightAckExpiryMode>('forever');
|
||||
const [ackSaving, setAckSaving] = useState(false);
|
||||
const [clearTarget, setClearTarget] = useState<PreflightFinding | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [ackSectionOpen, setAckSectionOpen] = useState(false);
|
||||
|
||||
const refreshReport = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to refresh the preflight report.');
|
||||
return;
|
||||
}
|
||||
setReport((await res.json()) as PreflightReport);
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
toast.error('Failed to refresh the preflight report.');
|
||||
}
|
||||
};
|
||||
|
||||
// Passive load of the last stored run when the stack or active node changes.
|
||||
// Read-only: opening the tab never renders or stores anything.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
@@ -131,7 +249,6 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, reloadKey]);
|
||||
|
||||
// Running preflight renders the effective model and stores the result.
|
||||
const runPreflight = async () => {
|
||||
setRunning(true);
|
||||
try {
|
||||
@@ -149,13 +266,83 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const activeFindings = useMemo(
|
||||
() => report?.findings.filter(f => !f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
const acknowledgedFindings = useMemo(
|
||||
() => report?.findings.filter(f => f.acknowledged) ?? [],
|
||||
[report?.findings],
|
||||
);
|
||||
|
||||
const summary = report && report.status !== 'never-run' ? summaryMeta(report) : null;
|
||||
const SummaryIcon = summary?.icon;
|
||||
const busy = loading || running;
|
||||
|
||||
// Dismiss the result banner (and the Doctor tab dot) until the findings change.
|
||||
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, report?.findings);
|
||||
const hasFindings = (report?.findings.length ?? 0) > 0;
|
||||
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, activeFindings);
|
||||
const hasActiveFindings = activeFindings.length > 0;
|
||||
|
||||
const openAckDialog = (finding: PreflightFinding) => {
|
||||
setAckTarget(finding);
|
||||
setAckReason('');
|
||||
setAckExpiry('forever');
|
||||
setAckOpen(true);
|
||||
};
|
||||
|
||||
const submitAck = async () => {
|
||||
if (!ackTarget) return;
|
||||
setAckSaving(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/preflight/acknowledgements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ruleId: ackTarget.ruleId,
|
||||
service: ackTarget.service ?? null,
|
||||
reason: ackReason.trim(),
|
||||
expiryMode: ackExpiry,
|
||||
expiresInDays: ackExpiry === 'days' ? 30 : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(data.error ?? 'Failed to acknowledge the finding.');
|
||||
return;
|
||||
}
|
||||
setAckOpen(false);
|
||||
setAckTarget(null);
|
||||
await refreshReport();
|
||||
toast.success('Finding acknowledged.');
|
||||
} catch {
|
||||
toast.error('Failed to acknowledge the finding.');
|
||||
} finally {
|
||||
setAckSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmClear = async () => {
|
||||
if (!clearTarget?.acknowledgementId) return;
|
||||
setClearing(true);
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${stackName}/preflight/acknowledgements/${clearTarget.acknowledgementId}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
toast.error('Failed to clear the acknowledgement.');
|
||||
return;
|
||||
}
|
||||
setClearTarget(null);
|
||||
await refreshReport();
|
||||
toast.success('Acknowledgement cleared.');
|
||||
} catch {
|
||||
toast.error('Failed to clear the acknowledgement.');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ackExpiryOptions = ackTarget ? expiryComboboxOptions(ackTarget) : [{ value: 'forever', label: EXPIRY_LABELS.forever }];
|
||||
|
||||
return (
|
||||
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
@@ -198,8 +385,8 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
) : (
|
||||
<>
|
||||
{summary && SummaryIcon && !dismissed && (
|
||||
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasFindings && (
|
||||
<div data-testid="preflight-status" data-status={report.activeStatus} className={cn(CARD_CLASS, summary.tone, 'relative')}>
|
||||
{hasActiveFindings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
@@ -225,19 +412,124 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
|
||||
)}
|
||||
|
||||
{GROUP_ORDER.map(sev => {
|
||||
const items = report.findings.filter(f => f.severity === sev);
|
||||
const items = activeFindings.filter(f => f.severity === sev);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={sev}>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>{SEVERITY_META[sev].label} · {items.length}</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{items.map((f, i) => <FindingRow key={`${f.ruleId}-${f.service ?? ''}-${i}`} finding={f} />)}
|
||||
{items.map((f, i) => (
|
||||
<FindingRow
|
||||
key={`${f.ruleId}-${f.service ?? ''}-${i}`}
|
||||
finding={f}
|
||||
canEdit={canEdit}
|
||||
onAcknowledge={openAckDialog}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{acknowledgedFindings.length > 0 && (
|
||||
<section data-testid="preflight-acknowledged-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAckSectionOpen(v => !v)}
|
||||
className={cn(LABEL_CLASS, 'mb-1.5 inline-flex items-center gap-1 hover:text-brand')}
|
||||
>
|
||||
{ackSectionOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
acknowledged · {acknowledgedFindings.length}
|
||||
</button>
|
||||
{ackSectionOpen && (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{acknowledgedFindings.map((f, i) => (
|
||||
<AcknowledgedRow
|
||||
key={`ack-${f.acknowledgementId ?? i}`}
|
||||
finding={f}
|
||||
canEdit={canEdit}
|
||||
onClear={setClearTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={ackOpen} onOpenChange={setAckOpen} size="md">
|
||||
<ModalHeader
|
||||
kicker="COMPOSE DOCTOR · ACKNOWLEDGE"
|
||||
title="Accept this finding"
|
||||
description="Accept a Compose Doctor finding for this stack."
|
||||
/>
|
||||
<ModalBody>
|
||||
{ackTarget && (
|
||||
<div className="rounded-md border border-card-border bg-card/40 px-3 py-2 font-mono text-[12px] text-foreground/80">
|
||||
{ackTarget.title}
|
||||
{ackTarget.service ? ` · ${ackTarget.service}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preflight-ack-reason" className={MODAL_FIELD_LABEL}>Note (optional)</Label>
|
||||
<textarea
|
||||
id="preflight-ack-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/50"
|
||||
placeholder="Why is this finding acceptable for this stack?"
|
||||
value={ackReason}
|
||||
onChange={(e) => setAckReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preflight-ack-expiry" className={MODAL_FIELD_LABEL}>Show again when</Label>
|
||||
<Combobox
|
||||
id="preflight-ack-expiry"
|
||||
options={ackExpiryOptions}
|
||||
value={ackExpiry}
|
||||
onValueChange={(value) => setAckExpiry(value as PreflightAckExpiryMode)}
|
||||
placeholder="Select expiry"
|
||||
disabled={ackSaving}
|
||||
className="w-full"
|
||||
/>
|
||||
{ackExpiry === 'until_image_change' && (
|
||||
<p className="text-[11px] leading-relaxed text-stat-subtitle">
|
||||
Re-surfaces when the service image reference changes in the effective model, not on silent digest re-pulls.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
hint="SHOW AGAIN"
|
||||
hintAccent={EXPIRY_LABELS[ackExpiry]}
|
||||
secondary={(
|
||||
<Button variant="outline" size="sm" onClick={() => setAckOpen(false)} disabled={ackSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
primary={(
|
||||
<Button size="sm" onClick={submitAck} disabled={ackSaving}>
|
||||
{ackSaving ? 'Saving…' : 'Acknowledge'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={clearTarget !== null}
|
||||
onOpenChange={(open) => { if (!open) setClearTarget(null); }}
|
||||
kicker="COMPOSE DOCTOR · CLEAR"
|
||||
title="Clear acknowledgement"
|
||||
description="Clear a Compose Doctor acknowledgement for this stack."
|
||||
hint="RESTORES active finding"
|
||||
confirmLabel="Clear"
|
||||
confirming={clearing}
|
||||
onConfirm={confirmClear}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
This finding will count as active again on the next preflight read.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
|
||||
|
||||
const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -10,55 +11,128 @@ const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
||||
* `refresh()` to force a refetch (e.g. after a deploy or a manual
|
||||
* registry-check trigger).
|
||||
*
|
||||
* Extracted from EditorLayout so the polling lifecycle and its state
|
||||
* live next to each other instead of being spread across a 3000-line
|
||||
* component. The dependency on `apiFetch` keeps the call routed
|
||||
* through the active-node header just like before.
|
||||
* Also owns the sidebar-indicator toggle preference, fetched from
|
||||
* /api/image-updates/status on the same cadence. All requests are
|
||||
* pinned to the captured node so a mid-flight node switch never
|
||||
* writes stale data.
|
||||
*/
|
||||
export function useImageUpdates(activeNodeId: number | undefined) {
|
||||
const [stackUpdates, setStackUpdates] = useState<Record<string, StackUpdateInfo>>({});
|
||||
const [sidebarIndicators, setSidebarIndicators] = useState(false);
|
||||
|
||||
// Track which node owns the current state. When activeNodeId changes
|
||||
// React renders once with the old owner before the passive effect clears
|
||||
// the data. Returning empty defaults when the IDs mismatch prevents a
|
||||
// single-frame flash of the wrong node's data.
|
||||
const [ownerNodeId, setOwnerNodeId] = useState<number | undefined>(activeNodeId);
|
||||
|
||||
// Generation counter: every activeNodeId change increments it, and every
|
||||
// await is gated against it so a slow response from a previous node is
|
||||
// discarded.
|
||||
const genRef = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/detail');
|
||||
if (res.ok) {
|
||||
setStackUpdates(await res.json() as Record<string, StackUpdateInfo>);
|
||||
return;
|
||||
}
|
||||
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
||||
// map so update badges keep working until that node is upgraded.
|
||||
if (res.status === 404) {
|
||||
const boolRes = await apiFetch('/image-updates');
|
||||
if (boolRes.ok) {
|
||||
const bool = await boolRes.json() as Record<string, boolean>;
|
||||
const synthesized: Record<string, StackUpdateInfo> = {};
|
||||
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
||||
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
||||
}
|
||||
setStackUpdates(synthesized);
|
||||
const gen = ++genRef.current;
|
||||
const targetNodeId = activeNodeId ?? null;
|
||||
|
||||
// Self-contained status helper: owns fetch, parse, and state write.
|
||||
// A failure here never blocks the detail path below.
|
||||
const fetchStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json() as ImageUpdateStatus;
|
||||
if (genRef.current !== gen) return;
|
||||
setSidebarIndicators(data.sidebarIndicators ?? false);
|
||||
} else {
|
||||
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
||||
console.error('[ImageUpdates] status fetch returned', res.status);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('[ImageUpdates] status fetch failed:', e);
|
||||
}
|
||||
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
||||
// the last-known state on screen, but do not let the failure go silent.
|
||||
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Self-contained detail helper: owns fetch, parse, 404 fallback, and
|
||||
// state write. A failure here never blocks the status path above.
|
||||
const fetchDetail = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (res.ok) {
|
||||
const data = await res.json() as Record<string, StackUpdateInfo>;
|
||||
if (genRef.current !== gen) return;
|
||||
setStackUpdates(data);
|
||||
return;
|
||||
}
|
||||
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
||||
// map so update badges keep working until that node is upgraded.
|
||||
if (res.status === 404) {
|
||||
const boolRes = await apiFetch('/image-updates', { nodeId: targetNodeId });
|
||||
if (genRef.current !== gen) return;
|
||||
if (boolRes.ok) {
|
||||
const bool = await boolRes.json() as Record<string, boolean>;
|
||||
if (genRef.current !== gen) return;
|
||||
const synthesized: Record<string, StackUpdateInfo> = {};
|
||||
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
||||
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
||||
}
|
||||
setStackUpdates(synthesized);
|
||||
} else {
|
||||
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
||||
// the last-known state on screen, but do not let the failure go silent.
|
||||
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.allSettled([fetchStatus(), fetchDetail()]);
|
||||
}, [activeNodeId]);
|
||||
|
||||
// Pin the interval to the latest closure without retriggering it on
|
||||
// every render the way putting `refresh` into the deps array would.
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
// Poll on mount and on node change. Reset state and capture the owning
|
||||
// node BEFORE fetching so the old node's data is cleared before the new
|
||||
// node's first response arrives, and the guard above returns empty defaults
|
||||
// on the render before this effect fires.
|
||||
useEffect(() => {
|
||||
genRef.current += 1;
|
||||
setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
void refreshRef.current();
|
||||
const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [activeNodeId]);
|
||||
|
||||
return { stackUpdates, refresh };
|
||||
// React to settings changes so toggling the sidebar-indicator preference
|
||||
// propagates immediately without waiting for the 5-minute poll.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail;
|
||||
if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) {
|
||||
refreshRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||
}, []);
|
||||
|
||||
// Return empty defaults until the owning node matches the active node.
|
||||
// This prevents React from rendering node B with node A's update data and
|
||||
// sidebar preference during the single frame before the passive effect fires.
|
||||
const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId;
|
||||
return {
|
||||
stackUpdates: isOwner ? stackUpdates : {} as Record<string, StackUpdateInfo>,
|
||||
refresh,
|
||||
sidebarIndicators: isOwner ? sidebarIndicators : false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ImageUpdateStatus {
|
||||
mode: 'interval' | 'cron';
|
||||
/** 5-field cron expression when mode is 'cron', null otherwise. */
|
||||
cronExpression: string | null;
|
||||
/** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */
|
||||
sidebarIndicators?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user