mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community (#1758)
* feat(fleet-secrets): graduate encrypted fleet-wide environment bundles to Community * fix(fleet-secrets): update reachability test for Secrets community graduation * fix(fleet-secrets): address review findings * fix(fleet-secrets): add HTTP-level Community admin push/import tests and non-admin tab-hidden test
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
* - encrypt round-trip via CryptoService
|
* - encrypt round-trip via CryptoService
|
||||||
* - DatabaseService secret + version + push CRUD
|
* - DatabaseService secret + version + push CRUD
|
||||||
* - SecretsService versioning, importFromStack, executePush aggregation
|
* - SecretsService versioning, importFromStack, executePush aggregation
|
||||||
* - Route guards (requirePaid 403, requireAdmin 403, requireUserSession 403, push lock 409)
|
* - Route guards (requireAdmin 403, requireUserSession 403, push lock 409)
|
||||||
* - Hub-only enforcement is covered in hub-only-guard.test.ts
|
* - Hub-only enforcement is covered in hub-only-guard.test.ts
|
||||||
* - developer_mode diagnostics gating (and that diagnostics never log the secret value)
|
* - developer_mode diagnostics gating (and that diagnostics never log the secret value)
|
||||||
* - getAuditSummary patterns for /secrets routes
|
* - getAuditSummary patterns for /secrets routes
|
||||||
@@ -74,9 +74,6 @@ beforeAll(async () => {
|
|||||||
({ SecretsService } = await import('../services/SecretsService'));
|
({ SecretsService } = await import('../services/SecretsService'));
|
||||||
({ CryptoService } = await import('../services/CryptoService'));
|
({ CryptoService } = await import('../services/CryptoService'));
|
||||||
|
|
||||||
const { LicenseService } = await import('../services/LicenseService');
|
|
||||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
||||||
|
|
||||||
({ app } = await import('../index'));
|
({ app } = await import('../index'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -387,34 +384,12 @@ describe('getAuditSummary for secrets routes', () => {
|
|||||||
|
|
||||||
// ---- Route guards via supertest ----
|
// ---- Route guards via supertest ----
|
||||||
|
|
||||||
describe('Routes /api/secrets tier gating and lock', () => {
|
describe('Routes /api/secrets basic guards', () => {
|
||||||
it('returns 403 when license is community', async () => {
|
|
||||||
const { LicenseService } = await import('../services/LicenseService');
|
|
||||||
// Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests.
|
|
||||||
// requirePaid only consults getTier once per request via effectiveTier(req).
|
|
||||||
const inst = LicenseService.getInstance();
|
|
||||||
const tierSpy = vi.spyOn(inst, 'getTier');
|
|
||||||
tierSpy.mockReturnValueOnce('community');
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/secrets')
|
|
||||||
.set('Authorization', `Bearer ${adminToken()}`);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unauthenticated requests', async () => {
|
it('rejects unauthenticated requests', async () => {
|
||||||
const res = await request(app).get('/api/secrets');
|
const res = await request(app).get('/api/secrets');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 when paid', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/secrets')
|
|
||||||
.set('Authorization', `Bearer ${adminToken()}`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(Array.isArray(res.body)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed body on POST /secrets', async () => {
|
it('rejects malformed body on POST /secrets', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/secrets')
|
.post('/api/secrets')
|
||||||
@@ -424,6 +399,114 @@ describe('Routes /api/secrets tier gating and lock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Community Admin happy path: Fleet Secrets is available without a paid license ----
|
||||||
|
|
||||||
|
describe('Routes /api/secrets Community Admin access', () => {
|
||||||
|
it('lets a Community admin list bundles', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/secrets')
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a Community admin create, read, update, and delete a bundle', async () => {
|
||||||
|
// Create
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/secrets')
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`)
|
||||||
|
.send({ name: 'community-test-bundle', kv: { KEY: 'val' } });
|
||||||
|
expect(create.status).toBe(201);
|
||||||
|
const id: number = create.body.id;
|
||||||
|
// Read
|
||||||
|
const get = await request(app)
|
||||||
|
.get(`/api/secrets/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`);
|
||||||
|
expect(get.status).toBe(200);
|
||||||
|
expect(get.body.kv).toEqual({ KEY: 'val' });
|
||||||
|
// Update
|
||||||
|
const upd = await request(app)
|
||||||
|
.put(`/api/secrets/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`)
|
||||||
|
.send({ kv: { KEY: 'updated' } });
|
||||||
|
expect(upd.status).toBe(200);
|
||||||
|
// Delete
|
||||||
|
const del = await request(app)
|
||||||
|
.delete(`/api/secrets/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`);
|
||||||
|
expect(del.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a Community admin list versions', async () => {
|
||||||
|
const svc = SecretsService.getInstance();
|
||||||
|
const { id } = svc.create({ name: 'versions-community', kv: { X: '1' }, user: TEST_USERNAME });
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/secrets/${id}/versions`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a Community admin import from a stack over HTTP', async () => {
|
||||||
|
const composeDir = process.env.COMPOSE_DIR!;
|
||||||
|
const stackDir = path.join(composeDir, 'importstack');
|
||||||
|
fs.mkdirSync(stackDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stackDir, '.env'), 'IMPORT_KEY=hello\n');
|
||||||
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||||
|
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const localNode = db.getNodes().find(n => n.type === 'local')!;
|
||||||
|
const svc = SecretsService.getInstance();
|
||||||
|
const { id } = svc.create({ name: 'import-http', kv: { X: '1' }, user: TEST_USERNAME });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/secrets/${id}/import-from-stack`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`)
|
||||||
|
.send({ nodeId: localNode.id, stackName: 'importstack', envFileBasename: '.env' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.kv).toEqual({ IMPORT_KEY: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a Community admin preview and execute a push over HTTP', async () => {
|
||||||
|
const composeDir = process.env.COMPOSE_DIR!;
|
||||||
|
const stackDir = path.join(composeDir, 'pushstack');
|
||||||
|
fs.mkdirSync(stackDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\n');
|
||||||
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||||
|
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const localNode = db.getNodes().find(n => n.type === 'local')!;
|
||||||
|
const svc = SecretsService.getInstance();
|
||||||
|
const { id } = svc.create({ name: 'push-http', kv: { EXISTING: 'updated', NEWKEY: 'added' }, user: TEST_USERNAME });
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
const preview = await request(app)
|
||||||
|
.post(`/api/secrets/${id}/push/preview`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`)
|
||||||
|
.send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' });
|
||||||
|
expect(preview.status).toBe(200);
|
||||||
|
expect(Array.isArray(preview.body)).toBe(true);
|
||||||
|
expect(preview.body.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(preview.body[0].reachable).toBe(true);
|
||||||
|
|
||||||
|
// Execute push
|
||||||
|
const push = await request(app)
|
||||||
|
.post(`/api/secrets/${id}/push`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken()}`)
|
||||||
|
.send({ selector: { type: 'nodes', ids: [localNode.id] }, stackName: 'pushstack', envFileBasename: '.env' });
|
||||||
|
expect(push.status).toBe(200);
|
||||||
|
expect(push.body.pushId).toBeTruthy();
|
||||||
|
expect(push.body.results).toHaveLength(1);
|
||||||
|
expect(push.body.results[0].status).toBe('ok');
|
||||||
|
|
||||||
|
// Verify the .env was actually written
|
||||||
|
const envText = fs.readFileSync(path.join(composeDir, 'pushstack', '.env'), 'utf-8');
|
||||||
|
const kv = parseEnv(envText);
|
||||||
|
expect(kv.EXISTING).toBe('updated');
|
||||||
|
expect(kv.NEWKEY).toBe('added');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ----
|
// ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ----
|
||||||
|
|
||||||
describe('Routes /api/secrets admin-role gating', () => {
|
describe('Routes /api/secrets admin-role gating', () => {
|
||||||
@@ -439,18 +522,26 @@ describe('Routes /api/secrets admin-role gating', () => {
|
|||||||
return authToken('sec-viewer', 'viewer', user.token_version);
|
return authToken('sec-viewer', 'viewer', user.token_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each(SECRET_ENDPOINTS)('403s a non-admin paid user on %s %s', async (method, p) => {
|
it.each(SECRET_ENDPOINTS)('403s a non-admin user on %s %s', async (method, p) => {
|
||||||
const res = await callWithToken(method, p, viewerToken());
|
const res = await callWithToken(method, p, viewerToken());
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets an admin paid user list (200)', async () => {
|
it('lets an admin user list (200)', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get('/api/secrets')
|
.get('/api/secrets')
|
||||||
.set('Authorization', `Bearer ${adminToken()}`);
|
.set('Authorization', `Bearer ${adminToken()}`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('403s a Community viewer on all endpoints', async () => {
|
||||||
|
for (const [method, p] of SECRET_ENDPOINTS) {
|
||||||
|
const res = await callWithToken(method, p, viewerToken());
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Machine-credential rejection: secrets need a real signed-in user session ----
|
// ---- Machine-credential rejection: secrets need a real signed-in user session ----
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { authMiddleware } from '../middleware/auth';
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { requirePaid, requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates';
|
import { requireAdmin, requireUserSession, requireBody } from '../middleware/tierGates';
|
||||||
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
|
import { SecretsService, PushBusyError, type SecretKv } from '../services/SecretsService';
|
||||||
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
|
import { DatabaseService, type BlueprintSelector } from '../services/DatabaseService';
|
||||||
import { isValidStackName } from '../utils/validation';
|
import { isValidStackName } from '../utils/validation';
|
||||||
@@ -66,7 +66,6 @@ function parsePushBody(body: unknown): PushBody | { error: string } {
|
|||||||
|
|
||||||
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const items = SecretsService.getInstance().list();
|
const items = SecretsService.getInstance().list();
|
||||||
@@ -79,7 +78,6 @@ secretsRouter.get('/', authMiddleware, async (req: Request, res: Response): Prom
|
|||||||
|
|
||||||
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireBody(req, res)) return;
|
if (!requireBody(req, res)) return;
|
||||||
try {
|
try {
|
||||||
@@ -121,7 +119,6 @@ secretsRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro
|
|||||||
|
|
||||||
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||||
@@ -141,7 +138,6 @@ secretsRouter.get('/:id', authMiddleware, async (req: Request, res: Response): P
|
|||||||
|
|
||||||
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireBody(req, res)) return;
|
if (!requireBody(req, res)) return;
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +177,6 @@ secretsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): P
|
|||||||
|
|
||||||
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||||
@@ -201,7 +196,6 @@ secretsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response)
|
|||||||
|
|
||||||
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const id = parseIntParam(req, res, 'id', 'secret ID');
|
const id = parseIntParam(req, res, 'id', 'secret ID');
|
||||||
@@ -219,7 +213,6 @@ secretsRouter.get('/:id/versions', authMiddleware, async (req: Request, res: Res
|
|||||||
|
|
||||||
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireBody(req, res)) return;
|
if (!requireBody(req, res)) return;
|
||||||
try {
|
try {
|
||||||
@@ -249,7 +242,6 @@ secretsRouter.post('/:id/import-from-stack', authMiddleware, async (req: Request
|
|||||||
|
|
||||||
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireBody(req, res)) return;
|
if (!requireBody(req, res)) return;
|
||||||
try {
|
try {
|
||||||
@@ -274,7 +266,6 @@ secretsRouter.post('/:id/push/preview', authMiddleware, async (req: Request, res
|
|||||||
|
|
||||||
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
secretsRouter.post('/:id/push', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requireUserSession(req, res)) return;
|
if (!requireUserSession(req, res)) return;
|
||||||
if (!requirePaid(req, res)) return;
|
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireBody(req, res)) return;
|
if (!requireBody(req, res)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus
|
|||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Fleet Secrets is a limited-availability surface. When it is present on an instance, managing it requires an admin user role.
|
Fleet Secrets is available on every Sencho installation. Managing bundles requires an admin user role.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
## What Fleet Secrets covers (and what it doesn't)
|
## What Fleet Secrets covers (and what it doesn't)
|
||||||
@@ -38,14 +38,14 @@ A **push** is a separate action. It reads the bundle's current version, walks ev
|
|||||||
|
|
||||||
| Requirement | Why it matters |
|
| Requirement | Why it matters |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Admin role on the control instance | Bundle CRUD and push require an administrator when the surface is present; authored-by rows are written into the audit log |
|
| Admin role on the control instance | Bundle CRUD and push require an administrator; authored-by rows are written into the audit log |
|
||||||
| At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks |
|
| At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks |
|
||||||
| The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up |
|
| The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up |
|
||||||
| The control instance can reach the remote node's API URL | Each remote write is an HTTP call from the control instance to the remote's `/api/stacks/.../env`; an unreachable remote is reported as a per-node failure, not a transport error for the whole push |
|
| The control instance can reach the remote node's API URL | Each remote write is an HTTP call from the control instance to the remote's `/api/stacks/.../env`; an unreachable remote is reported as a per-node failure, not a transport error for the whole push |
|
||||||
|
|
||||||
## Create a bundle
|
## Create a bundle
|
||||||
|
|
||||||
1. Open **Fleet → Secrets** (when that tab is available on the instance).
|
1. Open **Fleet → Secrets** on the Fleet view.
|
||||||
2. Click **New bundle**.
|
2. Click **New bundle**.
|
||||||
3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric.
|
3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric.
|
||||||
4. Optionally add a description; the description is a free-text field and is shown in the bundle list.
|
4. Optionally add a description; the description is a free-text field and is shown in the bundle list.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ A single rail summarises the state of every registered node so you can read the
|
|||||||
|
|
||||||
### Tabs
|
### Tabs
|
||||||
|
|
||||||
The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Routing and Secrets are limited-availability fleet surfaces and are not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) 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, Docker Labels, Deployments, Federation, and Actions. Snapshots appears for admins. Secrets appears for admins. Routing is a limited-availability fleet surface and is not part of the default tab strip. A vertical separator after **Docker Labels** (or after **Map** when Docker Labels is not present) divides the per-node monitoring tabs from the fleet-wide orchestration tabs.
|
||||||
|
|
||||||
| Tab | Tier | What it does |
|
| Tab | Tier | What it does |
|
||||||
|-----|------|--------------|
|
|-----|------|--------------|
|
||||||
@@ -50,7 +50,7 @@ The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Docker Lab
|
|||||||
| **Routing** | Limited availability | Cross-node service routing via Sencho Mesh when that surface is enabled on the instance. See [Sencho Mesh](/features/sencho-mesh). |
|
| **Routing** | Limited availability | Cross-node service routing via Sencho Mesh when that surface is enabled on the instance. See [Sencho Mesh](/features/sencho-mesh). |
|
||||||
| **Federation** | Community | 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). |
|
| **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** | Limited availability | Encrypted env-var bundles you push to labeled nodes when that surface is enabled on the instance. See [Fleet Secrets](/features/fleet-secrets). |
|
| **Secrets** | Community (admin role) | Encrypted env-var bundles you push to labeled nodes across the fleet. See [Fleet Secrets](/features/fleet-secrets). |
|
||||||
|
|
||||||
### Action buttons
|
### Action buttons
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ When several Sencho instances run as a fleet, the control instance is the source
|
|||||||
|
|
||||||
### Fleet Secrets
|
### Fleet Secrets
|
||||||
|
|
||||||
Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Limited-availability surface when present; admin role required to manage. [Learn more →](/features/fleet-secrets)
|
Centralized, encrypted, versioned env-var bundles you push to labeled nodes' stacks. Every save bumps a version, and every push records a per-node diff in the audit log using overlay merge semantics. Available on every installation; admin role required to manage. [Learn more →](/features/fleet-secrets)
|
||||||
|
|
||||||
### Fleet-wide backups
|
### Fleet-wide backups
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ These are the main terms you will see across the app and docs:
|
|||||||
- **Resource**: A Docker image, volume, network, or unmanaged container. Sencho classifies resources as managed, external, system, unused, or protected so cleanup decisions are visible.
|
- **Resource**: A Docker image, volume, network, or unmanaged container. Sencho classifies resources as managed, external, system, unused, or protected so cleanup decisions are visible.
|
||||||
- **Blueprint**: A fleet deployment model for keeping a compose template assigned to matching nodes.
|
- **Blueprint**: A fleet deployment model for keeping a compose template assigned to matching nodes.
|
||||||
- **Pilot Agent**: An outbound connector for remote nodes that cannot accept inbound traffic.
|
- **Pilot Agent**: An outbound connector for remote nodes that cannot accept inbound traffic.
|
||||||
- **Limited-availability networking and secrets surfaces**: documented on their own feature pages when enabled on an instance.
|
- **Limited-availability networking surface**: documented on its own feature page when enabled on an instance.
|
||||||
|
|
||||||
## What you see after sign-in
|
## What you see after sign-in
|
||||||
|
|
||||||
@@ -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.
|
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**, a **Docker Labels** audit, blueprint **Deployments**, **Federation**, and fleet **Actions**. Federation placement (cordon and pin) is available on every tier. Routing and Secrets are limited-availability tabs when enabled on an instance. See [Licensing](/features/licensing) for the full tier breakdown.
|
Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, a **Docker Labels** audit, blueprint **Deployments**, **Secrets**, **Federation**, and fleet **Actions**. Federation placement (cordon and pin) is available on every tier. Routing is a limited-availability tab when enabled on an instance. See [Licensing](/features/licensing) for the full tier breakdown.
|
||||||
|
|
||||||
## Resources, templates, and logs
|
## Resources, templates, and logs
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Every self-hosted instance includes the full security stack. Some advanced gover
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Fleet Secrets" icon="key-skeleton" href="/features/fleet-secrets">
|
<Card title="Fleet Secrets" icon="key-skeleton" href="/features/fleet-secrets">
|
||||||
Limited-availability encrypted, versioned env-var bundles pushed to labeled nodes' stacks when the surface is present. Sealed with the same data key as MFA and registry credentials.
|
Encrypted, versioned env-var bundles pushed to labeled nodes' stacks. Sealed with the same data key as MFA and registry credentials.
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Webhook signatures" icon="signature" href="/features/webhooks">
|
<Card title="Webhook signatures" icon="signature" href="/features/webhooks">
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export function FleetView({
|
|||||||
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
||||||
// Visual fail-closed while /meta loads; paid/admin gates still apply when on.
|
// Visual fail-closed while /meta loads; paid/admin gates still apply when on.
|
||||||
const canDiscoverRouting = experimentalReady && experimental && isPaid;
|
const canDiscoverRouting = experimentalReady && experimental && isPaid;
|
||||||
const canDiscoverSecrets = experimentalReady && experimental && isPaid && isAdmin;
|
|
||||||
|
|
||||||
const { prefs, updatePrefs } = useFleetPreferences();
|
const { prefs, updatePrefs } = useFleetPreferences();
|
||||||
const updateStatus = useFleetUpdateStatus();
|
const updateStatus = useFleetUpdateStatus();
|
||||||
@@ -104,9 +103,9 @@ export function FleetView({
|
|||||||
if (controlledTab === undefined) setInternalTab(tab);
|
if (controlledTab === undefined) setInternalTab(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fall back only after experimental readiness settles. When experimental is
|
// Fall back Routing deep links when experimental/license gates resolve false.
|
||||||
// on, also wait for license (and admin for secrets) so a paid deep link is
|
// Wait for license during cold load so a paid deep link is not rewritten to
|
||||||
// not rewritten to Overview while isPaid is still the cold-load false.
|
// Overview while isPaid is still the cold-load false.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!experimentalReady) return;
|
if (!experimentalReady) return;
|
||||||
if (activeTab === 'routing') {
|
if (activeTab === 'routing') {
|
||||||
@@ -116,19 +115,10 @@ export function FleetView({
|
|||||||
}
|
}
|
||||||
if (licenseStatus !== 'ready') return;
|
if (licenseStatus !== 'ready') return;
|
||||||
if (!isPaid) setActiveTab('overview');
|
if (!isPaid) setActiveTab('overview');
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeTab === 'secrets') {
|
|
||||||
if (!experimental) {
|
|
||||||
setActiveTab('overview');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (licenseStatus !== 'ready') return;
|
|
||||||
if (!isPaid || !isAdmin) setActiveTab('overview');
|
|
||||||
}
|
}
|
||||||
// setActiveTab closes over onFleetActiveTabChange; listing deps explicitly.
|
// setActiveTab closes over onFleetActiveTabChange; listing deps explicitly.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [experimentalReady, experimental, licenseStatus, isPaid, isAdmin, activeTab]);
|
}, [experimentalReady, experimental, licenseStatus, isPaid, activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fleetUpdatesIntent) {
|
if (fleetUpdatesIntent) {
|
||||||
@@ -217,7 +207,7 @@ export function FleetView({
|
|||||||
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsHighlightItem>
|
</TabsHighlightItem>
|
||||||
{canDiscoverSecrets && (
|
{isAdmin && (
|
||||||
<TabsHighlightItem value="secrets">
|
<TabsHighlightItem value="secrets">
|
||||||
<TabsTrigger value="secrets">
|
<TabsTrigger value="secrets">
|
||||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||||
@@ -347,7 +337,7 @@ export function FleetView({
|
|||||||
unfiltered node list rather than the overview-filtered view. */}
|
unfiltered node list rather than the overview-filtered view. */}
|
||||||
<FleetActionsTab nodes={overview.nodes} />
|
<FleetActionsTab nodes={overview.nodes} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{canDiscoverSecrets && (
|
{isAdmin && (
|
||||||
<TabsContent value="secrets">
|
<TabsContent value="secrets">
|
||||||
<SecretsTab />
|
<SecretsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ vi.mock('@/hooks/useExperimental', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/context/LicenseContext', () => ({
|
vi.mock('@/context/LicenseContext', () => ({
|
||||||
useLicense: () => ({ isPaid: true }),
|
useLicense: () => ({ isPaid: true, licenseStatus: 'ready' as const }),
|
||||||
}));
|
}));
|
||||||
|
const useAuthMock = vi.fn(() => ({ isAdmin: true as boolean, can: () => true as boolean }));
|
||||||
vi.mock('@/context/AuthContext', () => ({
|
vi.mock('@/context/AuthContext', () => ({
|
||||||
useAuth: () => ({ isAdmin: true, can: () => true }),
|
useAuth: () => useAuthMock(),
|
||||||
}));
|
}));
|
||||||
vi.mock('@/context/NodeContext', () => ({
|
vi.mock('@/context/NodeContext', () => ({
|
||||||
useNodes: () => ({ hasCapability: () => false }),
|
useNodes: () => ({ hasCapability: () => false }),
|
||||||
@@ -100,22 +101,23 @@ vi.mock('../fleet/DependencyMapTab', () => ({ DependencyMapTab: () => null }));
|
|||||||
vi.mock('../fleet/ContainerLabelsTab', () => ({ ContainerLabelsTab: () => null }));
|
vi.mock('../fleet/ContainerLabelsTab', () => ({ ContainerLabelsTab: () => null }));
|
||||||
vi.mock('../PaidGate', () => ({ PaidGate: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
|
vi.mock('../PaidGate', () => ({ PaidGate: ({ children }: { children: React.ReactNode }) => <>{children}</> }));
|
||||||
|
|
||||||
describe('FleetView experimental discovery', () => {
|
describe('FleetView tab discovery and deep-link fallback', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||||
|
useAuthMock.mockReturnValue({ isAdmin: true, can: () => true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Routing and Secrets when experimental discovery is on for paid admin', () => {
|
it('shows Routing when experimental is on; Secrets always visible for Admin', () => {
|
||||||
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
|
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
|
||||||
expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy();
|
expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy();
|
||||||
expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy();
|
expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides Routing and Secrets when experimental discovery is off', () => {
|
it('hides Routing when experimental off; Secrets stays visible for Admin', () => {
|
||||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||||
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
|
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
|
||||||
expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull();
|
expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull();
|
||||||
expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull();
|
expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy();
|
||||||
expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy();
|
expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy();
|
||||||
expect(screen.getByRole('tab', { name: /federation/i })).toBeTruthy();
|
expect(screen.getByRole('tab', { name: /federation/i })).toBeTruthy();
|
||||||
expect(screen.getByRole('tab', { name: /actions/i })).toBeTruthy();
|
expect(screen.getByRole('tab', { name: /actions/i })).toBeTruthy();
|
||||||
@@ -173,4 +175,29 @@ describe('FleetView experimental discovery', () => {
|
|||||||
await waitFor(() => expect(onTab).toHaveBeenCalledWith('overview'));
|
await waitFor(() => expect(onTab).toHaveBeenCalledWith('overview'));
|
||||||
expect(onTab).toHaveBeenCalledTimes(1);
|
expect(onTab).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not rewrite a Secrets deep link for Admin when experimental is off', () => {
|
||||||
|
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||||
|
const onTab = vi.fn();
|
||||||
|
render(
|
||||||
|
<FleetView
|
||||||
|
onNavigateToNode={vi.fn()}
|
||||||
|
onOpenNodeNetworking={vi.fn()}
|
||||||
|
fleetActiveTab="secrets"
|
||||||
|
onFleetActiveTabChange={onTab}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// No fallback rewrite; Secrets is always available to Admin
|
||||||
|
expect(onTab).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Secrets tab for non-admin', () => {
|
||||||
|
useAuthMock.mockReturnValue({ isAdmin: false, can: () => false });
|
||||||
|
render(<FleetView onNavigateToNode={vi.fn()} onOpenNodeNetworking={vi.fn()} />);
|
||||||
|
expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull();
|
||||||
|
// Routing still visible (gated on experimental + paid, not admin)
|
||||||
|
expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy();
|
||||||
|
// Unrelated tabs still visible
|
||||||
|
expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,19 +90,29 @@ describe('reachability', () => {
|
|||||||
expect(isViewHidden('audit-log', ctx({ isPaid: true, can: () => false }))).toBe(true);
|
expect(isViewHidden('audit-log', ctx({ isPaid: true, can: () => false }))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides routing and secrets fleet tabs only after experimentalReady when off', () => {
|
it('hides routing fleet tab only after experimentalReady when off; secrets always visible for admin', () => {
|
||||||
const loading = ctx({ experimental: false, experimentalReady: false });
|
const loading = ctx({ experimental: false, experimentalReady: false });
|
||||||
expect(isFleetTabHidden('routing', loading)).toBe(false);
|
expect(isFleetTabHidden('routing', loading)).toBe(false);
|
||||||
expect(isFleetTabHidden('secrets', loading)).toBe(false);
|
expect(isFleetTabHidden('secrets', loading)).toBe(false);
|
||||||
|
|
||||||
const off = ctx({ experimental: false, experimentalReady: true });
|
const off = ctx({ experimental: false, experimentalReady: true });
|
||||||
expect(isFleetTabHidden('routing', off)).toBe(true);
|
expect(isFleetTabHidden('routing', off)).toBe(true);
|
||||||
expect(isFleetTabHidden('secrets', off)).toBe(true);
|
expect(isFleetTabHidden('secrets', off)).toBe(false);
|
||||||
expect(isFleetTabHidden('deployments', off)).toBe(false);
|
expect(isFleetTabHidden('deployments', off)).toBe(false);
|
||||||
expect(isFleetTabHidden('federation', off)).toBe(false);
|
expect(isFleetTabHidden('federation', off)).toBe(false);
|
||||||
expect(isFleetTabHidden('actions', off)).toBe(false);
|
expect(isFleetTabHidden('actions', off)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides secrets fleet tab for non-admin after authz ready', () => {
|
||||||
|
// Cold load: permissions not ready, don't hide yet (deep link survives)
|
||||||
|
const loading = ctx({ isAdmin: false, permissionsStatus: 'loading' });
|
||||||
|
expect(isFleetTabHidden('secrets', loading)).toBe(false);
|
||||||
|
|
||||||
|
// Permissions settled: non-admin deep link normalizes to overview
|
||||||
|
const ready = ctx({ isAdmin: false, permissionsStatus: 'ready' });
|
||||||
|
expect(isFleetTabHidden('secrets', ready)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not hide fleet-mesh settings for experimental off', () => {
|
it('does not hide fleet-mesh settings for experimental off', () => {
|
||||||
const off = ctx({ experimental: false, experimentalReady: true,
|
const off = ctx({ experimental: false, experimentalReady: true,
|
||||||
scheduledOpsAccessible: false, isAdmin: true });
|
scheduledOpsAccessible: false, isAdmin: true });
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex
|
|||||||
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
|
export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean {
|
||||||
if (!authzReady(ctx)) return false;
|
if (!authzReady(ctx)) return false;
|
||||||
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
|
if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true;
|
||||||
|
if (tab === 'secrets' && !ctx.isAdmin) return true;
|
||||||
// Defer experimental hide until ready so deep links survive cold load.
|
// Defer experimental hide until ready so deep links survive cold load.
|
||||||
if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) {
|
if (tab === 'routing' && experimentalDiscoveryReady(ctx) && !ctx.experimental) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user