mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
716daf77d0
* 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.
102 lines
4.7 KiB
TypeScript
102 lines
4.7 KiB
TypeScript
/**
|
|
* findDataDirHost detects the host-side path for /app/data across bind AND
|
|
* named-volume mounts. Pre-fix the helper bound the resolver to Type='bind'
|
|
* only, so a pilot-agent deployed with the recommended `sencho-agent-data:/
|
|
* app/data` named volume logged "/app/data mount not found - update error
|
|
* recovery will be unavailable" at boot.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { buildSelfUpdateComposeCmd, findDataDirHost, shQuote } from '../services/SelfUpdateService';
|
|
|
|
describe('findDataDirHost', () => {
|
|
it('returns the host path for a bind mount at /app/data', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'bind', Source: '/opt/sencho/data', Destination: '/app/data' },
|
|
]);
|
|
expect(source).toBe('/opt/sencho/data');
|
|
});
|
|
|
|
it('returns the host path for a named volume at /app/data', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'volume', Source: '/var/lib/docker/volumes/sencho-agent-data/_data', Destination: '/app/data' },
|
|
]);
|
|
expect(source).toBe('/var/lib/docker/volumes/sencho-agent-data/_data');
|
|
});
|
|
|
|
it('returns null when no mount targets /app/data', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'bind', Source: '/var/run/docker.sock', Destination: '/var/run/docker.sock' },
|
|
{ Type: 'bind', Source: '/opt/compose', Destination: '/app/compose' },
|
|
]);
|
|
expect(source).toBeNull();
|
|
});
|
|
|
|
it('picks the /app/data mount out of a mixed list and ignores siblings', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'bind', Source: '/var/run/docker.sock', Destination: '/var/run/docker.sock' },
|
|
{ Type: 'volume', Source: '/var/lib/docker/volumes/sencho-agent-data/_data', Destination: '/app/data' },
|
|
{ Type: 'bind', Source: '/opt/compose', Destination: '/app/compose' },
|
|
]);
|
|
expect(source).toBe('/var/lib/docker/volumes/sencho-agent-data/_data');
|
|
});
|
|
|
|
it('returns null when a /app/data entry carries no Source', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'bind', Source: '', Destination: '/app/data' },
|
|
]);
|
|
expect(source).toBeNull();
|
|
});
|
|
|
|
it('ignores tmpfs and other non-bind/volume types', () => {
|
|
const source = findDataDirHost([
|
|
{ Type: 'tmpfs', Source: '', Destination: '/app/data' },
|
|
]);
|
|
expect(source).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildSelfUpdateComposeCmd', () => {
|
|
const fFlags = ['-f', '/app/docker-compose.yml'];
|
|
const stderrTmp = '/tmp/_sencho_err';
|
|
const errorFile = '/app/data/.sencho-update-error';
|
|
|
|
it('appends a success-guarded dangling-image prune when pruneOnUpdate is true', () => {
|
|
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, true);
|
|
expect(cmd).toContain('if [ $ec -eq 0 ]; then docker image prune -f');
|
|
// The prune suppresses its own output and `|| true` so it can never alter
|
|
// the helper exit code; the command still ends on exit $ec.
|
|
expect(cmd).toContain('docker image prune -f >/dev/null 2>&1 || true');
|
|
expect(cmd.trim().endsWith('exit $ec')).toBe(true);
|
|
// Order matters: the prune must run after $ec is captured and after the
|
|
// error-file write, or it could shadow the recreate's exit code / clobber
|
|
// the error file. Lock the ordering, not just the presence of the line.
|
|
expect(cmd.indexOf('ec=$?')).toBeLessThan(cmd.indexOf('docker image prune'));
|
|
expect(cmd.indexOf(errorFile)).toBeLessThan(cmd.indexOf('docker image prune'));
|
|
});
|
|
|
|
it('omits the prune entirely when pruneOnUpdate is false', () => {
|
|
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, false);
|
|
expect(cmd).not.toContain('docker image prune');
|
|
});
|
|
|
|
it('always recreates the service and persists the error file on failure', () => {
|
|
const cmd = buildSelfUpdateComposeCmd(fFlags, 'sencho', stderrTmp, errorFile, true);
|
|
expect(cmd).toContain(`up -d --force-recreate ${shQuote('sencho')}`);
|
|
expect(cmd).toContain(`> ${errorFile}`);
|
|
});
|
|
|
|
it('shell-quotes label-derived values so metacharacters cannot break the command', () => {
|
|
// serviceName and config paths come from Docker Compose labels; a hostile
|
|
// label must stay inert data, not run as a second command.
|
|
const evilFlags = ['-f', '/tmp/compose.yml; ec=0; #'];
|
|
const cmd = buildSelfUpdateComposeCmd(evilFlags, 'svc; rm -rf /', stderrTmp, errorFile, true);
|
|
// The dangerous text survives only inside single quotes, never as bare shell.
|
|
expect(cmd).toContain(shQuote('/tmp/compose.yml; ec=0; #'));
|
|
expect(cmd).toContain(shQuote('svc; rm -rf /'));
|
|
expect(cmd).not.toContain('up -d --force-recreate svc; rm -rf /');
|
|
// The recreate line stays intact: its redirection and the real exit-code
|
|
// capture follow the quoted args, so the injected `ec=0` never runs as shell.
|
|
expect(cmd).toContain(`2>${stderrTmp}; ec=$?;`);
|
|
});
|
|
});
|