Files
dockhand-upstream/tests/backups/stackfile-filter.test.ts
T

216 lines
11 KiB
TypeScript

/**
* Unit tests for stackfile-filter — deciding which paths under a stack dir are
* bind-mounted DATA (already a restic volume, exclude) vs load-bearing config (keep).
*
* The exclusion is derived by PARSING THE COMPOSE FILE for relative bind sources, not
* from docker Mount.Source (a containerized Dockhand can't see the host-side source, so
* keying on it silently no-ops). These tests feed synthetic compose strings + an
* injected isDirRel and assert the derived relative dirs.
*/
import { describe, test, expect } from 'bun:test';
import { relativeBindDirsFromCompose, relativeBindsFromCompose, isUnderRelDir, isLoadBearingStackFile } from '../../src/lib/server/backups/stackfile-filter';
const STACK = '/app/data/stacks/env/name';
const allDirs = () => true; // treat every candidate as a directory
const noDirs = () => false; // treat every candidate as a file
describe('relativeBindsFromCompose — files AND dirs (for host-path derivation)', () => {
test('a SINGLE-FILE bind is included (dirs-only misses it)', () => {
const c = `services:\n app:\n volumes:\n - ./config.yaml:/config.yaml`;
// dirs-only (isDirRel=false for a file) drops it; the all-binds variant keeps it so a
// single-file-bind stack can derive its host path without the working_dir label.
expect(relativeBindDirsFromCompose(c, STACK, noDirs)).toEqual([]);
expect(relativeBindsFromCompose(c, STACK)).toEqual(['config.yaml']);
});
test('files and dirs together', () => {
const c = `services:\n app:\n volumes:\n - ./config.yaml:/c\n - ./data:/d`;
expect(relativeBindsFromCompose(c, STACK).sort()).toEqual(['config.yaml', 'data']);
});
test('named volumes and escaping/whole-dir binds are excluded', () => {
const c = `services:\n app:\n volumes:\n - mydata:/x\n - ../outside:/y\n - ./:/z`;
expect(relativeBindsFromCompose(c, STACK)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — short syntax', () => {
test('./data and ./config relative binds -> [data, config]', () => {
const c = `services:
web:
volumes:
- ./data:/var/lib/data
- ./config:/etc/app:ro`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs).sort()).toEqual(['config', 'data']);
});
test('bare "data:/x" (no ./) is a NAMED VOLUME per compose spec, not a bind -> []', () => {
// Compose treats a bare name as a named volume; a host bind must be ./data or /abs.
const c = `services:\n w:\n volumes:\n - data:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('a NAMED volume (db_data:/x) is never excluded', () => {
// A bare name with no path-ish prefix is a named volume, not a bind.
const c = `services:\n db:\n volumes:\n - db_data:/var/lib/postgresql/data`;
// db_data has no slash/dot -> treated as named volume -> dropped.
// (Also, isDirRel would be false for a name that isn't a real subdir, but the
// parser drops it before that.)
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — long syntax', () => {
test('{ type: bind, source: ./config } -> [config]', () => {
const c = `services:
web:
volumes:
- type: bind
source: ./config
target: /etc/app`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['config']);
});
test('{ type: volume, source: db } is NOT excluded', () => {
const c = `services:
db:
volumes:
- type: volume
source: dbdata
target: /data`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — edge cases', () => {
test('single-FILE bind (./config.yaml) is not a dir -> not excluded', () => {
const c = `services:\n w:\n volumes:\n - ./config.yaml:/etc/app/config.yaml`;
expect(relativeBindDirsFromCompose(c, STACK, noDirs)).toEqual([]);
});
test('../sibling escapes the stack dir -> dropped', () => {
const c = `services:\n w:\n volumes:\n - ../sibling:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('absolute bind pointing INTO the stack dir -> relativized and kept', () => {
const c = `services:\n w:\n volumes:\n - ${STACK}/data:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['data']);
});
test('absolute bind OUTSIDE the stack dir -> dropped', () => {
const c = `services:\n w:\n volumes:\n - /var/lib/other:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('a ${VAR} in the path -> left captured (conservative), not excluded', () => {
const c = 'services:\n w:\n volumes:\n - ./data-${ENV}:/x';
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual([]);
});
test('trailing slash and ./ prefix normalise the same', () => {
const c = `services:\n w:\n volumes:\n - ./data/:/x`;
expect(relativeBindDirsFromCompose(c, STACK, allDirs)).toEqual(['data']);
});
test('malformed / empty compose -> []', () => {
expect(relativeBindDirsFromCompose('', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(':::not yaml:::', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose('services: {}', STACK, allDirs)).toEqual([]);
});
});
describe('relativeBindDirsFromCompose — exotic real-world compose (users have imagination)', () => {
const c = (vol: string) => `services:\n w:\n volumes:\n - ${vol}`;
test('mode suffixes ro/rw/z/Z are stripped -> [data]', () => {
for (const m of ['ro', 'rw', 'z', 'Z', 'cached', 'delegated']) {
expect(relativeBindDirsFromCompose(c(`./data:/x:${m}`), STACK, allDirs)).toEqual(['data']);
}
});
test('quoted sources (single and double) -> [data]', () => {
expect(relativeBindDirsFromCompose(c('"./data:/x"'), STACK, allDirs)).toEqual(['data']);
expect(relativeBindDirsFromCompose(c("'./data:/x'"), STACK, allDirs)).toEqual(['data']);
});
test('a space in the path is preserved', () => {
expect(relativeBindDirsFromCompose(c('"./my data:/x"'), STACK, allDirs)).toEqual(['my data']);
});
test('a nested relative dir ./a/b -> [a/b]', () => {
expect(relativeBindDirsFromCompose(c('./a/b:/x'), STACK, allDirs)).toEqual(['a/b']);
});
test('WHOLE-DIR bind (. or ./) excludes NOTHING (would strip config sidecars) -> []', () => {
expect(relativeBindDirsFromCompose(c('.:/app'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('./:/app'), STACK, allDirs)).toEqual([]);
// an absolute source equal to the stack dir is the same whole-dir case
expect(relativeBindDirsFromCompose(c(`${STACK}:/app`), STACK, allDirs)).toEqual([]);
});
test('${VAR} forms are all left captured (never dropped): default, bare, mid-path', () => {
expect(relativeBindDirsFromCompose(c('${DATADIR:-./data}:/x'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('./data-${ENV}:/x'), STACK, allDirs)).toEqual([]);
});
test('a bare $VAR (no braces) or ~ home is not a recognised relative bind -> []', () => {
expect(relativeBindDirsFromCompose(c('$DATADIR/data:/x'), STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('~/data:/x'), STACK, allDirs)).toEqual([]);
});
test('a Windows-style source (C:\\\\...) is not a posix relative/abs bind -> []', () => {
expect(relativeBindDirsFromCompose(c('C:\\\\stuff:/x'), STACK, allDirs)).toEqual([]);
});
test('long syntax with read_only / propagation still resolves the bind', () => {
const ro = `services:\n w:\n volumes:\n - type: bind\n source: ./data\n target: /x\n read_only: true`;
expect(relativeBindDirsFromCompose(ro, STACK, allDirs)).toEqual(['data']);
const prop = `services:\n w:\n volumes:\n - type: bind\n source: ./cfg\n target: /x\n bind:\n propagation: rslave`;
expect(relativeBindDirsFromCompose(prop, STACK, allDirs)).toEqual(['cfg']);
});
test('long syntax WITHOUT type but a relative source is treated as a bind', () => {
const c2 = `services:\n w:\n volumes:\n - source: ./data\n target: /x`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('the SAME bind across two services is de-duplicated', () => {
const c2 = `services:\n a:\n volumes:\n - ./data:/x\n b:\n volumes:\n - ./data:/y`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('a top-level `volumes:` named-volume declaration is ignored (only service volumes count)', () => {
const c2 = `services:\n w:\n volumes:\n - ./data:/x\nvolumes:\n named_one:`;
expect(relativeBindDirsFromCompose(c2, STACK, allDirs)).toEqual(['data']);
});
test('null volumes: / service without volumes / empty string entry -> []', () => {
expect(relativeBindDirsFromCompose('services:\n w:\n volumes:', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose('services:\n w:\n image: nginx', STACK, allDirs)).toEqual([]);
expect(relativeBindDirsFromCompose(c('""'), STACK, allDirs)).toEqual([]);
});
test('a mix in one service: some excluded, some kept, some ignored', () => {
const c2 = `services:
app:
volumes:
- ./data:/data:ro # dir bind -> excluded
- ./config.yaml:/etc/c.yaml # single file -> kept
- db_vol:/var/lib/db # named volume -> ignored
- /etc/localtime:/etc/localtime:ro # abs outside -> ignored
- ../shared:/shared # escapes -> ignored`;
const isDir = (r: string) => r === 'data'; // only ./data is a dir; config.yaml is a file
expect(relativeBindDirsFromCompose(c2, STACK, isDir)).toEqual(['data']);
});
});
describe('isLoadBearingStackFile — never excluded even under a whole-dir bind', () => {
test('.env and .env.* and yaml/yml are load-bearing', () => {
expect(isLoadBearingStackFile('.env')).toBe(true);
expect(isLoadBearingStackFile('.env.production')).toBe(true);
expect(isLoadBearingStackFile('compose.yaml')).toBe(true);
expect(isLoadBearingStackFile('docker-compose.yml')).toBe(true);
expect(isLoadBearingStackFile('includes/base.yaml')).toBe(true);
});
test('data blobs are not load-bearing', () => {
expect(isLoadBearingStackFile('data/db.sqlite')).toBe(false);
expect(isLoadBearingStackFile('config/nginx.conf')).toBe(false);
});
});
describe('isUnderRelDir', () => {
const dirs = ['data', 'config'];
test('the dir itself and files inside it match', () => {
expect(isUnderRelDir('data', dirs)).toBe(true);
expect(isUnderRelDir('data/db.sqlite', dirs)).toBe(true);
expect(isUnderRelDir('config/nginx.conf', dirs)).toBe(true);
});
test('root config files do not match', () => {
expect(isUnderRelDir('compose.yaml', dirs)).toBe(false);
expect(isUnderRelDir('.env', dirs)).toBe(false);
});
test('segment-aware: a prefix-sharing sibling does not match', () => {
expect(isUnderRelDir('data-backup/x', dirs)).toBe(false);
expect(isUnderRelDir('datastore', dirs)).toBe(false);
});
test('no dirs -> nothing matches', () => {
expect(isUnderRelDir('data/db', [])).toBe(false);
});
});