Update
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user