"use strict"; function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) { class UnraidStateMethods { async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { const repository = { fullName, name: fullName.split("/").pop() }; const { profile, server, remotePath } = this.resolve(repository, profileId); const containerName = String( profile.containerName || profile.remoteFolder || repository.name, ); const script = ` root=${shellQuote(remotePath)} container=${shellQuote(containerName)} template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false [ -f "$template_path" ] && template_exists=true [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") [ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) [ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") if docker inspect "$container" >/dev/null 2>&1; then running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false) docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true) webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true) [ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true) fi printf '__FORGEFLOW_KV__\n' printf 'liveSha=%s\n' "$live" printf 'previousSha=%s\n' "$previous" printf 'containerRunning=%s\n' "$running" printf 'dockerHealth=%s\n' "$docker_health" printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')" printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" printf 'templateExists=%s\n' "$template_exists" `; const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000, }); const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); if (marker < 0) throw new Error( "Unraid state inspection did not return a ForgeFlow marker.", ); const fields = {}; for (const line of result.stdout .slice(marker + "__FORGEFLOW_KV__".length) .trim() .split(/\r?\n/)) { const index = line.indexOf("="); if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); } const decode = (value) => { try { return value ? Buffer.from(value, "base64").toString("utf8") : ""; } catch { return ""; } }; const containerRunning = fields.containerRunning === "true"; const health = containerRunning ? await this.checkHealth(profile.healthcheckUrl) : { configured: false, healthy: false, skipped: "container-stopped" }; const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === "healthy" : null; const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy; const runtimeVerification = !containerRunning ? "stopped" : health.configured ? "desktop-healthcheck" : dockerHealthy === true ? "docker-healthcheck" : dockerHealthy === false ? "docker-unhealthy" : containerRunning ? "running-unverified" : "stopped"; return this.store.saveDeploymentState(profile.id, { liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") ? fields.liveSha : null, previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") ? fields.previousSha : null, healthy: effectiveHealthy, runtimeVerification, healthStatus: health.status, healthLatencyMs: health.latencyMs, containerName, containerRunning, dockerHealth: fields.dockerHealth || null, dockerMan: { webUi: decode(fields.webUiLabel), icon: decode(fields.iconLabel), shell: decode(fields.shellLabel), templateExists: fields.templateExists === "true", configured: Boolean( decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === "true", ), }, webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), remotePath, provider: "ssh-unraid", giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) ? expectedGiteaSha : null, matchesGitea: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && fields.liveSha === expectedGiteaSha, }); } async applyDockerManMetadata({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); if (profile.generatedCompose !== true) { // Existing Compose files remain authoritative. Applying a generated // labels-only service fragment can create a phantom service when a stale // profile hint no longer matches the real Compose service keys. return this.refreshProfileState(repository.fullName, profileId); } const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository); const flags = this.composeUpFlags(profile); const script = ` root=${shellQuote(remotePath)} test -d "$root" mkdir -p "$root/.forgeflow" cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" ${compose} config >/dev/null ${compose} up -d --build ${flags} ${this.containerVerificationScript(profile, repository, compose)} ${this.dockerManRefreshScript(profile, repository, iconReference)} `; await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024, }); return this.refreshProfileState(repository.fullName, profileId); } async refreshOperation( operationId, { includeTerminal = false, state: suppliedState = null } = {}, ) { const operation = this.store.getOperation(operationId); if (!operation || operation.provider !== "ssh-unraid") return operation; if ( !includeTerminal && ["success", "failed", "cancelled", "rolled-back"].includes( operation.status, ) ) return operation; try { const state = suppliedState || (await this.refreshProfileState( operation.repository, operation.profileId, )); if ( state.liveSha === operation.sha && state.containerRunning && state.healthy !== false ) { return this.saveOperation({ ...operation, status: operation.action === "rollback" ? "rolled-back" : "success", health: { healthy: state.healthy, status: state.healthStatus }, logs: [ ...(operation.logs || []), "Deployment state reconciled from Unraid.", ], }); } if ( /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false ) { return this.saveOperation({ ...operation, status: "cancelled", error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, health: { healthy: state.healthy, status: state.healthStatus }, logs: [ ...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`, ], }); } const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime(); if (ageMs > 45 * 60_000) { return this.saveOperation({ ...operation, status: "failed", error: "Deployment was interrupted or did not reach the requested commit within 45 minutes.", logs: [ ...(operation.logs || []), "Stale deployment was marked failed during reconciliation.", ], }); } return operation; } catch { return operation; } } async reconcileRecordedOperations(profileId, state) { const operations = this.store.data.operations .filter( (item) => item.profileId === profileId && item.provider === "ssh-unraid", ) .sort( (left, right) => new Date(right.updatedAt || right.createdAt || 0) - new Date(left.updatedAt || left.createdAt || 0), ); const matching = operations.find( (item) => item.sha === state.liveSha && item.status === "failed", ); if (matching && state.containerRunning && state.healthy !== false) { await this.refreshOperation(matching.id, { includeTerminal: true, state, }); } const latestFailed = operations.find((item) => item.status === "failed"); if ( latestFailed && latestFailed.id !== matching?.id && state.matchesGitea && state.containerRunning && state.healthy !== false ) { await this.saveOperation({ ...latestFailed, status: "cancelled", error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, logs: [ ...(latestFailed.logs || []), `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, ], }); } return this.store.data.operations .filter((item) => item.profileId === profileId) .slice(0, 10); } async refreshActiveOperations() { const active = this.store.data.operations.filter( (item) => item.provider === "ssh-unraid" && item.type === "deployment" && !["success", "failed", "cancelled", "rolled-back"].includes( item.status, ), ); const queue = [...active]; const results = []; const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { while (queue.length) { const operation = queue.shift(); results.push(await this.refreshOperation(operation.id)); } }); await Promise.all(workers); return results; } } return UnraidStateMethods.prototype; } module.exports = { createUnraidStateMethods };