fix(fleet): resolve self-update compose file access and improve completion detection (#441)

The self-update feature failed on remote nodes because SelfUpdateService
ran `docker compose -f <host_path>` inside the container, where the host
compose file path does not exist. The fix splits the update into two
steps: (1) pull the latest image directly via `docker pull`, and (2)
spawn a short-lived helper container that mounts the compose directory
from the host and runs `docker compose up --force-recreate`.

Additional changes:
- Use execFileSync/execFile with argument arrays instead of shell strings
  to eliminate shell injection surface from Docker label values
- Add Signal 4 completion detection: mark update as completed when the
  remote version matches the gateway version (with 15s elapsed guard)
- Extend early failure heuristic from 90s to 3 minutes for slow pulls
- Distinguish "node unreachable" from "node lacks self-update capability"
  in error messages; use silent skip in update-all to avoid res crashes
- Add requireAdmin guard to POST /api/system/update
- Handle comma-separated compose config file paths (multiple -f flags)
- Update fleet docs with self-update mechanism, troubleshooting entries
This commit is contained in:
Anso
2026-04-08 14:59:11 -04:00
committed by GitHub
parent 24011aea9e
commit 6fff2c2d35
7 changed files with 93 additions and 19 deletions
+38 -14
View File
@@ -1,4 +1,4 @@
import { execSync, exec } from 'child_process';
import { execFileSync, execFile } from 'child_process';
import DockerController from './DockerController';
import { disableCapability } from './CapabilityRegistry';
@@ -6,6 +6,7 @@ interface ComposeContext {
workingDir: string;
configFiles: string;
serviceName: string;
imageName: string;
}
class SelfUpdateService {
@@ -47,16 +48,24 @@ class SelfUpdateService {
// Verify docker compose CLI is available inside the container
try {
execSync('docker compose version', { shell: '/bin/sh', stdio: 'pipe', timeout: 5000 });
execFileSync('docker', ['compose', 'version'], { stdio: 'pipe', timeout: 5000 });
} catch {
console.log('[SelfUpdate] docker compose CLI not available in container');
disableCapability('self-update');
return;
}
this.composeContext = { workingDir, configFiles, serviceName };
// Read the container's own image name for direct docker pull
const imageName = info.Config?.Image;
if (!imageName) {
console.log('[SelfUpdate] Could not determine container image name');
disableCapability('self-update');
return;
}
this.composeContext = { workingDir, configFiles, serviceName, imageName };
this.canSelfUpdate = true;
console.log(`[SelfUpdate] Ready - service="${serviceName}" in ${workingDir}`);
console.log(`[SelfUpdate] Ready - service="${serviceName}" image="${imageName}" in ${workingDir}`);
} catch (error) {
console.log('[SelfUpdate] Could not inspect own container - self-update unavailable:', (error as Error).message);
disableCapability('self-update');
@@ -79,15 +88,15 @@ class SelfUpdateService {
triggerUpdate(): void {
if (!this.composeContext) return;
const { configFiles, serviceName } = this.composeContext;
const { workingDir, configFiles, serviceName, imageName } = this.composeContext;
const env = { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' };
this.lastUpdateError = null;
console.log(`[SelfUpdate] Pulling latest image for ${serviceName}...`);
// Step 1: Pull latest image directly (no compose file needed)
console.log(`[SelfUpdate] Pulling latest image: ${imageName}...`);
try {
execSync(`docker compose -f ${configFiles} pull ${serviceName}`, {
execFileSync('docker', ['pull', imageName], {
env,
shell: '/bin/sh',
stdio: 'pipe',
timeout: 300_000, // 5 min max for pull
});
@@ -98,12 +107,27 @@ class SelfUpdateService {
return;
}
console.log(`[SelfUpdate] Recreating container for ${serviceName}... (last breath)`);
exec(`docker compose -f ${configFiles} up -d --force-recreate ${serviceName}`, {
env,
shell: '/bin/sh',
});
// Process will be killed by Docker during recreate, no code runs after this
// Step 2: Spawn a helper container to run docker compose recreate.
// The main container cannot access the compose file because the host path
// from Docker labels does not exist inside this container. The helper
// explicitly mounts the compose working directory from the host, so the
// compose file is accessible at the original path.
console.log(`[SelfUpdate] Spawning updater container... (last breath)`);
const fFlags = configFiles.split(',').flatMap(f => ['-f', f.trim()]);
const composeCmd = ['sleep 3 && docker compose', ...fFlags, 'up -d --force-recreate', serviceName].join(' ');
const args = [
'run', '--rm', '-d',
'--user', 'root',
'--entrypoint', 'sh',
'-v', '/var/run/docker.sock:/var/run/docker.sock',
'-v', `${workingDir}:${workingDir}:ro`,
'-w', workingDir,
imageName,
'-c', composeCmd,
];
execFile('docker', args, { env });
// Process will be killed by Docker during recreate; no code runs after this
}
}