"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 };