mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: move Blueprint orchestration and Federation placement to Community tier (#1555)
* feat: move core Blueprint orchestration to Community tier Blueprints CRUD, reconciliation, and drift modes are now available on Community. Pin remains Admiral-only via Federation placement controls. * feat: move Federation placement controls to Community tier Remove requirePaid from cordon, uncordon, and blueprint pin routes. Ungate the Federation tab and gate cordon UI on node:manage only. Update licensing and fleet docs for the new tier split.
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -393,7 +393,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 +431,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -144,13 +144,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">
|
||||
@@ -158,13 +156,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
|
||||
@@ -261,11 +257,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>
|
||||
@@ -273,13 +267,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,7 +21,6 @@ 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 { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
|
||||
@@ -71,16 +70,13 @@ 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 { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
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 canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
// Cordon requires node:manage, matching the backend guard.
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
|
||||
@@ -55,24 +55,23 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a free-tier user', () => {
|
||||
it('hides the actions menu when the user lacks node:manage and no edit/delete affordances', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
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 });
|
||||
it('exposes the actions menu (cordon entry point) for a Community admin with node:manage', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// With no edit/delete affordances wired, the menu renders iff cordon is
|
||||
// allowed: isPaid && can('node:manage'). The admin's can() returns true.
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the cordon control for a 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 });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
@@ -80,18 +79,18 @@ describe('NodeCard', () => {
|
||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||
});
|
||||
|
||||
it('hides the cordon control from a paid user lacking node:manage', () => {
|
||||
it('hides the cordon control from a user lacking node:manage', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// The paid tier alone must not surface cordon to a deployer/viewer/auditor.
|
||||
// node:manage alone must not surface cordon when false to a deployer/viewer/auditor.
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Uncordon when the node is already cordoned', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
@@ -104,13 +103,13 @@ describe('NodeCard', () => {
|
||||
};
|
||||
|
||||
it('renders the update button for an admin when an update is available', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: true });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the update button for a non-admin but still shows the read-only badge', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false });
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Update available')).toBeInTheDocument();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user