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
This commit is contained in:
Anso
2026-05-21 23:54:45 -04:00
committed by GitHub
parent 8a3889dc67
commit 60f893a81f
10 changed files with 17 additions and 23 deletions
@@ -184,15 +184,15 @@ describe('POST /api/fleet/nodes/:nodeId/update (pilot-agent)', () => {
}); });
describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => { describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => {
// /update-all is requirePaid; spy the license tier so the test DB does not // Bulk OTA is admin-only and runs at every tier; spy the tier to Community
// need a real activation row. // so this suite doubles as a regression guard that the gate has not been
function mockPaidTier() { // re-introduced.
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); function mockCommunityTier() {
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
} }
it('includes the pilot node in the candidate set and dispatches through its target', async () => { it('includes the pilot node in the candidate set and dispatches through its target', async () => {
mockPaidTier(); mockCommunityTier();
mockTargetForPilot(); mockTargetForPilot();
mockMeta(META_ONLINE_OUTDATED); mockMeta(META_ONLINE_OUTDATED);
const postedUrls: string[] = []; 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 () => { it('skips remotes whose target resolves to null and never calls /api/system/update on them', async () => {
mockPaidTier(); mockCommunityTier();
mockTargetUnreachable(); mockTargetUnreachable();
// /update-all also calls api.github.com to compute the compare target; // /update-all also calls api.github.com to compute the compare target;
// pin the assertion to the route's own dispatch surface. // pin the assertion to the route's own dispatch surface.
+2 -3
View File
@@ -157,13 +157,12 @@ describe('GET /api/fleet/overview', () => {
describe('Fleet tier gating', () => { describe('Fleet tier gating', () => {
afterEach(() => vi.restoreAllMocks()); 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'); mockTier('community');
const res = await request(app) const res = await request(app)
.post('/api/fleet/update-all') .post('/api/fleet/update-all')
.set('Authorization', authHeader); .set('Authorization', authHeader);
expect(res.status).toBe(403); expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).toBe('PAID_REQUIRED');
}); });
it('GET /api/fleet/update-status is accessible on community tier', async () => { it('GET /api/fleet/update-status is accessible on community tier', async () => {
-1
View File
@@ -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<void> => { fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return; if (!requireAdmin(req, res)) return;
try { try {
const db = DatabaseService.getInstance(); const db = DatabaseService.getInstance();
+1 -1
View File
@@ -215,7 +215,7 @@ The sheet has four sections from top to bottom.
### Header actions ### 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. - **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 ### Summary cards
+2 -3
View File
@@ -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 - Real-time container stats, global logs, the interactive network topology graph, and stack labels
- Git sources for compose stacks - Git sources for compose stacks
- Multi-node management in both Proxy and Pilot Agent modes - 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) - 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 - Vulnerability scanning: install, update, and uninstall Trivy, on-demand scans for vulnerabilities, secrets, and misconfigurations, plus scan comparison
- CVE suppressions - 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 - Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export
- Auto-update of the managed Trivy binary - Auto-update of the managed Trivy binary
- Bulk actions on a label (deploy, stop, or restart every stack tagged with it) - 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 restart Sencho)
- Fleet Actions (bulk update all, bulk restart Sencho)
- Blueprints and Fleet Secrets - Blueprints and Fleet Secrets
- Preset SSO for Google, GitHub, and Okta - Preset SSO for Google, GitHub, and Okta
- Viewer accounts (1 admin plus 3 viewers) - Viewer accounts (1 admin plus 3 viewers)
+2 -2
View File
@@ -109,7 +109,7 @@ Connect containers across nodes by hostname over the Pilot tunnel so multi-node
### Fleet View ### 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 ### Fleet Federation
@@ -133,7 +133,7 @@ Create point-in-time snapshots of every compose file and environment file across
### Remote updates ### 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 ### Scheduled operations
+1 -1
View File
@@ -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). 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).
<Note> <Note>
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.
</Note> </Note>
## Prerequisites ## Prerequisites
+1 -1
View File
@@ -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. Sencho will apply any necessary database migrations automatically on startup. No manual steps are required.
<Note> <Note>
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.
</Note> </Note>
--- ---
-1
View File
@@ -242,7 +242,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
retryNodeUpdate={updateStatus.retryNodeUpdate} retryNodeUpdate={updateStatus.retryNodeUpdate}
dismissNodeUpdate={updateStatus.dismissNodeUpdate} dismissNodeUpdate={updateStatus.dismissNodeUpdate}
triggerUpdateAll={updateStatus.triggerUpdateAll} triggerUpdateAll={updateStatus.triggerUpdateAll}
canBulkUpdate={isPaid}
/> />
<LocalUpdateConfirmDialog <LocalUpdateConfirmDialog
@@ -23,13 +23,11 @@ interface NodeUpdatesSheetProps {
retryNodeUpdate: (nodeId: number) => void; retryNodeUpdate: (nodeId: number) => void;
dismissNodeUpdate: (nodeId: number) => void; dismissNodeUpdate: (nodeId: number) => void;
triggerUpdateAll: () => Promise<void>; triggerUpdateAll: () => Promise<void>;
canBulkUpdate: boolean;
} }
export function NodeUpdatesSheet({ export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId,
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
canBulkUpdate,
}: NodeUpdatesSheetProps) { }: NodeUpdatesSheetProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false); const [recheckingUpdates, setRecheckingUpdates] = useState(false);
@@ -71,7 +69,7 @@ export function NodeUpdatesSheet({
? undefined ? undefined
: (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`); : (gatewayLabel ? `Latest version ${gatewayLabel}` : `${available} update${available === 1 ? '' : 's'} available`);
const secondaryActions = canBulkUpdate && updatableRemoteCount > 0 const secondaryActions = updatableRemoteCount > 0
? [{ ? [{
label: `Update all (${updatableRemoteCount})`, label: `Update all (${updatableRemoteCount})`,
icon: Download, icon: Download,