fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes (#545)

* fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes

- Fix orphaned task runs on policy/node deletion with transaction-wrapped cascade deletes
- Make manual trigger non-blocking (202 Accepted) to prevent proxy timeouts
- Distinguish registry check failures from clean "no update" results via structured ImageCheckResult
- Trim whitespace-only policy names in both frontend and backend validation
- Add strokeWidth={1.5} to action icons per design system
- Add sr-only DialogDescription for Radix accessibility
- Replace Select with Combobox for frequency picker
- Wrap run history sheet content in ScrollArea
- Support concurrent Run Now indicators via Set-based state
- Abort stale stack fetches on node switch with AbortController
- Add standard and diagnostic logging to SchedulerService and ImageUpdateService
- Add tests for cascade deletes, image checking, and scheduler edge cases
- Add troubleshooting section to auto-update docs

* fix(tests): resolve lint errors in image-update-service tests

Remove unused mock variables (mockGetImage, mockGetDocker) and unused
ImageCheckResult type import. Replace CommonJS require('yaml') with
ESM import to satisfy no-require-imports rule.

* chore(deps): bump Docker CLI to 29.4.0 and Compose to v5.1.2

Resolves Trivy CVE-2026-32282 (Go stdlib symlink follow in Root.Chmod)
by upgrading to releases that ship Go 1.25.9. Compose v5.1.2 also bumps
grpc to 1.80.0, resolving CVE-2026-33186.

* chore(security): accept CVE-2026-32282 in .trivyignore, update stale refs

Go stdlib symlink-following in Root.Chmod (CVE-2026-32282) affects both
Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). Fix
requires Go 1.25.9 or 1.26.2; no upstream static binary ships a patched
runtime yet. The vulnerable code path requires a chroot context with
attacker-controlled filesystem, which does not apply to our usage.

Also updates version references from v5.1.1/v29.3.1 to v5.1.2/v29.4.0
for existing CVE entries, and notes that Compose v5.1.2 resolved
CVE-2026-33186 (grpc bumped to 1.80.0) for the compose binary.
This commit is contained in:
Anso
2026-04-13 09:49:11 -04:00
committed by GitHub
parent c6e8efc2e7
commit a17b16b258
14 changed files with 903 additions and 100 deletions
+28 -10
View File
@@ -7,6 +7,7 @@ import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
import { NodeRegistry } from './NodeRegistry';
import { isDebugEnabled } from '../utils/debug';
// ─── Image ref parsing ────────────────────────────────────────────────────────
@@ -51,6 +52,11 @@ function parseImageRef(imageRef: string): ParsedRef | null {
return { registry, repo: rest, tag };
}
export interface ImageCheckResult {
hasUpdate: boolean;
error?: string;
}
// ─── Minimal HTTP helper ──────────────────────────────────────────────────────
interface HttpResult {
@@ -355,25 +361,29 @@ export class ImageUpdateService {
const allImages = new Set<string>();
for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img);
const imageUpdateMap = new Map<string, boolean>();
const imageUpdateMap = new Map<string, ImageCheckResult>();
for (const imageRef of allImages) {
try {
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
} catch (e) {
console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e);
imageUpdateMap.set(imageRef, false);
imageUpdateMap.set(imageRef, { hasUpdate: false, error: String(e) });
}
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
}
// Write status for ALL stacks (including those with no pullable images)
const now = Date.now();
let updatesFound = 0;
for (const [stackName, images] of stackImages) {
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true);
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img)?.hasUpdate === true);
if (hasUpdate) updatesFound++;
db.upsertStackUpdateStatus(nodeId, stackName, hasUpdate, now);
}
console.log(`[ImageUpdateService] Node ${nodeId}: checked ${allImages.size} image(s), ${updatesFound} stack(s) with updates`);
// Prune stale entries for stacks no longer on disk
const existing = db.getStackUpdateStatus(nodeId);
for (const staleStack of Object.keys(existing)) {
@@ -383,12 +393,19 @@ export class ImageUpdateService {
}
}
public async checkImage(docker: DockerController, imageRef: string): Promise<boolean> {
public async checkImage(docker: DockerController, imageRef: string): Promise<ImageCheckResult> {
const parsed = parseImageRef(imageRef);
if (!parsed) return false;
if (!parsed) return { hasUpdate: false };
if (isDebugEnabled()) {
console.log(`[ImageUpdateService] Checking ${imageRef}: registry=${parsed.registry} repo=${parsed.repo} tag=${parsed.tag}`);
}
// Look up stored credentials for this registry
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
if (isDebugEnabled()) {
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
}
// Get local digest from RepoDigests
let localDigest: string | null = null;
@@ -400,27 +417,28 @@ export class ImageUpdateService {
if (!rd.includes('@sha256:')) continue;
const [, digest] = rd.split('@');
// Match: rd contains the repo path or this is the only digest entry
if (rd.includes(parsed.repo) || rd.includes(parsed.registry) || repoDigests.length === 1) {
localDigest = digest;
break;
}
}
} catch {
return false; // Image inspect failed (removed since container was started)
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
}
if (!localDigest) return false; // Locally built or never pulled with a digest
if (!localDigest) return { hasUpdate: false };
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials);
if (!remoteDigest) return false; // Registry unreachable - no false positives
if (!remoteDigest) {
return { hasUpdate: false, error: `Registry unreachable for ${parsed.registry}/${parsed.repo}:${parsed.tag}` };
}
const hasUpdate = localDigest !== remoteDigest;
console.log(
`[ImageUpdateService] ${imageRef}: ` +
`local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}`
);
return hasUpdate;
return { hasUpdate };
}
}