Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+406 -91
View File
@@ -88,12 +88,36 @@ function checksSummary(checks) {
};
}
function xmlEscape(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function iconReferenceLocalPath(iconReference) {
const value = String(iconReference || '').trim();
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
if (value.startsWith('/')) return value;
return '';
}
class UnraidDeploymentService {
constructor({ store, ssh, git, diagnostics }) {
constructor({ store, ssh, git, diagnostics, sourcePath = process.cwd(), onOperationChange = null }) {
this.store = store;
this.ssh = ssh;
this.git = git;
this.diagnostics = diagnostics;
this.sourcePath = sourcePath;
this.onOperationChange = onOperationChange;
}
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
return saved;
}
resolve(repository, profileId) {
@@ -225,6 +249,20 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' });
else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint });
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: 'No server-usable Git clone URL is configured.' });
} else {
try {
const branchRef = `refs/heads/${String(profile.branch || 'main')}`;
const probe = await this.ssh.exec(server.id, bash(`git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`), { timeout: 45_000, maxOutput: 256 * 1024 });
const remoteSha = String(probe.stdout || '').trim().split(/\s+/)[0] || 'reachable';
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'pass', detail: `${cloneUrl} · ${String(remoteSha).slice(0, 7)}` });
} catch (error) {
checks.push({ id: 'server-git-access', label: 'Unraid → Gitea access', status: 'fail', detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}` });
}
}
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
@@ -274,6 +312,19 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
} catch (error) {
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
}
const iconMode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (iconMode === 'upload') {
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
checks.push({ id: 'dockerman-icon-file', label: 'DockerMan icon upload', status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === '.png' ? 'pass' : 'fail', detail: iconStat?.isFile() ? profile.iconFilePath : 'The selected local PNG icon file was not found.' });
} else if (iconMode === 'builtin') {
const builtinIcon = nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png');
const iconStat = await fs.stat(builtinIcon).catch(() => null);
checks.push({ id: 'dockerman-icon-builtin', label: 'DockerMan icon', status: iconStat?.isFile() ? 'pass' : 'fail', detail: iconStat?.isFile() ? 'Built-in high-contrast ITWorx mark.' : 'The built-in ITWorx icon asset is missing.' });
} else if (iconMode === 'url') checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: profile.iconUrl ? 'pass' : 'fail', detail: profile.iconUrl || 'Icon URL mode requires an HTTPS or HTTP PNG URL.' });
else checks.push({ id: 'dockerman-icon', label: 'DockerMan icon', status: 'warning', detail: 'Custom DockerMan icon disabled.' });
const webUiLabel = this.dockerManWebUi(profile);
checks.push({ id: 'dockerman-webui', label: 'DockerMan Web UI action', status: webUiLabel ? 'pass' : 'warning', detail: webUiLabel || 'No Web UI URL or host port is configured.' });
checks.push({ id: 'compose-identity', label: 'Safe Docker Compose identity', status: 'pass', detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.` });
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
return {
provider: 'ssh-unraid',
@@ -288,27 +339,148 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
};
}
internalSlug(profile, repository) {
return String(profile.remoteFolder || repository.name || profile.composeService || 'app')
.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
}
generatedCompose(profile, repository) {
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || service;
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
const labels = [
'net.unraid.docker.managed=dockerman',
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
].filter(Boolean);
return [
'services:',
` ${service}:`,
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || 'production').toLowerCase()}`,
' build:',
' context: ..',
` container_name: ${service}`,
` container_name: ${containerName}`,
' restart: unless-stopped',
' ports:',
` - "${profile.hostPort}:${profile.containerPort}"`,
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
` - "${profile.hostPort}:${profile.containerPort}"`
].join('\n') + '\n';
}
dockerManWebUi(profile) {
if (profile.hostPort) {
let suffix = '/';
try {
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
suffix = parsed ? `${parsed.pathname || '/'}${parsed.search || ''}${parsed.hash || ''}` : '/';
} catch {}
if (!suffix.startsWith('/')) suffix = `/${suffix}`;
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
}
return profile.webUiUrl || '';
}
dockerManShell(profile) {
return String(profile.dockerShell || '/bin/sh').toLowerCase().includes('bash') ? 'bash' : 'sh';
}
dockerManTemplatePath(profile, repository) {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
}
dockerManTemplate(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const environment = String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'production';
const image = `forgeflow/${slug}:${environment}`;
const webUi = this.dockerManWebUi(profile);
return [
'<?xml version="1.0"?>',
'<Container version="2">',
` <Name>${xmlEscape(containerName)}</Name>`,
` <Repository>${xmlEscape(image)}</Repository>`,
' <Registry/>',
' <Network>bridge</Network>',
' <MyIP/>',
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
' <Privileged>false</Privileged>',
' <Support/>',
' <Project/>',
' <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>',
' <Category>Tools:</Category>',
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
' <TemplateURL/>',
` <Icon>${xmlEscape(iconReference)}</Icon>`,
' <ExtraParams/>',
' <PostArgs/>',
' <CPUset/>',
' <DonateText/>',
' <DonateLink/>',
'</Container>'
].join('\n') + '\n';
}
iconCacheRefresh(profile, repository, iconReference = '') {
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
const localIconPath = iconReferenceLocalPath(iconReference);
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
return `${cacheLoop}
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
${invalidateMetadata}`;
}
dockerManRefreshScript(profile, repository, iconReference = '') {
const templatePath = this.dockerManTemplatePath(profile, repository);
const template = this.dockerManTemplate(profile, repository, iconReference);
return `mkdir -p /boot/config/plugins/dockerMan/templates-user
cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE'
${template}FORGEFLOW_DOCKERMAN_TEMPLATE
chmod 0644 ${shellQuote(templatePath)}
${this.iconCacheRefresh(profile, repository, iconReference)}`;
}
metadataCompose(profile, repository, iconReference = '') {
const service = String(profile.composeService || repository.name || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || service).replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const slug = this.internalSlug(profile, repository);
const labels = {
'net.unraid.docker.managed': 'dockerman',
'net.unraid.docker.shell': this.dockerManShell(profile)
};
const webUiLabel = this.dockerManWebUi(profile);
if (webUiLabel) labels['net.unraid.docker.webui'] = webUiLabel;
if (iconReference) labels['net.unraid.docker.icon'] = iconReference;
return [
'services:',
` ${service}:`,
` image: forgeflow/${slug}:${String(profile.environment || 'production').toLowerCase().replace(/[^a-z0-9._-]/g, '-')}`,
` container_name: ${containerName}`,
' labels:',
...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`)
].join('\n') + '\n';
}
async prepareIcon(profile, repository, server) {
const mode = profile.iconMode || (profile.iconFilePath ? 'upload' : profile.iconUrl ? 'url' : 'builtin');
if (mode === 'none') return '';
if (mode === 'url') {
if (!profile.iconUrl) throw new Error('DockerMan icon URL mode is selected, but no icon URL is configured.');
return profile.iconUrl;
}
const localIconPath = mode === 'builtin'
? nativePath.join(this.sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png')
: profile.iconFilePath;
const stat = await fs.stat(localIconPath).catch(() => null);
if (!stat?.isFile()) throw new Error(mode === 'builtin' ? 'The built-in ITWorx DockerMan icon is missing.' : `The selected DockerMan icon file no longer exists: ${localIconPath}`);
if (nativePath.extname(localIconPath).toLowerCase() !== '.png') throw new Error('DockerMan icon upload currently accepts PNG files only.');
const containerName = String(profile.containerName || profile.remoteFolder || repository.name || 'app').replace(/[^A-Za-z0-9._-]/g, '-') || 'app';
const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`;
await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { mode: 0o644 });
return `file://${remoteIconPath}`;
}
composeInvocation(profile, repository, composeFile) {
const slug = this.internalSlug(profile, repository);
return `docker compose -p ${shellQuote(slug)} -f ${shellQuote(composeFile)} -f '.forgeflow/compose.metadata.yml'`;
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
let last = null;
@@ -336,7 +508,7 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
throw error;
}
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
const operation = await this.saveOperation({
id: requestId,
type: 'deployment',
action: 'deploy',
@@ -349,13 +521,15 @@ printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
sha: targetSha,
shortSha: targetSha.slice(0, 7),
status: 'running',
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
logs: ['Preflight passed.', 'Unraid can read the Gitea repository.', `Deploying exact commit ${targetSha} in the background.`]
});
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const branch = String(profile.branch || 'main');
const statusJson = JSON.stringify({
repository: repository.fullName,
@@ -377,7 +551,7 @@ fi
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
changes=$(git -C "$root" status --porcelain --untracked-files=no)
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; }
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; }
git -C "$root" fetch --prune origin ${shellQuote(branch)}
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
@@ -388,71 +562,103 @@ mkdir -p "$root/.forgeflow"
printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
container=${shellQuote(String(profile.containerName || profile.remoteFolder || repository.name))}
docker inspect "$container" >/dev/null
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
`;
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'success';
const finalLogs = [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
];
const completed = await this.store.addOperation({
...operation,
status: finalStatus,
previousSha: preflight.inspection?.head || null,
health,
logs: finalLogs,
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: preflight.inspection?.head || null,
healthy: health.healthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
requestId,
remotePath,
provider: 'ssh-unraid'
});
await this.diagnostics?.info('unraid.deployment.completed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
healthy: health.healthy,
healthStatus: health.status
});
if (health.healthy === false) {
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
void (async () => {
try {
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
// The remote deployment script already verifies that Docker created the expected
// container. Complete the operation before a secondary state inspection so a slow or
// failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run.
const effectiveHealthy = health.configured ? health.healthy : true;
const finalStatus = effectiveHealthy === false ? 'failed' : 'success';
const finalLogs = [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Docker Compose deployment completed.',
health.configured
? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.`
: 'No desktop healthcheck URL configured; the remote container inspection passed.'
];
await this.saveOperation({
...operation,
status: finalStatus,
previousSha: preflight.inspection?.head || null,
health: { ...health, healthy: effectiveHealthy },
logs: finalLogs,
error: effectiveHealthy === false ? 'The application healthcheck did not pass after deployment.' : null
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: preflight.inspection?.head || null,
healthy: effectiveHealthy,
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: 'ssh-unraid',
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
containerRunning: true,
dockerMan: {
webUi: this.dockerManWebUi(profile),
icon: iconReference,
shell: this.dockerManShell(profile),
templateExists: true,
configured: Boolean(this.dockerManWebUi(profile) || iconReference)
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null)
});
// Reconcile authoritative Unraid/Docker state in the background and preserve the already
// completed operation if that follow-up inspection is unavailable.
void this.refreshProfileState(repository.fullName, profileId).catch(async (refreshError) => {
await this.diagnostics?.warning('unraid.deployment.post-refresh-failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
error: refreshError
});
});
await this.diagnostics?.info('unraid.deployment.completed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
healthy: effectiveHealthy,
healthStatus: health.status ?? null
});
} catch (error) {
await this.saveOperation({
...operation,
status: 'failed',
error: error.message,
failure: { stage: 'SSH / Docker deployment', message: error.message },
logs: [...operation.logs, error.message]
});
await this.diagnostics?.error('unraid.deployment.failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
error
});
}
return completed;
} catch (error) {
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
await this.diagnostics?.error('unraid.deployment.failed', {
requestId,
repository: repository.fullName,
serverId: server.id,
remotePath,
sha: targetSha,
error
});
throw error;
}
})();
return operation;
}
async rollback({ repository, profileId, targetSha }) {
@@ -470,8 +676,11 @@ FORGEFLOW_STATUS
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
const operation = await this.saveOperation({
id: requestId,
type: 'deployment',
action: 'rollback',
@@ -504,9 +713,12 @@ git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
current=$(git -C "$root" rev-parse HEAD)
git -C "$root" reset --hard ${shellQuote(target)}
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
@@ -517,7 +729,7 @@ FORGEFLOW_STATUS
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
const completed = await this.store.addOperation({
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || inspection.head || null,
@@ -549,7 +761,7 @@ FORGEFLOW_STATUS
return completed;
} catch (error) {
if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') {
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
await this.saveOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
}
throw error;
}
@@ -558,30 +770,131 @@ FORGEFLOW_STATUS
async refreshProfileState(fullName, profileId) {
const repository = { fullName, name: fullName.split('/').pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId);
const containerName = String(profile.containerName || profile.remoteFolder || repository.name);
const script = `
root=${shellQuote(remotePath)}
live=""; previous=""; status=""
container=${shellQuote(containerName)}
template_path=${shellQuote('/boot/config/plugins/dockerMan/templates-user/my-' + containerName + '.xml')}
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
[ -f "$template_path" ] && template_exists=true
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
if docker inspect "$container" >/dev/null 2>&1; then
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
fi
printf '__FORGEFLOW_KV__\n'
printf 'liveSha=%s\n' "$live"
printf 'previousSha=%s\n' "$previous"
printf 'containerRunning=%s\n' "$running"
printf 'dockerHealth=%s\n' "$docker_health"
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
printf 'templateExists=%s\n' "$template_exists"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
const raw = parseInspection(result.stdout);
let remoteStatus = null;
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
const existing = this.store.getDeploymentState(profile.id) || {};
const marker = result.stdout.lastIndexOf('__FORGEFLOW_KV__');
if (marker < 0) throw new Error('Unraid state inspection did not return a ForgeFlow marker.');
const fields = {};
for (const line of result.stdout.slice(marker + '__FORGEFLOW_KV__'.length).trim().split(/\r?\n/)) {
const index = line.indexOf('=');
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
}
const decode = (value) => { try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; } catch { return ''; } };
const health = await this.checkHealth(profile.healthcheckUrl);
const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === 'healthy' : null;
const effectiveHealthy = health.configured ? health.healthy : (dockerHealthy ?? (fields.containerRunning === 'true' ? true : false));
return this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
healthStatus: existing.healthStatus ?? null,
healthLatencyMs: existing.healthLatencyMs ?? null,
requestId: remoteStatus?.request_id || existing.requestId || null,
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || '') ? fields.liveSha : null,
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || '') ? fields.previousSha : null,
healthy: effectiveHealthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
containerName,
containerRunning: fields.containerRunning === 'true',
dockerHealth: fields.dockerHealth || null,
dockerMan: {
webUi: decode(fields.webUiLabel),
icon: decode(fields.iconLabel),
shell: decode(fields.shellLabel),
templateExists: fields.templateExists === 'true',
configured: Boolean(decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === 'true')
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
remotePath,
provider: 'ssh-unraid'
});
}
async applyDockerManMetadata({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository, composeFile);
const script = `
root=${shellQuote(remotePath)}
test -d "$root/.git"
mkdir -p "$root/.forgeflow"
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
cd "$root"
${compose} config >/dev/null
${compose} up -d --build --remove-orphans --force-recreate
${this.dockerManRefreshScript(profile, repository, iconReference)}
`;
await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024 });
return this.refreshProfileState(repository.fullName, profileId);
}
async refreshOperation(operationId) {
const operation = this.store.getOperation(operationId);
if (!operation || operation.provider !== 'ssh-unraid') return operation;
if (['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation;
try {
const state = await this.refreshProfileState(operation.repository, operation.profileId);
if (state.liveSha === operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: operation.action === 'rollback' ? 'rolled-back' : 'success',
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), 'Deployment state reconciled from Unraid.']
});
}
if (/^[0-9a-f]{40}$/i.test(String(state.liveSha || '')) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false) {
return this.saveOperation({
...operation,
status: 'cancelled',
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
health: { healthy: state.healthy, status: state.healthStatus },
logs: [...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`]
});
}
const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime();
if (ageMs > 45 * 60_000) {
return this.saveOperation({
...operation,
status: 'failed',
error: 'Deployment was interrupted or did not reach the requested commit within 45 minutes.',
logs: [...(operation.logs || []), 'Stale deployment was marked failed during reconciliation.']
});
}
return operation;
} catch {
return operation;
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.provider === 'ssh-unraid' && item.type === 'deployment' && !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status));
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
}
}
module.exports = {
@@ -591,5 +904,7 @@ module.exports = {
parseInspection,
dockerIgnoreHasPath,
checksSummary,
xmlEscape,
iconReferenceLocalPath,
bash
};