refactor: split renderer ipc and unraid domains
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"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 health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const dockerHealthy = fields.dockerHealth
|
||||
? fields.dockerHealth === "healthy"
|
||||
: null;
|
||||
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = health.configured
|
||||
? "desktop-healthcheck"
|
||||
: dockerHealthy === true
|
||||
? "docker-healthcheck"
|
||||
: dockerHealthy === false
|
||||
? "docker-unhealthy"
|
||||
: fields.containerRunning === "true"
|
||||
? "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: fields.containerRunning === "true",
|
||||
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 composeFile = profile.generatedCompose
|
||||
? ".forgeflow/compose.forgeflow.yml"
|
||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
||||
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,
|
||||
),
|
||||
);
|
||||
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
|
||||
}
|
||||
}
|
||||
return UnraidStateMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidStateMethods };
|
||||
Reference in New Issue
Block a user