refactor: split renderer ipc and unraid domains
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user