fix(fleet): resolve ENOENT when triggering remote node self-update (#413)

The execSync call in SelfUpdateService used cwd from Docker Compose
labels (host-side path) which does not exist inside the container.
Removed cwd (the -f flag provides the absolute compose file path),
added explicit shell, added Docker Compose CLI check at startup,
and improved error capture via stderr.
This commit is contained in:
Anso
2026-04-06 23:07:03 -04:00
committed by GitHub
parent 7f1d32f616
commit 1b890b4d03
2 changed files with 20 additions and 9 deletions
+1
View File
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
* **fleet:** fix `spawnSync /bin/sh ENOENT` when triggering remote node self-update. The `execSync` call used `cwd: workingDir` from Docker Compose labels, which is a host-side path that does not exist inside the container. Removed `cwd` (the `-f` flag already provides the absolute compose file path), added `shell: true`, and added a Docker Compose CLI availability check at startup.
* **fleet:** fix version detection returning stale value from `generated/version.ts` instead of the authoritative `package.json`. This caused remote nodes to show "unknown" version and false "Update available" badges when both nodes were on the same version. `resolveVersion()` now reads the root `package.json` first and only falls back to the build-time constant if the walk fails.
* **fleet:** fix permanently stuck "Timed out" / "Failed" badges after node update attempts. The in-memory update tracker now supports clearing via a new DELETE endpoint, and terminal states are automatically clearable through the Recheck button.
* **fleet:** fix update completion detection for remote nodes that cannot report their version. The gateway now uses three completion signals: version change, process restart detection (`startedAt`), and offline/online detection (node went unreachable during update and came back). This eliminates false timeouts on nodes running older Sencho versions.
+19 -9
View File
@@ -24,7 +24,7 @@ class SelfUpdateService {
async initialize(): Promise<void> {
const hostname = process.env.HOSTNAME;
if (!hostname) {
console.log('[SelfUpdate] HOSTNAME not set self-update unavailable (not running in Docker?)');
console.log('[SelfUpdate] HOSTNAME not set - self-update unavailable (not running in Docker?)');
disableCapability('self-update');
return;
}
@@ -40,16 +40,25 @@ class SelfUpdateService {
const serviceName = labels['com.docker.compose.service'];
if (!workingDir || !configFiles || !serviceName) {
console.log('[SelfUpdate] Container lacks Docker Compose labels self-update unavailable');
console.log('[SelfUpdate] Container lacks Docker Compose labels - self-update unavailable');
disableCapability('self-update');
return;
}
// Verify docker compose CLI is available inside the container
try {
execSync('docker compose version', { shell: '/bin/sh', stdio: 'pipe', timeout: 5000 });
} catch {
console.log('[SelfUpdate] docker compose CLI not available in container');
disableCapability('self-update');
return;
}
this.composeContext = { workingDir, configFiles, serviceName };
this.canSelfUpdate = true;
console.log(`[SelfUpdate] Ready service="${serviceName}" in ${workingDir}`);
console.log(`[SelfUpdate] Ready - service="${serviceName}" in ${workingDir}`);
} catch (error) {
console.log('[SelfUpdate] Could not inspect own container self-update unavailable:', (error as Error).message);
console.log('[SelfUpdate] Could not inspect own container - self-update unavailable:', (error as Error).message);
disableCapability('self-update');
}
}
@@ -70,30 +79,31 @@ class SelfUpdateService {
triggerUpdate(): void {
if (!this.composeContext) return;
const { workingDir, configFiles, serviceName } = this.composeContext;
const { configFiles, serviceName } = 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}...`);
try {
execSync(`docker compose -f ${configFiles} pull ${serviceName}`, {
cwd: workingDir,
env,
shell: '/bin/sh',
stdio: 'pipe',
timeout: 300_000, // 5 min max for pull
});
} catch (error) {
this.lastUpdateError = (error as Error).message;
const stderr = (error as { stderr?: Buffer })?.stderr?.toString().trim();
this.lastUpdateError = stderr || (error as Error).message;
console.error('[SelfUpdate] Pull failed:', this.lastUpdateError);
return;
}
console.log(`[SelfUpdate] Recreating container for ${serviceName}... (last breath)`);
exec(`docker compose -f ${configFiles} up -d --force-recreate ${serviceName}`, {
cwd: workingDir,
env,
shell: '/bin/sh',
});
// Process will be killed by Docker during recreate no code runs after this
// Process will be killed by Docker during recreate, no code runs after this
}
}