refactor: split renderer ipc and unraid domains

This commit is contained in:
NuklearRabbit
2026-07-29 18:11:07 +02:00
parent 5d3731a853
commit 7b05c953b6
36 changed files with 9316 additions and 8330 deletions
+576
View File
@@ -0,0 +1,576 @@
"use strict";
function createUnraidDeploymentMethods({
path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs,
}) {
class UnraidDeploymentMethods {
pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) {
const compose = this.composeInvocation(profile, repository);
const project = String(
profile.composeProject || this.internalSlug(profile, repository),
).trim();
const candidateFiles = [...this.deploymentComposeFiles(profile)];
if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml");
const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles
.map((file) => `-f "$release"/${shellQuote(file)}`)
.join(" ")}`;
const preservePayload = Buffer.from(
[".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"),
"utf8",
).toString("base64");
const statusJson = this.deploymentStatusDocument({
repository,
profile,
targetSha,
requestId,
rollback,
});
const verification = this.containerVerificationScript(
profile,
repository,
compose,
{ requireRecreated: true },
);
const containerHint = String(
profile.containerName || profile.remoteFolder || repository.name || "",
).trim();
return `
root=${shellQuote(remotePath)}
expected_project=${shellQuote(project)}
tracked_container_hint=${shellQuote(containerHint)}
target=${shellQuote(targetSha)}
request_id=${shellQuote(requestId)}
incoming=${shellQuote(remotePart)}
expected_digest=${shellQuote(digest)}
release_root="$root/.forgeflow/releases/$target"
release="$release_root/source"
staging="$root/.forgeflow/staging/$request_id"
backup="$root/.forgeflow/backups/$request_id"
lock="$root/.forgeflow/deploy.lock"
mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then
lock_pid=$(cat "$lock/pid" 2>/dev/null || true)
if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi
fi
mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; }
printf '%s\n' "$request_id" > "$lock/request-id"
printf '%s\n' "$$" > "$lock/pid"
date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at"
restore_needed=false
activation_started=false
is_preserved() {
rel="$1"
while IFS= read -r keep; do
[ -n "$keep" ] || continue
if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi
done < "$staging.preserve"
return 1
}
restore_files() {
if [ -f "$backup/present" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
mkdir -p -- "$root/$(dirname "$rel")"
temp="$root/$rel.forgeflow-restore-$request_id"
cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel"
done < "$backup/present"
fi
if [ -f "$backup/absent" ]; then
while IFS= read -r rel; do
[ -n "$rel" ] || continue
case "$rel" in .forgeflow/*) continue ;; esac
[ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue
rm -f -- "$root/$rel"
done < "$backup/absent"
fi
if [ -f "$backup/generated.present" ]; then
cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml"
elif [ -f "$backup/generated.created" ]; then
rm -f "$root/.forgeflow/compose.forgeflow.yml"
fi
if [ -f "$backup/metadata.present" ]; then
cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml"
elif [ -f "$backup/metadata.created" ]; then
rm -f "$root/.forgeflow/compose.metadata.yml"
fi
}
restore_images() {
[ -f "$backup/containers.before" ] || return 0
while IFS=$'\t' read -r service container_id image_id image_ref_b64; do
[ -n "$image_id" ] || continue
docker image inspect "$image_id" >/dev/null 2>&1 || continue
image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true)
case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac
docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true
done < "$backup/containers.before"
}
restore_runtime() {
[ "$activation_started" = true ] || return 0
restore_images
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
${compose} up -d --no-build >/dev/null 2>&1 || return 1
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
printf '%s\n' "$old_services" | while IFS= read -r service; do
[ -n "$service" ] || continue
old_id=$(${compose} ps -q "$service" | head -n1)
[ -n "$old_id" ] || exit 1
[ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1
done
fi
}
finish() {
status=$?
trap - EXIT
set +e
if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then
restore_files
if ! restore_runtime; then
echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2
else
echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2
fi
fi
rm -rf "$staging" "$lock"
exit "$status"
}
trap finish EXIT
actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; }
tar -tf "$incoming" > "$staging.entries"
if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi
rm -rf "$staging" "$release_root.pending"
mkdir -p "$staging/source" "$release_root.pending"
tar -xf "$incoming" -C "$staging/source"
if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi
mv "$staging/source" "$release_root.pending/source"
find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files"
rm -rf "$release_root"
mv "$release_root.pending" "$release_root"
rm -f "$incoming" "$staging.entries"
printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve"
for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then
mkdir -p "$release/$(dirname "$runtime_config")"
cp -a "$root/$runtime_config" "$release/$runtime_config"
fi
done
${profile.generatedCompose ? `mkdir -p "$release/.forgeflow"
cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'
${generated}FORGEFLOW_COMPOSE
cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA` : ""}
cd "$release"
${candidateCompose} config >/dev/null
candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d')
[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; }
mkdir -p "$backup/source"
: > "$backup/present"
: > "$backup/absent"
: > "$backup/containers.before"
had_existing_compose=false
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
had_existing_compose=true
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
printf '%s\n' "$old_services" | while IFS= read -r service; do
[ -n "$service" ] || continue
container_id=$(${compose} ps -q "$service" | head -n1)
image_id=''; image_ref=''
if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true)
fi
printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')"
done >> "$backup/containers.before"
fi
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true)
if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then
echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2
exit 67
fi
fi
# Build all candidate images before any running container is touched.
cd "$release"
${candidateCompose} build
new_manifest="$release_root/managed-files"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
parent=$(dirname "$rel")
current="$root"
if [ "$parent" != . ]; then
old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs
for part in "\${parts[@]}"; do
current="$current/$part"
[ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; }
done
fi
[ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; }
if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi
if [ -e "$root/$rel" ]; then
mkdir -p "$backup/source/$(dirname "$rel")"
cp -a -- "$root/$rel" "$backup/source/$rel"
printf '%s\n' "$rel" >> "$backup/present"
else
printf '%s\n' "$rel" >> "$backup/absent"
fi
done < "$new_manifest"
[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; }
[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; }
restore_needed=true
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
mkdir -p -- "$root/$(dirname "$rel")"
temp="$root/$rel.forgeflow-new-$request_id"
cp -a -- "$release/$rel" "$temp"
mv -f -- "$temp" "$root/$rel"
done < "$new_manifest"
mkdir -p "$root/.forgeflow"
${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi
if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi
cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE'
${generated}FORGEFLOW_COMPOSE
mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml"
cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA'
${metadata}FORGEFLOW_METADATA
mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON'
${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })}
FORGEFLOW_METADATA_JSON
mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`}
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true
chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true
chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true
while IFS= read -r rel; do
[ -n "$rel" ] || continue
is_preserved "$rel" && continue
chgrp "$share_group" "$root/$rel" 2>/dev/null || true
chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true
parent="$root/$(dirname "$rel")"
chgrp "$share_group" "$parent" 2>/dev/null || true
chmod g+rwx,g+s "$parent" 2>/dev/null || true
done < "$new_manifest"
cd "$root"
${compose} config >/dev/null
actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then
echo "Refusing activation because candidate and server Compose service sets differ" >&2
exit 68
fi
before_containers="$backup/containers.before"
hint_before_id=''
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true)
fi
activation_started=true
${compose} up -d --no-build
${verification}
if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then
old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false)
[ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; }
fi
${this.dockerManRefreshScript(profile, repository, iconReference)}
previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true)
[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
cp "$new_manifest" "$root/.forgeflow/managed-files.pending"
mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files"
printf '%s' "$target" > "$root/.forgeflow/current-sha.pending"
mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha"
cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS'
${statusJson}
FORGEFLOW_STATUS
mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json"
restore_needed=false
printf '%s\n' "successful" > "$backup/result"
date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at"
echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup"
`;
}
async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
const permissionReport = await this.inspectWriteAccess({
repository,
profileId: profile.id,
});
if (!permissionReport.ready) {
const error = new Error(
`Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`,
);
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
error.permissionReport = permissionReport;
throw error;
}
const bundle = await this.createCommitBundle(repository, targetSha, requestId);
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
try {
await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 });
await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 });
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback });
return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
} finally {
await fs.rm(bundle.archivePath, { force: true }).catch(() => {});
}
}
async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) {
const remote = this.serverGitRemote(repository, profile);
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`);
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
const marker = "__FORGEFLOW_SERVER_ARCHIVE__";
const script = `
cache=${shellQuote(cache)}
incoming=${shellQuote(remotePart)}
remote=${shellQuote(remote)}
branch=${shellQuote(profile.branch)}
target=${shellQuote(targetSha)}
mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")"
if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi
if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then
git --git-dir="$cache" remote set-url origin "$remote"
else
git --git-dir="$cache" remote add origin "$remote"
fi
${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch"
git --git-dir="$cache" cat-file -e "$target^{commit}"
git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch"
archive_tmp="$incoming.$$.tmp"
git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target"
[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; }
mv "$archive_tmp" "$incoming"
digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
printf '%s\n' ${shellQuote(marker)}
printf 'digest=%s\n' "$digest"
`;
const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 });
const output = String(result.stdout || "");
const markerIndex = output.lastIndexOf(marker);
const digest = markerIndex >= 0
? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase()
: "";
if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive.");
return { remotePart, digest };
}
async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id });
if (!permissionReport.ready) {
const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`);
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
error.permissionReport = permissionReport;
throw error;
}
const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId });
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback });
return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
}
async deploy({ repository, profileId, sha }) {
const targetSha = assertFullCommitSha(sha);
const { profile, server, remotePath } = this.resolve(repository, profileId);
if (profile.deploymentMode === "server-git") {
const verification = await this.verifyServerGitProfile({ repository, profileId });
const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"];
const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass");
if (blocked.length || !verification.branchSha) {
const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`);
error.code = "SERVER_GIT_VERIFICATION_FAILED";
error.verification = verification;
throw error;
}
}
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 mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
const operation = await this.saveOperation({
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: [
"Preflight passed.",
mode === "push-bundle"
? "Creating and uploading the exact committed local project directly to Unraid."
: mode === "server-git"
? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key."
: "This workload is monitor-only and cannot be deployed.",
`Deploying exact commit ${targetSha} in the background.`,
],
});
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: targetSha,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
});
const previousState = this.store.getDeploymentState?.(profileId) || null;
void (async () => {
try {
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying.");
const result = mode === "server-git"
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference })
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? "failed" : "success";
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
health,
logs: [
...operation.logs,
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
"Docker Compose activation and runtime verification completed.",
health.configured
? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.`
: "No desktop healthcheck configured; running containers were verified and health remains unverified.",
],
error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null,
});
await this.store.saveDeploymentState(profileId, {
liveSha: targetSha,
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
healthy: health.configured ? health.healthy : null,
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: "ssh-unraid",
deploymentMode: mode,
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
containerRunning: true,
dockerMan: {
webUi: this.dockerManWebUi(profile),
icon: iconReference,
shell: this.dockerManShell(profile),
templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true,
configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured),
},
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
});
void this.refreshProfileState(repository.fullName, profileId).catch(() => {});
await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status });
} catch (error) {
await this.saveOperation({
...operation,
status: "failed",
error: error.message,
failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message },
logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."],
});
await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error });
}
})();
return operation;
}
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;
}
const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
if (rollbackMode === "push-bundle" && !repository.localPath)
throw new Error("A linked local repository is required for Direct copy rollback verification.");
const requestId = crypto.randomUUID();
const operation = await this.saveOperation({
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 generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: target,
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
});
try {
const mode = rollbackMode;
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back.");
const result = mode === "server-git"
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true })
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true });
const health = await this.checkHealth(profile.healthcheckUrl);
const finalStatus = health.healthy === false ? "failed" : "rolled-back";
const completed = await this.saveOperation({
...operation,
status: finalStatus,
previousSha: deploymentState.liveSha || 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(-80),
"Rollback activation completed.",
health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.",
],
});
await this.store.saveDeploymentState(profileId, {
liveSha: target,
previousSha: deploymentState.liveSha || null,
healthy: health.configured ? health.healthy : null,
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
healthStatus: health.status ?? null,
healthLatencyMs: health.latencyMs ?? null,
requestId,
remotePath,
provider: "ssh-unraid",
containerRunning: true,
});
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.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] });
}
throw error;
}
}
}
return UnraidDeploymentMethods.prototype;
}
module.exports = { createUnraidDeploymentMethods };