mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user