refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)

* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only

The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.

The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.

Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
  accessors, the three routes under /api/stacks/*/auto-update, the per-stack
  skip in /api/auto-update/execute and SchedulerService.executeUpdate's
  fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
  Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
  rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
  optimistic toggle plumbing. Drop autoUpdateSettings state, the
  /stacks/auto-update-settings fetch, and the auto-update-settings-changed
  WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
  enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
  now means "a covering enabled action='update' schedule exists" (per-stack
  row or fleet row on this node, earliest next_run_at wins, per-stack row
  wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
  PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
  useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
  for the 60s poll. The broadcast is wrapped so a broken subscriber socket
  cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
  to describe the schedule-based model; update the matching troubleshooting
  entry. The misleading fleet-update help text in ScheduledOperationsView
  is corrected to reflect that every stack on the node is covered.

Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.

No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.

* docs: sweep remaining references to the per-stack auto-update toggle

The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:

- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
  group description, the matching screenshot alt-text, and the Skipper
  Note that listed it. Schedule task now carries the cross-link to
  Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
  refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
  "Auto-update stacks" to "Auto-update schedules" with the new value
  shape, and rewrite the troubleshooting accordion to describe the
  scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
  Stacks row and helper text to reflect that every stack on the node is
  covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
  derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
  All Stacks schedules instead of the removed per-stack policy.

The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.

No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.

* fix(stacks): drop orphaned requireAdmin import after auto-update route removal

CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.

tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
This commit is contained in:
Anso
2026-05-26 11:08:33 -04:00
committed by GitHub
parent 2a29fed117
commit adcd04b01a
36 changed files with 295 additions and 668 deletions
+3 -3
View File
@@ -66,10 +66,10 @@ export function buildLocalConfigurationStatus(
const notifRoutes = db.getNotificationRoutes();
const healPolicies = db.getAutoHealPolicies(undefined, nodeId);
const autoUpdateMap = db.getStackAutoUpdateSettingsForNode(nodeId);
const autoUpdateEnabled = Object.values(autoUpdateMap).filter(Boolean).length;
const autoUpdateTotal = Object.keys(autoUpdateMap).length;
const scheduledTasks = db.getScheduledTasks();
const nodeUpdateTasks = scheduledTasks.filter(t => t.action === 'update' && t.node_id === nodeId);
const autoUpdateTotal = nodeUpdateTasks.length;
const autoUpdateEnabled = nodeUpdateTasks.filter(t => t.enabled === 1).length;
const webhooks = db.getWebhooks();
const mfaRow = userId ? db.getUserMfa(userId) : undefined;
-5
View File
@@ -229,11 +229,6 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
for (const stackName of stackNames) {
try {
if (!db.getStackAutoUpdateEnabled(req.nodeId, stackName)) {
results.push(`Stack "${stackName}": auto-updates disabled; skipped.`);
continue;
}
const containers = await docker.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
results.push(`Stack "${stackName}": no containers found; skipped.`);
+19
View File
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express';
import { CronExpressionParser } from 'cron-parser';
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
import { SchedulerService } from '../services/SchedulerService';
import { NotificationService } from '../services/NotificationService';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
@@ -9,6 +10,20 @@ import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
import { isValidStackName } from '../utils/validation';
// Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a
// broken subscriber socket cannot turn a successful mutation into a 500.
function broadcastScheduledTasksChanged(): void {
try {
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'scheduled-tasks',
ts: Date.now(),
});
} catch (err) {
console.error('[ScheduledTasks] broadcast failed:', getErrorMessage(err, String(err)));
}
}
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
@@ -201,6 +216,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
console.log(`[ScheduledTasks] Created task id=${id} action=${sanitizeForLog(action)} target=${sanitizeForLog(target_id || 'none')}`);
const task = DatabaseService.getInstance().getScheduledTask(id);
broadcastScheduledTasksChanged();
res.status(201).json(task);
} catch (error) {
console.error('[ScheduledTasks] Create error:', error);
@@ -297,6 +313,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
db.updateScheduledTask(id, updates as Partial<Omit<ScheduledTask, 'id'>>);
console.log(`[ScheduledTasks] Updated task id=${id}`);
const task = db.getScheduledTask(id);
broadcastScheduledTasksChanged();
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Update error:', error);
@@ -317,6 +334,7 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
db.deleteScheduledTask(id);
console.log(`[ScheduledTasks] Deleted task id=${id}`);
broadcastScheduledTasksChanged();
res.json({ success: true });
} catch (error) {
console.error('[ScheduledTasks] Delete error:', error);
@@ -346,6 +364,7 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void =>
console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`);
const task = db.getScheduledTask(id);
broadcastScheduledTasksChanged();
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Toggle error:', error);
+1 -49
View File
@@ -12,7 +12,7 @@ import { UpdatePreviewService } from '../services/UpdatePreviewService';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission, checkPermission } from '../middleware/permissions';
import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates';
import { requirePaid, effectiveTier } from '../middleware/tierGates';
import { NotificationService, type NotificationCategory } from '../services/NotificationService';
import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService';
import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService';
@@ -253,16 +253,6 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => {
}
});
stacksRouter.get('/auto-update-settings', (req: Request, res: Response): void => {
try {
const settings = DatabaseService.getInstance().getStackAutoUpdateSettingsForNode(req.nodeId);
res.json(settings);
} catch (error) {
console.error('[Stacks] Failed to fetch auto-update settings:', error);
res.status(500).json({ error: 'Failed to fetch auto-update settings' });
}
});
type BulkLifecycleAction = 'start' | 'stop' | 'restart' | 'update';
const VALID_BULK_ACTIONS: ReadonlySet<BulkLifecycleAction> = new Set(['start', 'stop', 'restart', 'update']);
const BULK_PARALLELISM = 4;
@@ -420,43 +410,6 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => {
res.json({ action: typedAction, results });
});
stacksRouter.get('/:stackName/auto-update', (req: Request, res: Response): void => {
try {
const stackName = req.params.stackName as string;
const enabled = DatabaseService.getInstance().getStackAutoUpdateEnabled(req.nodeId, stackName);
res.json({ enabled });
} catch (error) {
console.error('[Stacks] Failed to fetch auto-update setting:', error);
res.status(500).json({ error: 'Failed to fetch auto-update setting' });
}
});
stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
try {
const stackName = req.params.stackName as string;
const { enabled } = req.body as { enabled?: unknown };
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: '"enabled" must be a boolean' });
return;
}
DatabaseService.getInstance().upsertStackAutoUpdateEnabled(req.nodeId, stackName, enabled);
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'stack',
nodeId: req.nodeId,
stackName,
action: 'auto-update-settings-changed',
ts: Date.now(),
});
res.json({ enabled });
} catch (error) {
console.error('[Stacks] Failed to update auto-update setting:', error);
res.status(500).json({ error: 'Failed to update auto-update setting' });
}
});
stacksRouter.get('/:stackName', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
@@ -852,7 +805,6 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
// Step 4: database cleanup. Per-call idempotent; safe to run sequentially.
try {
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName);
DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName);
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
DatabaseService.getInstance().deleteGitSource(stackName);