This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+595
View File
@@ -0,0 +1,595 @@
'use strict';
const fs = require('node:fs/promises');
const path = require('node:path').posix;
const nativePath = require('node:path');
const crypto = require('node:crypto');
const { shellQuote } = require('./ssh-service.cjs');
const { assertFullCommitSha } = require('../shared/validation.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
function safeRemoteFolder(value) {
const text = String(value || '').trim();
if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === '.' || text === '..') throw new Error('Remote folder contains unsupported characters.');
return text;
}
function safeRelativeRemoteFile(value, fallback = '') {
const text = String(value || fallback).trim().replace(/\\/g, '/');
if (!text || text.startsWith('/') || text.split('/').some((part) => !part || part === '.' || part === '..')) {
throw new Error('Remote file path must remain inside the project folder.');
}
return text;
}
function bash(command) {
const script = `set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${command}`;
const payload = Buffer.from(script, 'utf8').toString('base64');
return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`;
}
function parseInspection(text) {
const jsonMarker = '__FORGEFLOW_JSON__';
const jsonIndex = text.lastIndexOf(jsonMarker);
if (jsonIndex >= 0) return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim());
const kvMarker = '__FORGEFLOW_KV__';
const kvIndex = text.lastIndexOf(kvMarker);
if (kvIndex < 0) throw new Error('The server inspection did not return a ForgeFlow result.');
const fields = {};
for (const line of text.slice(kvIndex + kvMarker.length).trim().split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
}
const decodeLines = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : []; }
catch { return []; }
};
const decodeText = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
catch { return ''; }
};
return {
exists: fields.exists === 'true',
rootGit: fields.rootGit === 'true',
head: fields.head || null,
branch: fields.branch || null,
remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : null,
trackedChanges: decodeLines(fields.trackedChanges),
composeFiles: decodeLines(fields.composeFiles),
nestedGit: decodeLines(fields.nestedGit),
dockerfile: fields.dockerfile === 'true',
dockerignoreContent: decodeText(fields.dockerignoreContent),
existingPreservePaths: decodeLines(fields.existingPreservePaths)
};
}
function dockerIgnoreHasPath(content, value) {
const target = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
if (!target) return false;
return String(content || '').split(/\r?\n/).some((line) => {
let rule = line.trim();
if (!rule || rule.startsWith('#') || rule.startsWith('!')) return false;
rule = rule.replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
return rule === target || rule === `${target}/**` || rule === `${target}/**/*`;
});
}
function checksSummary(checks) {
const counts = {
pass: checks.filter((item) => item.status === 'pass').length,
warning: checks.filter((item) => item.status === 'warning').length,
fail: checks.filter((item) => item.status === 'fail').length
};
return {
ready: counts.fail === 0,
counts,
blocking: checks.filter((item) => item.status === 'fail').map((item) => item.id)
};
}
class UnraidDeploymentService {
constructor({ store, ssh, git, diagnostics }) {
this.store = store;
this.ssh = ssh;
this.git = git;
this.diagnostics = diagnostics;
}
resolve(repository, profileId) {
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile || profile.provider !== 'ssh-unraid') throw new Error('The SSH / Unraid deployment profile no longer exists.');
const server = this.store.getServer(profile.serverId);
if (!server) throw new Error('The deployment server no longer exists.');
const remoteFolder = safeRemoteFolder(profile.remoteFolder || repository.name);
const remotePath = path.join(server.basePath, remoteFolder);
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
return { profile, server, remoteFolder, remotePath };
}
async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`
).join('\n');
const script = `
root=${shellQuote(remotePath)}
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
if [ -d "$root" ]; then
exists=true
if [ -d "$root/.git" ]; then
root_git=true
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
fi
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
[ -f "$root/Dockerfile" ] && dockerfile=true
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
existing_preserve_paths=$({ ${preserveProbe || ':'}; } | sort -u | base64 | tr -d '\\r\\n' || true)
fi
printf '__FORGEFLOW_KV__\\n'
printf 'exists=%s\\n' "$exists"
printf 'rootGit=%s\\n' "$root_git"
printf 'head=%s\\n' "$head"
printf 'branch=%s\\n' "$branch"
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
printf 'trackedChanges=%s\\n' "$tracked_changes"
printf 'composeFiles=%s\\n' "$compose_files"
printf 'nestedGit=%s\\n' "$nested_git"
printf 'dockerfile=%s\\n' "$dockerfile"
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
`;
const wrapped = bash(script);
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
const parsed = parseInspection(result.stdout);
const contextCandidates = [...new Set([...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || [])])];
const inspection = {
...parsed,
dockerignore: Boolean(parsed.dockerignoreContent),
dockerignoreGitExcluded: dockerIgnoreHasPath(parsed.dockerignoreContent, '.git'),
dockerContextExclusionsMissing: parsed.dockerfile
? contextCandidates.filter((item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item))
: [],
serverId: server.id,
serverName: server.name,
remotePath,
profileId: profile.id
};
await this.diagnostics?.info('unraid.inspected', {
repository: repository.fullName,
serverId: server.id,
remotePath,
exists: inspection.exists,
rootGit: inspection.rootGit,
head: inspection.head,
composeFiles: inspection.composeFiles,
nestedGitCount: inspection.nestedGit.length,
trackedChangeCount: inspection.trackedChanges.length,
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing
});
return inspection;
}
async preflight({ repository, profileId, sha = null }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const targetSha = assertFullCommitSha(sha || repository.localStatus?.head);
const checks = [];
let inspection = null;
if (!repository.localPath) {
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: 'Link or clone the repository locally before deploying.' });
} else {
try {
const localStatus = await this.git.status(repository.localPath);
checks.push({ id: 'local-repository', label: 'Local repository', status: 'pass', detail: localStatus.root });
checks.push({ id: 'local-branch', label: 'Allowed branch', status: localStatus.branch.head === profile.branch ? 'pass' : 'fail', detail: `Current: ${localStatus.branch.head || 'detached'}; required: ${profile.branch}.` });
checks.push({ id: 'local-clean', label: 'Clean local working tree', status: localStatus.clean ? 'pass' : 'fail', detail: localStatus.clean ? 'No uncommitted changes.' : `${localStatus.counts.changed} changed file(s) remain.` });
checks.push({ id: 'local-upstream', label: 'Published upstream', status: localStatus.branch.upstream ? 'pass' : 'fail', detail: localStatus.branch.upstream || 'No upstream branch is configured.' });
checks.push({ id: 'local-sync', label: 'Local and Gitea synchronized', status: !localStatus.branch.ahead && !localStatus.branch.behind ? 'pass' : 'fail', detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.` });
checks.push({ id: 'local-target-sha', label: 'Selected deployment commit', status: localStatus.head === targetSha ? 'pass' : 'fail', detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || 'unknown'}, but deployment requested ${targetSha}.` });
try {
await this.git.verifyCommitOnRemoteBranch(repository.localPath, targetSha, profile.branch);
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'pass', detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.` });
} catch (error) {
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'fail', detail: error.message });
}
const localDeploymentFile = profile.generatedCompose
? nativePath.join(repository.localPath, 'Dockerfile')
: nativePath.join(repository.localPath, safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'));
const localDeploymentFileExists = Boolean((await fs.stat(localDeploymentFile).catch(() => null))?.isFile());
checks.push({
id: 'local-deployment-file',
label: profile.generatedCompose ? 'Dockerfile in repository' : 'Compose file in repository',
status: localDeploymentFileExists ? 'pass' : 'fail',
detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.`
});
} catch (error) {
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: error.message });
}
}
try {
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
checks.push({ id: 'ssh', label: 'SSH connection', status: 'pass', detail: `${server.username}@${server.host}:${server.port}` });
if (!/docker compose|docker-compose/i.test(connection.output)) {
checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'fail', detail: 'Docker Compose was not detected on the server.' });
} else checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'pass', detail: 'Docker Compose is available.' });
} catch (error) {
checks.push({ id: 'ssh', label: 'SSH connection', status: 'fail', detail: error.message });
}
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 });
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'pass', detail: `${remotePath} will be created.` });
} else if (!inspection.rootGit) {
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'fail', detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.` });
} else {
checks.push({ id: 'remote-folder', label: 'Remote Git working tree', status: 'pass', detail: `${remotePath} at ${String(inspection.head || '').slice(0, 7) || 'unknown'}.` });
}
if (inspection.trackedChanges.length) {
checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'fail', detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.` });
} else if (inspection.rootGit) checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'pass', detail: 'No tracked server-only edits detected.' });
if (inspection.rootGit && profile.cloneUrl && inspection.remote) {
const expectedRemote = normalizeRemoteUrl(profile.cloneUrl);
const currentRemote = normalizeRemoteUrl(inspection.remote);
const matches = Boolean(expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path);
if (!matches && profile.alignRemote) {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'warning', detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.` });
} else if (!matches) {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'fail', detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.` });
} else {
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'pass', detail: inspection.remote });
}
}
if (inspection.nestedGit.length) {
checks.push({ id: 'nested-git', label: 'Nested Git repositories', status: 'warning', detail: `Detected: ${inspection.nestedGit.join(', ')}. ForgeFlow will not delete them automatically.` });
}
if (inspection.dockerfile && !inspection.dockerignore) {
checks.push({ id: 'dockerignore', label: 'Docker build context', status: 'warning', detail: 'A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.' });
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'warning', detail: '.dockerignore does not explicitly exclude .git.' });
} else if (inspection.dockerfile) {
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'pass', detail: '.git is excluded from the Docker build context.' });
}
if (inspection.dockerContextExclusionsMissing.length) {
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'warning', detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(', ')}.` });
} else if (inspection.dockerfile && inspection.existingPreservePaths.length) {
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'pass', detail: 'Detected preserved runtime paths are excluded from the Docker build context.' });
}
const composeFile = safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
if (inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose) {
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'fail', detail: `${composeFile} was not found. Select an existing file or enable generated Compose.` });
} else {
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'pass', detail: profile.generatedCompose ? 'ForgeFlow will generate an isolated Compose file.' : composeFile });
}
} catch (error) {
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
}
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
return {
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
sha: targetSha,
server: { id: server.id, name: server.name, host: server.host },
remotePath,
inspection,
checks,
summary: checksSummary(checks)
};
}
generatedCompose(profile, repository) {
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
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}:`,
' build:',
' context: ..',
` container_name: ${service}`,
' restart: unless-stopped',
' ports:',
` - "${profile.hostPort}:${profile.containerPort}"`,
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
].join('\n') + '\n';
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
let last = null;
for (let attempt = 1; attempt <= 5; attempt += 1) {
const started = Date.now();
try {
const response = await fetch(url, { signal: AbortSignal.timeout(8_000), redirect: 'manual' });
last = { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
if (response.ok) return last;
} catch (error) {
last = { configured: true, healthy: false, status: null, latencyMs: Date.now() - started, error: error.message };
}
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3_000));
}
return last;
}
async deploy({ repository, profileId, sha }) {
const targetSha = assertFullCommitSha(sha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
if (!preflight.summary.ready) {
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(', ')}`);
error.code = 'SSH_DEPLOYMENT_PREFLIGHT_FAILED';
throw error;
}
const requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
id: requestId,
type: 'deployment',
action: 'deploy',
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: targetSha,
shortSha: targetSha.slice(0, 7),
status: 'running',
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
});
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 branch = String(profile.branch || 'main');
const statusJson = JSON.stringify({
repository: repository.fullName,
environment: profile.environment,
requested_sha: targetSha,
live_sha: targetSha,
request_id: requestId,
healthy: null,
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
deployed_at: new Date().toISOString()
});
const script = `
root=${shellQuote(remotePath)}
parent=$(dirname "$root")
mkdir -p "$parent"
if [ ! -d "$root" ]; then
git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root"
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; }
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}`)}
previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)}
git -C "$root" reset --hard ${shellQuote(targetSha)}
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` : ''}
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
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;
}
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;
}
}
async rollback({ repository, profileId, targetSha }) {
const target = assertFullCommitSha(targetSha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
const deploymentState = this.store.getDeploymentState(profileId);
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
const error = new Error('Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.');
error.code = 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA';
throw error;
}
if (!repository.localPath) throw new Error('A linked local repository is required for rollback verification.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch);
const inspection = await this.inspect({ repository, profileId });
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 requestId = crypto.randomUUID();
const operation = await this.store.addOperation({
id: requestId,
type: 'deployment',
action: 'rollback',
provider: 'ssh-unraid',
repository: repository.fullName,
environment: profile.environment,
profileId,
serverId: server.id,
remotePath,
sha: target,
shortSha: target.slice(0, 7),
status: 'running',
logs: [`Rolling back to exact commit ${target}.`]
});
const statusJson = JSON.stringify({
repository: repository.fullName,
environment: profile.environment,
requested_sha: target,
live_sha: target,
request_id: requestId,
healthy: null,
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
rollback: true,
deployed_at: new Date().toISOString()
});
const script = `
root=${shellQuote(remotePath)}
test -d "$root/.git"
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)}
cd "$root"
docker compose -f ${shellQuote(composeFile)} config >/dev/null
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
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' : 'rolled-back';
const completed = await this.store.addOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || inspection.head || null,
health,
error: health.healthy === false ? 'The application healthcheck did not pass after rollback.' : null,
logs: [
...operation.logs,
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
'Rollback completed.',
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
]
});
await this.store.saveDeploymentState(profileId, {
liveSha: target,
previousSha: deploymentState.liveSha || inspection.head || null,
healthy: health.healthy,
healthStatus: health.status,
healthLatencyMs: health.latencyMs,
requestId,
remotePath,
provider: 'ssh-unraid'
});
if (health.healthy === false) {
const error = new Error('Rollback completed, but the configured healthcheck failed.');
error.code = 'ROLLBACK_HEALTHCHECK_FAILED';
error.operationId = completed.id;
throw error;
}
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] });
}
throw error;
}
}
async refreshProfileState(fullName, profileId) {
const repository = { fullName, name: fullName.split('/').pop() };
const { profile, server, remotePath } = this.resolve(repository, profileId);
const script = `
root=${shellQuote(remotePath)}
live=""; previous=""; status=""
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
[ -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"
`;
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) || {};
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,
remotePath,
provider: 'ssh-unraid'
});
}
}
module.exports = {
UnraidDeploymentService,
safeRemoteFolder,
safeRelativeRemoteFile,
parseInspection,
dockerIgnoreHasPath,
checksSummary,
bash
};