From 60f893a81f1accc411b85d6cc72fae5f0818aa0d Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 21 May 2026 23:54:45 -0400 Subject: [PATCH] feat(fleet): move bulk Remote OTA updates to Community tier (#1151) Drops `requirePaid` from `POST /api/fleet/update-all` so Community admins can dispatch bulk node updates. Per-node OTA was already Community- reachable (admin-only); this completes the move so the full Remote OTA surface ships at Community. Frontend mirrors the backend: removes `canBulkUpdate` from NodeUpdatesSheet so the "Update all (N)" affordance is purely data- driven on `updatableRemoteCount > 0`. Docs realigned to drop fence-spec and Skipper-only phrasing on the Update all bulk action: - features/licensing.mdx: Community line now lists Remote OTA (per-node and Update all); Skipper Fleet Actions parenthetical drops "bulk update all" - features/remote-updates.mdx: Note rewritten to role-only requirement - features/fleet-view.mdx: Update all (n) bullet drops the tier clause - features/overview.mdx: Fleet View and Remote updates blurbs drop the Skipper/Admiral fences - operations/upgrade.mdx: Note rephrased without naming tiers Test coverage: - fleet.test.ts: tier-gating spec flipped to assert Community access - fleet-pilot-update.test.ts: bulk-OTA dispatch suite now spies tier to Community so it doubles as a regression guard --- backend/src/__tests__/fleet-pilot-update.test.ts | 14 +++++++------- backend/src/__tests__/fleet.test.ts | 5 ++--- backend/src/routes/fleet.ts | 1 - docs/features/fleet-view.mdx | 2 +- docs/features/licensing.mdx | 5 ++--- docs/features/overview.mdx | 4 ++-- docs/features/remote-updates.mdx | 2 +- docs/operations/upgrade.mdx | 2 +- frontend/src/components/FleetView.tsx | 1 - .../src/components/FleetView/NodeUpdatesSheet.tsx | 4 +--- 10 files changed, 17 insertions(+), 23 deletions(-) diff --git a/backend/src/__tests__/fleet-pilot-update.test.ts b/backend/src/__tests__/fleet-pilot-update.test.ts index cb25e73d..364459a2 100644 --- a/backend/src/__tests__/fleet-pilot-update.test.ts +++ b/backend/src/__tests__/fleet-pilot-update.test.ts @@ -184,15 +184,15 @@ describe('POST /api/fleet/nodes/:nodeId/update (pilot-agent)', () => { }); describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => { - // /update-all is requirePaid; spy the license tier so the test DB does not - // need a real activation row. - function mockPaidTier() { - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + // Bulk OTA is admin-only and runs at every tier; spy the tier to Community + // so this suite doubles as a regression guard that the gate has not been + // re-introduced. + function mockCommunityTier() { + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); } it('includes the pilot node in the candidate set and dispatches through its target', async () => { - mockPaidTier(); + mockCommunityTier(); mockTargetForPilot(); mockMeta(META_ONLINE_OUTDATED); const postedUrls: string[] = []; @@ -213,7 +213,7 @@ describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => { }); it('skips remotes whose target resolves to null and never calls /api/system/update on them', async () => { - mockPaidTier(); + mockCommunityTier(); mockTargetUnreachable(); // /update-all also calls api.github.com to compute the compare target; // pin the assertion to the route's own dispatch surface. diff --git a/backend/src/__tests__/fleet.test.ts b/backend/src/__tests__/fleet.test.ts index 5efe8080..a56f061c 100644 --- a/backend/src/__tests__/fleet.test.ts +++ b/backend/src/__tests__/fleet.test.ts @@ -157,13 +157,12 @@ describe('GET /api/fleet/overview', () => { describe('Fleet tier gating', () => { afterEach(() => vi.restoreAllMocks()); - it('POST /api/fleet/update-all returns 403 on community tier (bulk update is Skipper+)', async () => { + it('POST /api/fleet/update-all is accessible on community tier', async () => { mockTier('community'); const res = await request(app) .post('/api/fleet/update-all') .set('Authorization', authHeader); - expect(res.status).toBe(403); - expect(res.body.code).toBe('PAID_REQUIRED'); + expect(res.body.code).not.toBe('PAID_REQUIRED'); }); it('GET /api/fleet/update-status is accessible on community tier', async () => { diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 8c950b57..0b47f530 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -937,7 +937,6 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r }); fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requirePaid(req, res)) return; if (!requireAdmin(req, res)) return; try { const db = DatabaseService.getInstance(); diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index f8400f4b..48b0e551 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -215,7 +215,7 @@ The sheet has four sections from top to bottom. ### Header actions - **Recheck** re-queries every node's `/api/meta` endpoint and re-resolves the latest available Sencho version from GitHub Releases (with a Docker Hub fallback if the GitHub API is unreachable). The button disables and shows a spinner while the check is in flight. -- **Update all (n)** ships only on Skipper or Admiral. The label carries the count of remote nodes with a pending update. Clicking it dispatches the update to every remote with a known current and latest version. +- **Update all (n)** carries the count of remote nodes with a pending update. Clicking it dispatches the update to every remote with a known current and latest version. ### Summary cards diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index 8dd3baf7..9619c66d 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -29,7 +29,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Real-time container stats, global logs, the interactive network topology graph, and stack labels - Git sources for compose stacks - Multi-node management in both Proxy and Pilot Agent modes -- Manual fleet snapshots (create, browse, restore, delete) and per-node Sencho updates +- Manual fleet snapshots (create, browse, restore, delete) and Remote OTA node updates (per-node and **Update all**) - Custom S3-compatible backup target (bring your own AWS S3, Cloudflare R2, MinIO, Backblaze B2, or Wasabi bucket) - Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison - CVE suppressions @@ -49,8 +49,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export - Auto-update of the managed Trivy binary - Bulk actions on a label (deploy, stop, or restart every stack tagged with it) -- Remote OTA node updates from the control instance -- Fleet Actions (bulk update all, bulk restart Sencho) +- Fleet Actions (bulk restart Sencho) - Blueprints and Fleet Secrets - Preset SSO for Google, GitHub, and Okta - Viewer accounts (1 admin plus 3 viewers) diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index b49d2b58..f4f317b7 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -109,7 +109,7 @@ Connect containers across nodes by hostname over the Pilot tunnel so multi-node ### Fleet View -Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Search, sort, filtering, stack drill-down, the Grid / Topology toggle, and critical-node detection are available on every tier; the bulk **Update All** action inside the Node Updates modal is Skipper or Admiral. [Learn more →](/features/fleet-view) +Monitor your entire infrastructure from a single screen. The fleet dashboard shows all nodes with health metrics, container counts, and resource usage. Search, sort, filtering, stack drill-down, the Grid / Topology toggle, and critical-node detection are available on every tier. [Learn more →](/features/fleet-view) ### Fleet Federation @@ -133,7 +133,7 @@ Create point-in-time snapshots of every compose file and environment file across ### Remote updates -Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. Per-node updates and the Check Updates view are available on every tier; the bulk **Update All** action is Skipper or Admiral. [Learn more →](/features/remote-updates) +Check for outdated nodes and trigger over-the-air updates from the Fleet View. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. Both per-node updates and the bulk **Update all** action are available on every tier. [Learn more →](/features/remote-updates) ### Scheduled operations diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index eae16ee1..e57b7f5e 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -8,7 +8,7 @@ Sencho can update any node in the fleet to the latest published release without This page covers the mechanism: prerequisites, what happens on a node during an update, how completion and failure are detected, and how to recover. The full UI tour for the Node updates sheet itself lives in [Fleet View](/features/fleet-view#node-updates). - Per-node updates and the Check Updates view are available on every tier. The bulk **Update all** action is a Skipper or Admiral feature. Triggering an update (per-row or bulk) requires the **admin** role; viewer and operator roles can read update status but cannot dispatch updates. + Triggering an update (per-row or **Update all**) requires the **admin** role. Viewer and operator roles can read update status but cannot dispatch updates. ## Prerequisites diff --git a/docs/operations/upgrade.mdx b/docs/operations/upgrade.mdx index 69adebc3..188da0c2 100644 --- a/docs/operations/upgrade.mdx +++ b/docs/operations/upgrade.mdx @@ -23,7 +23,7 @@ docker stop sencho && docker rm sencho Sencho will apply any necessary database migrations automatically on startup. No manual steps are required. - Skipper and Admiral users can also update remote nodes directly from the Fleet View. See [Remote Updates](/features/remote-updates) for details. + Remote nodes can be updated directly from the Fleet View without dropping to a shell. See [Remote Updates](/features/remote-updates) for details. --- diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index a739396d..51a84e0c 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -242,7 +242,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { retryNodeUpdate={updateStatus.retryNodeUpdate} dismissNodeUpdate={updateStatus.dismissNodeUpdate} triggerUpdateAll={updateStatus.triggerUpdateAll} - canBulkUpdate={isPaid} /> void; dismissNodeUpdate: (nodeId: number) => void; triggerUpdateAll: () => Promise; - canBulkUpdate: boolean; } export function NodeUpdatesSheet({ open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, - canBulkUpdate, }: NodeUpdatesSheetProps) { const [search, setSearch] = useState(''); const [recheckingUpdates, setRecheckingUpdates] = useState(false); @@ -71,7 +69,7 @@ export function NodeUpdatesSheet({ ? undefined : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`); - const secondaryActions = canBulkUpdate && updatableRemoteCount > 0 + const secondaryActions = updatableRemoteCount > 0 ? [{ label: `Update all (${updatableRemoteCount})`, icon: Download,