mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
feat: drop stack labels and network topology to Community tier (#995)
* feat(labels): drop tier gate to Community for organization endpoints Stack Labels CRUD and per-stack assignment are now Community-tier features: list, create, update, delete labels, and assign labels to a single stack. The two automation surfaces stay Skipper+: per-label bulk deploy / stop / restart (POST /api/labels/:id/action) and the Fleet Actions tab's bulk-assign card (POST /api/fleet-actions/labels/bulk-assign). Tier story is now organize free, automate paid. Add a route-level test that proves the CRUD endpoints succeed on a Community license while the bulk-action endpoint still returns 403. Update overview, licensing, and stack-labels docs to reflect the new tier placement and to note that bulk actions on a label still require Skipper or Admiral. * feat(topology): drop tier gate to Community for network topology view The Resources tab's Networks > Topology view is now available on every tier. Drops requirePaid from GET /api/system/networks/topology, removes the isPaid wrapper around the List | Topology toggle in ResourcesView, and removes the PaidGate around the topology graph. CapabilityGate stays in place so a node running on a build without the network-topology capability still renders its lock card instead of the graph. Add a route-level test that proves the endpoint returns 200 on a Community license. Update licensing and resources docs to reflect the new tier placement. * fix(labels): expose Settings > Labels tab on Community tier The settings registry entry for the Labels tab still carried tier: 'skipper', which kept the tab hidden in the Settings sidebar even though the underlying CRUD endpoints now serve Community. Drop the tier flag so Community users can discover and reach the section that backs the already-Community-tier label endpoints.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Confirms Stack Labels CRUD + per-stack assignment is reachable on the
|
||||
* Community tier. The per-label bulk-action endpoint stays Skipper+ and is
|
||||
* exercised here too to guard against an accidental gate removal in the
|
||||
* future.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
describe('Stack Labels on Community tier', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('GET /api/labels returns 200 (empty array) on community', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app).get('/api/labels').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/labels creates a label on community', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'production', color: 'teal' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ name: 'production', color: 'teal' });
|
||||
expect(typeof res.body.id).toBe('number');
|
||||
});
|
||||
|
||||
it('GET /api/labels/assignments returns 200 on community', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app).get('/api/labels/assignments').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
|
||||
it('PUT /api/labels/:id updates a label on community', async () => {
|
||||
mockTier('community');
|
||||
const created = await request(app)
|
||||
.post('/api/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'staging', color: 'blue' });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/labels/${created.body.id}`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ color: 'rose' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.color).toBe('rose');
|
||||
});
|
||||
|
||||
it('DELETE /api/labels/:id removes a label on community', async () => {
|
||||
mockTier('community');
|
||||
const created = await request(app)
|
||||
.post('/api/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'temp', color: 'amber' });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/labels/${created.body.id}`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/stacks/:stackName/labels accepts an empty assignment on community', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.put('/api/stacks/some-stack/labels')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelIds: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack Labels bulk-action endpoint stays Skipper+', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('POST /api/labels/:id/action returns 403 on community', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/labels/1/action')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ action: 'deploy' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Confirms GET /api/system/networks/topology is reachable on the Community
|
||||
* tier. Mocks DockerController.getTopologyData so the route can return data
|
||||
* without a real Docker daemon.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let DockerController: typeof import('../services/DockerController').default;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
DockerController = (await import('../services/DockerController')).default;
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
describe('Network topology on Community tier', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('GET /api/system/networks/topology returns 200 on community', async () => {
|
||||
mockTier('community');
|
||||
vi.spyOn(DockerController.prototype, 'getTopologyData').mockResolvedValue([]);
|
||||
const res = await request(app)
|
||||
.get('/api/system/networks/topology')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ const MAX_ASSIGNMENTS = 1000;
|
||||
// Bulk label assignment for many stacks on a single node. The single-stack
|
||||
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
|
||||
// this wrapper applies the same operation to many stacks atomically per HTTP
|
||||
// request. Tier: requirePaid + requireAdmin (matches the per-stack endpoint).
|
||||
// request. Tier: requirePaid + requireAdmin. The per-stack endpoint is
|
||||
// Community-tier organization metadata; this multi-stack wrapper is an
|
||||
// automation surface exposed only inside the Skipper+ Fleet Actions tab.
|
||||
fleetActionsRouter.post(
|
||||
'/labels/bulk-assign',
|
||||
authMiddleware,
|
||||
|
||||
@@ -24,7 +24,6 @@ export const activeBulkActions = new Set<string>();
|
||||
export const labelsRouter = Router();
|
||||
|
||||
labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const labels = DatabaseService.getInstance().getLabels(nodeId);
|
||||
@@ -37,7 +36,6 @@ labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
|
||||
});
|
||||
|
||||
labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
@@ -77,7 +75,6 @@ labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom
|
||||
});
|
||||
|
||||
labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -108,7 +105,6 @@ labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Respo
|
||||
});
|
||||
|
||||
labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'label ID');
|
||||
@@ -152,7 +148,6 @@ labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Pr
|
||||
});
|
||||
|
||||
labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'label ID');
|
||||
if (id === null) return;
|
||||
@@ -260,7 +255,6 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
|
||||
export const stackLabelsRouter = Router();
|
||||
|
||||
stackLabelsRouter.put('/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import DockerController, { type CreateNetworkOptions, type NetworkDriver } from '../services/DockerController';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -209,7 +209,6 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.get('/networks/topology', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const includeSystem = req.query.includeSystem === 'true';
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
|
||||
Reference in New Issue
Block a user