Release ForgeFlow 0.6.0
This commit is contained in:
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, bash } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
|
||||
|
||||
test('Unraid remote paths cannot escape appdata project folder', () => {
|
||||
@@ -107,7 +107,7 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
|
||||
const savedStates = [];
|
||||
const operations = [];
|
||||
const store = {
|
||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '' }),
|
||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
|
||||
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
|
||||
getDeploymentState: () => ({ liveSha, previousSha }),
|
||||
addOperation: async (operation) => { operations.push(operation); return operation; },
|
||||
@@ -124,3 +124,144 @@ test('successful SSH rollback records the formerly live SHA as the new rollback
|
||||
assert.equal(savedStates.at(-1).previousSha, liveSha);
|
||||
assert.equal(operations.at(-1).previousSha, liveSha);
|
||||
});
|
||||
|
||||
test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
||||
const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' });
|
||||
assert.match(compose, / portfolio:/);
|
||||
assert.match(compose, /image: forgeflow\/portfolio:production/);
|
||||
assert.match(compose, /container_name: Portfolio/);
|
||||
});
|
||||
|
||||
test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => {
|
||||
const sha = 'd'.repeat(40);
|
||||
const operations = [];
|
||||
let resolveRemote;
|
||||
const store = {
|
||||
getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }),
|
||||
getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }),
|
||||
addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); },
|
||||
saveDeploymentState: async () => ({})
|
||||
};
|
||||
const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) };
|
||||
const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null });
|
||||
service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } });
|
||||
service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null });
|
||||
|
||||
const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha });
|
||||
assert.equal(operation.status, 'running');
|
||||
assert.match(operation.logs.join('\n'), /background/i);
|
||||
|
||||
resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(operations.at(-1).status, 'success');
|
||||
});
|
||||
|
||||
test('Unraid preflight verifies repository access before a deployment can start', async () => {
|
||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
||||
assert.match(source, /server-git-access/);
|
||||
assert.match(source, /git ls-remote --exit-code/);
|
||||
assert.match(source, /Unraid → Gitea access/);
|
||||
});
|
||||
|
||||
|
||||
test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
||||
const metadata = service.metadataCompose({
|
||||
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
|
||||
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh'
|
||||
}, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
||||
assert.match(metadata, / portfolio:/);
|
||||
assert.match(metadata, /image: forgeflow\/portfolio:production/);
|
||||
assert.match(metadata, /container_name: Portfolio/);
|
||||
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
|
||||
assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/);
|
||||
assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null });
|
||||
const profile = {
|
||||
composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio',
|
||||
environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh'
|
||||
};
|
||||
const repository = { name: 'Portfolio' };
|
||||
const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png';
|
||||
const template = service.dockerManTemplate(profile, repository, icon);
|
||||
const refresh = service.dockerManRefreshScript(profile, repository, icon);
|
||||
assert.match(template, /<Name>Portfolio<\/Name>/);
|
||||
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/);
|
||||
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
|
||||
assert.match(template, /<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/);
|
||||
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
|
||||
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
|
||||
assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/);
|
||||
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
|
||||
assert.equal(xmlEscape('A&B<"x">'), 'A&B<"x">');
|
||||
});
|
||||
|
||||
test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => {
|
||||
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-'));
|
||||
t.after(() => rm(sourcePath, { recursive: true, force: true }));
|
||||
const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
|
||||
await mkdir(path.dirname(asset), { recursive: true });
|
||||
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
|
||||
const uploads = [];
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {}, git: {}, diagnostics: null, sourcePath,
|
||||
ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } }
|
||||
});
|
||||
const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' });
|
||||
assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
||||
assert.equal(uploads.length, 1);
|
||||
assert.equal(uploads[0][0], 'unraid');
|
||||
assert.equal(uploads[0][1], asset);
|
||||
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
||||
});
|
||||
|
||||
test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => {
|
||||
const sha = 'f'.repeat(40);
|
||||
const saved = [];
|
||||
const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] };
|
||||
const store = {
|
||||
getOperation: () => operation,
|
||||
addOperation: async (next) => { saved.push(next); return next; }
|
||||
};
|
||||
const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null });
|
||||
service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true });
|
||||
const result = await service.refreshOperation('op-1');
|
||||
assert.equal(result.status, 'success');
|
||||
assert.match(result.logs.at(-1), /reconciled/i);
|
||||
assert.equal(saved.at(-1).status, 'success');
|
||||
});
|
||||
|
||||
test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => {
|
||||
const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8'));
|
||||
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
|
||||
assert.match(source, /dynamix\.docker\.manager\/images/);
|
||||
assert.match(source, /-icon\.png/);
|
||||
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
|
||||
assert.match(source, /--force-recreate/);
|
||||
});
|
||||
|
||||
|
||||
test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => {
|
||||
const requested = 'a'.repeat(40);
|
||||
const live = 'b'.repeat(40);
|
||||
const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] };
|
||||
const saved = [];
|
||||
const service = new UnraidDeploymentService({
|
||||
store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } },
|
||||
ssh: {}, git: {}, diagnostics: null
|
||||
});
|
||||
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
|
||||
const result = await service.refreshOperation(operation.id);
|
||||
assert.equal(result.status, 'cancelled');
|
||||
assert.match(result.error, /Superseded/);
|
||||
assert.equal(saved.at(-1).status, 'cancelled');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user