Files
ForgeFlow/tests/server-inventory-branches.test.mjs
NuklearRabbit 4c21616e72
ForgeFlow quality gate / quality (push) Canceled after 0s
fix: harden deployment discovery and preflight
2026-08-08 23:59:27 +02:00

151 lines
12 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const {
parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity,
stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase,
canonicalServerAppdataPath, deploymentRootCandidate,
} = require('../src/main/server-inventory.cjs');
const b64 = (value) => Buffer.from(String(value)).toString('base64');
test('inventory parser handles every evidence record and ignores malformed payloads', () => {
const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null };
const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null };
const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }];
const output = [
'noise', '__FORGEFLOW_INVENTORY__',
`H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`,
`R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`,
`C\t${b64(JSON.stringify([legacy, null]))}`,
`C\t${b64(JSON.stringify(safe))}`,
`C\t${b64('{bad json')}`,
`D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`,
`P\t${b64(JSON.stringify(projects))}`,
`P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`,
`Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`,
`W\t${b64('partial docker inspect failure')}`,
'UNKNOWN\tignored',
].join('\n');
const parsed = parseServerInventory(output);
assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' });
assert.equal(parsed.checkouts.length, 1);
assert.equal(parsed.containers.length, 2);
assert.equal(parsed.containers[0].health, 'healthy');
assert.deepEqual(parsed.containers[1].labels, {});
assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml');
assert.equal(parsed.composeProjects.length, 2);
assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']);
assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']);
assert.deepEqual(parsed.warnings, ['partial docker inspect failure']);
assert.throws(() => parseServerInventory('ordinary output'), /did not return/i);
});
test('server path normalization keeps deployments inside canonical appdata', () => {
assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App');
for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), '');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data');
assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path');
assert.equal(canonicalServerAppdataPath('/custom', ''), '');
assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App');
assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App');
});
test('profile matching requires the same server and accepts each stable identity form', () => {
const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] };
assert.equal(profileMatchesWorkload(null, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true);
assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector));
});
test('container matching prioritizes working directory, mounts, provenance and stable names', () => {
const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' };
const repository = { name: 'App' };
const base = { running: true, labels: {}, mounts: [], name: 'different' };
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0);
assert.equal(inventoryContainerMatch(checkout, repository, base), 0);
assert.equal(remoteIdentity(''), '');
});
test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => {
const labels = {
'com.docker.compose.project': 'app',
'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App',
'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml',
'com.docker.compose.service': 'web',
'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git',
'tech.itworx.forgeflow.commit': 'b'.repeat(40),
'tech.itworx.forgeflow.branch': 'main',
};
const inventory = {
containers: [
{ id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' },
{ id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' },
],
checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }],
composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }],
composeDefinitions: [
{ workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' },
{ workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true },
{ workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' },
],
dockerMan: [
{ name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' },
{ name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' },
], warnings: [], capabilities: {},
};
const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' };
const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] });
assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false);
const app = workloads.find((workload) => workload.displayName === 'app');
assert.equal(app.status, 'linked');
assert.equal(app.runtime.running, true);
assert.equal(app.runtime.allRunning, false);
assert.equal(app.runtime.health, 'unhealthy');
assert.equal(app.runtime.ports.length, 2);
assert.equal(app.containers[0].mounts[0].readOnly, true);
assert.equal(app.remoteFolderCandidate, 'App');
assert.equal(app.candidates[0].exact, true);
assert.equal(app.link.source, 'automatic');
const standalone = workloads.find((workload) => workload.displayName === 'Standalone');
assert.equal(standalone.metadata.composeDefinitionValid, false);
assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose');
const template = workloads.find((workload) => workload.displayName === 'template-only');
assert.equal(template.kind, 'dockerman-container');
assert.equal(template.runtime.running, false);
assert.equal(template.metadata.shell, '/bin/sh');
});
test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => {
assert.deepEqual(sanitizeLegacyContainer(null), {
id: '', name: '', image: '', imageId: '', running: false, status: '', health: null,
labels: {
'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '',
'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '',
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
});
assert.equal(sanitizeLegacyContainer({
Id: 'no-healthcheck',
Name: '/NoHealthcheck',
State: { Running: true, Status: 'running' },
Config: { Image: 'example/no-healthcheck:latest', Labels: null },
}).health, null);
});