feat(updates): auto-prune dangling images after updates (#1316)

* feat(updates): auto-prune dangling images after updates

Each update pulls a fresh image and recreates containers, leaving the
replaced image behind as a dangling layer that previously had to be
pruned by hand. A new "Prune dangling images after updates" toggle under
Settings > System > Docker hygiene reclaims these automatically.

The setting is on by default and opt-out. When enabled, a successful
stack update (manual or scheduled) and a Sencho self-update each remove
the dangling image layers they orphaned. Only untagged layers are
touched; tagged images, volumes, and data are never removed. The toggle
requires an admin account and is per node: each instance honors its own
value, so a remote node self-update applies that node's own preference.

A prune failure never affects the update result: on the stack path it is
caught and logged after the update has already succeeded, and on the
self-update path the helper-shell prune runs only after a clean recreate
and cannot change the exit code or the recorded update error.

* security(self-update): shell-quote label-derived values in helper command

Address review feedback on the prune-on-update change:

- The self-update helper command interpolated the compose service name and
  config-file paths (both read from Docker Compose labels) straight into a
  shell string. Shell-quote them via shQuote so a label carrying shell
  metacharacters stays inert data and cannot break the exit-code capture,
  error-file write, or prune guard.
- Correct the settings copy and docs: the prune is a standard dangling-image
  prune, so it reclaims every untagged layer on the node, not only the one the
  current update orphaned. Tagged images, volumes, and data remain untouched.
- Add tests: shell-metacharacter neutralization and prune-output suppression in
  the self-update command, and an atomic-update case asserting a prune failure
  does not trigger a rollback.

* fix(updates): omit the reclaim figure when the daemon reports zero bytes

End-to-end testing on a Docker daemon backed by the containerd image store
showed the post-update prune removing a dangling image while the prune API
returned SpaceReclaimed=0, so the stream printed "reclaimed 0.0 MB" even though
an image was removed. Show the reclaimed figure only when the daemon reports a
non-zero value; otherwise the line reads "=== Pruned dangling images ===". The
overlay2 store still reports real figures and shows them. Add a test covering
both branches.
This commit is contained in:
Anso
2026-06-05 18:12:37 -04:00
committed by GitHub
parent 622af7e0b3
commit 716daf77d0
12 changed files with 324 additions and 10 deletions
+18
View File
@@ -527,6 +527,24 @@ export class ComposeService {
}
sendOutput('=== Stack updated successfully ===\n');
// Opt-out (default ON): after a clean update, prune the node's dangling
// (untagged) image layers, including the one this pull just orphaned. Read
// fresh each run so a remote node honors its own setting. Wrapped so a
// prune failure can never reach the atomic-rollback catch below.
try {
const pruneOnUpdate = DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
if (pruneOnUpdate) {
const result = await DockerController.getInstance(this.nodeId).pruneDanglingImages();
// The Docker prune API does not report SpaceReclaimed on the containerd
// image store, so only show the figure when the daemon actually returns one.
const reclaimed = result.reclaimedBytes > 0
? ` · reclaimed ${(result.reclaimedBytes / (1024 * 1024)).toFixed(1)} MB`
: '';
sendOutput(`=== Pruned dangling images${reclaimed} ===\n`);
}
} catch (pruneError) {
console.warn('Failed to prune dangling images after update for %s:', sanitizeForLog(stackName), pruneError);
}
if (debug) console.debug(`[ComposeService:debug] updateStack completed in ${Date.now() - t0}ms`, { stackName });
} catch (updateError) {
if (atomic) {
+1
View File
@@ -1255,6 +1255,7 @@ export class DatabaseService {
stmt.run('trivy_last_notified_version', '');
stmt.run('deploy_block_honor_suppressions', '0');
stmt.run('mesh_auto_recreate', '0');
stmt.run('prune_on_update', '1');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
+10
View File
@@ -263,6 +263,16 @@ class DockerController {
};
}
// Prune ONLY dangling (untagged) images. Distinct from pruneSystem('images'),
// which uses { dangling: { 'false': true } } to remove every unused image.
// Used by the prune-on-update flow to reclaim the layers a pull/recreate
// orphans, without touching tagged images for stopped stacks.
public async pruneDanglingImages(): Promise<{ success: boolean; reclaimedBytes: number }> {
const filters: Record<string, string[] | Record<string, boolean>> = { dangling: { 'true': true } };
const r = await this.docker.pruneImages({ filters });
return { success: true, reclaimedBytes: r.SpaceReclaimed || 0 };
}
public async getImages() {
const data = await this.docker.listImages({ all: false });
return this.validateApiData<any[]>(data);
+45 -8
View File
@@ -2,6 +2,7 @@ import { execFile } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { disableCapability } from './CapabilityRegistry';
import { isDebugEnabled } from '../utils/debug';
@@ -39,6 +40,45 @@ export function findDataDirHost(mounts: ReadonlyArray<DockerMount>): string | nu
return match?.Source ?? null;
}
// POSIX single-quote escaping. serviceName and the compose config paths in
// fFlags come from Docker Compose labels on Sencho's own container, so a label
// carrying shell metacharacters must not be able to break out of the command
// sequence (the exit-code capture, error-file write, and prune guard).
export function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Build the shell command the helper container runs to recreate Sencho. Kept as
* a pure, exported function so the prune-on-update branch is unit-testable.
*
* Label-derived inputs (serviceName, the fFlags config paths) are shell-quoted
* so they cannot alter the command structure. The recreate writes the error
* file only on failure; the optional dangling prune runs only on success, so
* the two branches never overlap. The prune suppresses its own output and
* `|| true`, so it can never alter $ec or be mistaken for an update error.
*/
export function buildSelfUpdateComposeCmd(
fFlags: string[],
serviceName: string,
stderrTmp: string,
errorFile: string,
pruneOnUpdate: boolean,
): string {
const recreate = ['docker compose', ...fFlags.map(shQuote), 'up -d --force-recreate', shQuote(serviceName), `2>${stderrTmp}`].join(' ');
return [
'sleep 3',
recreate,
'ec=$?',
`if [ $ec -ne 0 ]; then { echo "exit=$ec"; cat ${stderrTmp}; } > ${errorFile} 2>/dev/null; fi`,
...(pruneOnUpdate
? [`if [ $ec -eq 0 ]; then docker image prune -f >/dev/null 2>&1 || true; fi`]
: []),
`cat ${stderrTmp} >&2 2>/dev/null`,
'exit $ec',
].join('; ');
}
interface ComposeContext {
workingDir: string;
configFiles: string;
@@ -198,15 +238,12 @@ class SelfUpdateService {
// On failure, persist exit code + stderr to UPDATE_ERROR_FILE (host-mounted)
// so the NEW gateway can read it after restart if we die mid-execution.
// Opt-out (default ON): after a clean recreate, prune the dangling image
// layers the pull orphaned. Read fresh so this node honors its own setting.
const stderrTmp = '/tmp/_sencho_err';
const composeCmd = [
'sleep 3',
['docker compose', ...fFlags, 'up -d --force-recreate', serviceName, `2>${stderrTmp}`].join(' '),
'ec=$?',
`if [ $ec -ne 0 ]; then { echo "exit=$ec"; cat ${stderrTmp}; } > ${UPDATE_ERROR_FILE} 2>/dev/null; fi`,
`cat ${stderrTmp} >&2 2>/dev/null`,
'exit $ec',
].join('; ');
const pruneOnUpdate =
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
const composeCmd = buildSelfUpdateComposeCmd(fFlags, serviceName, stderrTmp, UPDATE_ERROR_FILE, pruneOnUpdate);
const mountArgs: string[] = [
'-v', '/var/run/docker.sock:/var/run/docker.sock',