From ca8f22734d1c6f960cf564cf9fa9588396fbd1fd Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 7 Apr 2026 01:44:39 -0400 Subject: [PATCH] fix(auto-update): proxy update execution to remote nodes via Distributed API (#419) * fix(auto-update): proxy update execution to remote nodes via Distributed API Remote auto-update policies previously failed because the scheduler tried to access the Docker daemon directly on remote nodes. Now the scheduler detects remote nodes and proxies the update execution via HTTP to the remote Sencho instance's new /api/auto-update/execute endpoint, which runs image checks and compose updates locally on the remote machine. * test(auto-update): add getNode mock to NodeRegistry in scheduler tests The executeUpdate method now calls NodeRegistry.getNode() to detect remote nodes. The test mock for NodeRegistry was missing this method, causing the two executeUpdate tests to fail. --- CHANGELOG.md | 6 ++ .../src/__tests__/scheduler-service.test.ts | 1 + backend/src/index.ts | 97 +++++++++++++++++++ backend/src/services/SchedulerService.ts | 44 ++++++++- docs/features/auto-update-policies.mdx | 6 ++ 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c831b351..cafe25af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **auto-update:** resolve failure when executing auto-update policies on remote Distributed API nodes. Previously, the scheduler tried to access the remote Docker daemon directly, which is not supported. Now the update execution is proxied to the remote Sencho instance via HTTP, matching the existing Distributed API architecture. + ## [0.39.6](https://github.com/AnsoCode/Sencho/compare/v0.39.5...v0.39.6) (2026-04-07) diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 90065287..b262cb28 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -116,6 +116,7 @@ vi.mock('../services/NodeRegistry', () => ({ NodeRegistry: { getInstance: () => ({ getDefaultNodeId: () => 1, + getNode: mockGetNode, }), }, })); diff --git a/backend/src/index.ts b/backend/src/index.ts index 26aab653..7bb58194 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5412,6 +5412,103 @@ app.get('/api/image-updates/fleet', authMiddleware, async (_req: Request, res: R } }); +// ========================= +// Auto-Update Execution API +// ========================= + +// Execute auto-update for a single stack (or all stacks with target "*"). +// This runs locally on whichever Sencho instance receives the request. +// The gateway scheduler proxies this to remote nodes via HTTP. +app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const { target } = req.body as { target?: string }; + if (!target || typeof target !== 'string') { + return res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' }); + } + + let stackNames: string[]; + if (target === '*') { + stackNames = await FileSystemService.getInstance(req.nodeId).getStacks(); + if (stackNames.length === 0) { + return res.json({ result: 'No stacks found on node; skipped.' }); + } + } else { + if (!isValidStackName(target)) { + return res.status(400).json({ error: 'Invalid stack name' }); + } + stackNames = [target]; + } + + const docker = DockerController.getInstance(req.nodeId); + const imageUpdateService = ImageUpdateService.getInstance(); + const compose = ComposeService.getInstance(req.nodeId); + const db = DatabaseService.getInstance(); + const atomic = LicenseService.getInstance().getTier() === 'paid'; + const results: string[] = []; + + for (const stackName of stackNames) { + try { + const containers = await docker.getContainersByStack(stackName); + if (!containers || containers.length === 0) { + results.push(`Stack "${stackName}": no containers found; skipped.`); + continue; + } + + const imageRefs = [...new Set( + containers + .map((c: { Image?: string }) => c.Image) + .filter((img): img is string => !!img && !img.startsWith('sha256:')) + )]; + + if (imageRefs.length === 0) { + results.push(`Stack "${stackName}": no pullable images; skipped.`); + continue; + } + + let hasUpdate = false; + const updatedImages: string[] = []; + for (const imageRef of imageRefs) { + try { + if (await imageUpdateService.checkImage(docker, imageRef)) { + hasUpdate = true; + updatedImages.push(imageRef); + } + } catch (e) { + console.warn(`[AutoUpdate] Failed to check image ${imageRef}:`, e); + } + } + + if (!hasUpdate) { + results.push(`Stack "${stackName}": all images up to date.`); + continue; + } + + await compose.updateStack(stackName, undefined, atomic); + db.clearStackUpdateStatus(req.nodeId, stackName); + + NotificationService.getInstance().dispatchAlert( + 'info', + `Auto-update: stack "${stackName}" updated with new images`, + stackName + ); + + results.push(`Stack "${stackName}": updated (${updatedImages.join(', ')}).`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + results.push(`Stack "${stackName}" failed: ${msg}`); + console.error(`[AutoUpdate] Failed for stack "${stackName}":`, e); + } + } + + res.json({ result: results.join('\n') }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Auto-update execution failed'; + console.error('[AutoUpdate] Execute error:', msg); + res.status(500).json({ error: msg }); + } +}); + // ========================= // Node Management API // ========================= diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 7dae2717..80377312 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -369,12 +369,18 @@ export class SchedulerService { throw new Error('Auto-update requires target_id (stack name or "*") and node_id'); } - // Resolve target stacks: "*" means all stacks on the node + // For remote nodes, proxy the entire execution to the remote Sencho instance + const node = NodeRegistry.getInstance().getNode(task.node_id); + if (node?.type === 'remote') { + return this.executeUpdateRemote(task.node_id, task.target_id); + } + + // Local node: execute directly let stackNames: string[]; if (task.target_id === '*') { stackNames = await FileSystemService.getInstance(task.node_id).getStacks(); if (stackNames.length === 0) { - return 'No stacks found on node — skipped.'; + return 'No stacks found on node; skipped.'; } } else { stackNames = [task.target_id]; @@ -400,6 +406,36 @@ export class SchedulerService { return results.join('\n'); } + /** + * Proxy auto-update execution to a remote Sencho instance. + * The remote node runs the image checks and compose update locally. + */ + private async executeUpdateRemote(nodeId: number, target: string): Promise { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!proxyTarget) { + throw new Error('Remote node is not configured or missing API credentials'); + } + + const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); + const response = await fetch(`${baseUrl}/api/auto-update/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${proxyTarget.apiToken}`, + }, + body: JSON.stringify({ target }), + signal: AbortSignal.timeout(300_000), // 5 minute timeout for long updates + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`); + } + + const body = await response.json() as { result?: string }; + return body.result || 'Remote auto-update completed (no details returned).'; + } + private async executeUpdateForStack( stackName: string, nodeId: number, @@ -410,7 +446,7 @@ export class SchedulerService { ): Promise { const containers = await docker.getContainersByStack(stackName); if (!containers || containers.length === 0) { - return `Stack "${stackName}": no containers found — skipped.`; + return `Stack "${stackName}": no containers found; skipped.`; } const imageRefs = [...new Set( @@ -420,7 +456,7 @@ export class SchedulerService { )]; if (imageRefs.length === 0) { - return `Stack "${stackName}": no pullable images — skipped.`; + return `Stack "${stackName}": no pullable images; skipped.`; } let hasUpdate = false; diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index ff9df405..b529a300 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -99,6 +99,12 @@ Click the clock icon on any policy to open the run history panel. The history is Run history is paginated at 20 entries per page. You can export the full history as CSV using the download button in the panel header. +## Multi-node support + +Auto-Update Policies work seamlessly across both local and remote nodes. When a policy targets a remote node, Sencho automatically proxies the update execution to the remote Sencho instance via the Distributed API. The remote node performs all image checks and compose updates locally on its own machine, then reports the results back. + +No additional configuration is required. As long as your remote node is connected and reachable, auto-update policies will execute on it just like they do on the local node. + ## How it works Under the hood, Auto-Update Policies are built on the same scheduling engine as [Scheduled Operations](/features/scheduled-operations). The key difference is that auto-update policies: