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
+579
View File
@@ -0,0 +1,579 @@
"use strict";
function createUnraidInventoryMethods({
shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory,
inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer,
safeRemoteFolder, bash,
}) {
class UnraidInventoryMethods {
inventoryScript(server) {
const configuredRoots = [...new Set([server.basePath, ...(server.scanRoots || [])])].map((root) => ` add_scan_root ${shellQuote(root)}`).join("\n");
const configuredExcludes = (server.scanExcludes || []).map((name) => ` -o -name ${shellQuote(name)}`).join("");
return `
base=${shellQuote(server.basePath)}
platform=$(uname -srm 2>/dev/null || true)
docker_ok=false; compose_ok=false; compose_v2=false; git_ok=false; tar_ok=false; checksum_ok=false; base_writable=false; compose_version=''
command -v docker >/dev/null 2>&1 && docker_ok=true
if [ "$docker_ok" = true ]; then
if docker compose version >/dev/null 2>&1; then compose_ok=true; compose_v2=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose_ok=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
fi
command -v git >/dev/null 2>&1 && git_ok=true
command -v tar >/dev/null 2>&1 && tar_ok=true
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum_ok=true
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
printf '__FORGEFLOW_INVENTORY__\\n'
printf 'H\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$docker_ok" "$compose_ok" "$git_ok" "$tar_ok" "$checksum_ok" "$base_writable" "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
ids=''
if [ "$docker_ok" != true ]; then
printf 'W\\t%s\\n' "$(printf '%s' 'Docker is not installed or not in PATH. Compose files and DockerMan templates will still be scanned.' | base64 | tr -d '\\r\\n')"
else
if ! ids=$(docker ps -aq --no-trunc 2>&1); then
printf 'W\\t%s\\n' "$(printf '%s' "Docker inventory failed: $ids. Compose files and DockerMan templates will still be scanned." | head -c 2000 | base64 | tr -d '\\r\\n')"
ids=''
fi
fi
if [ -n "$ids" ]; then
disappeared=0
while IFS= read -r container_id; do
[ -n "$container_id" ] || continue
if inspect=$(docker inspect --format '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"running":{{json .State.Running}},"status":{{json .State.Status}},"health":{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}},"labels":{{json .Config.Labels}},"ports":{{json .NetworkSettings.Ports}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}},"restartPolicy":{{json .HostConfig.RestartPolicy.Name}}}' "$container_id" 2>/dev/null); then
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
else
disappeared=$((disappeared + 1))
fi
done <<< "$ids"
if [ "$disappeared" -gt 0 ]; then
printf 'W\\t%s\\n' "$(printf '%s' "$disappeared stale container reference(s) disappeared during inventory; current containers were still processed." | base64 | tr -d '\\r\\n')"
fi
fi
templates_dir=/boot/config/plugins/dockerMan/templates-user
if [ -d "$templates_dir" ]; then
find "$templates_dir" -maxdepth 1 -type f -name '*.xml' -print0 2>/dev/null | while IFS= read -r -d '' template; do
read_tag() { sed -n "s#.*<$1>\\(.*\\)</$1>.*#\\1#p" "$template" | head -n1; }
name=$(read_tag Name)
[ -n "$name" ] || continue
printf 'D\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
"$(printf '%s' "$name" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$template" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag WebUI)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Icon)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Shell)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Repository)" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$(read_tag Network)" | base64 | tr -d '\\r\\n')"
done
fi
if [ "$compose_ok" = true ]; then
compose_projects=$(docker compose ls --all --format json 2>/dev/null || docker-compose ls --all --format json 2>/dev/null || true)
if [ -n "$compose_projects" ]; then
printf 'P\\t%s\\n' "$(printf '%s' "$compose_projects" | base64 | tr -d '\\r\\n')"
fi
fi
scan_roots=()
add_scan_root() {
candidate=$1
[ -d "$candidate" ] || return 0
for existing in "\${scan_roots[@]}"; do [ "$existing" = "$candidate" ] && return 0; done
scan_roots+=("$candidate")
}
${configuredRoots}
for root in "\${scan_roots[@]}"; do
scan_error=$(mktemp)
while IFS= read -r -d '' primary; do
dir=$(dirname "$primary")
filename=$(basename "$primary")
case "$filename" in
compose.override.yml|compose.override.yaml|docker-compose.override.yml|docker-compose.override.yaml) continue ;;
compose.yml) ;;
compose.yaml) [ -f "$dir/compose.yml" ] && continue ;;
docker-compose.yml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ]; } && continue ;;
docker-compose.yaml) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ]; } && continue ;;
*) { [ -f "$dir/compose.yml" ] || [ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && continue ;;
esac
(
set -- -f "$primary"
files_text=$primary
for extra in "$dir/compose.override.yml" "$dir/compose.override.yaml" "$dir/docker-compose.override.yml" "$dir/docker-compose.override.yaml"; do
[ -f "$extra" ] || continue
set -- "$@" -f "$extra"
files_text="$files_text
$extra"
done
project_name=$(sed -n 's/^name:[[:space:]]*//p' "$primary" 2>/dev/null | head -n1 | cut -d'#' -f1 | tr -d '"' | tr -d "'" | xargs 2>/dev/null || true)
[ -n "$project_name" ] || project_name=$(basename "$dir")
valid=false; services=''; images=''; compose_error=''
if [ "$compose_ok" != true ]; then
compose_error='Docker Compose is unavailable; file metadata was still detected.'
elif [ "$compose_v2" = true ]; then
if services=$(cd "$dir" && docker compose "$@" config --services 2>&1); then
valid=true
images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true)
else compose_error=$services; services=''; fi
else
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
valid=true
images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true)
else compose_error=$services; services=''; fi
fi
if [ -z "$services" ]; then
services=$(awk '
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
in_services && /^[^[:space:]]/ { exit }
in_services && /^ [A-Za-z0-9._-]+:[[:space:]]*($|#)/ {
line=$0; sub(/^[[:space:]]*/, "", line); sub(/:.*/, "", line); print line
}
' "$primary" 2>/dev/null || true)
fi
if [ -z "$images" ]; then
images=$(awk '
/^[[:space:]]*services:[[:space:]]*($|#)/ { in_services=1; next }
in_services && /^[^[:space:]]/ { exit }
in_services && /^[[:space:]]+image:[[:space:]]*/ {
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/, "", line); sub(/[[:space:]]+#.*/, "", line); gsub(/"/, "", line); print line
}
' "$primary" 2>/dev/null || true)
fi
printf 'Y\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \\
"$(printf '%s' "$dir" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$files_text" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$project_name" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$services" | base64 | tr -d '\\r\\n')" \\
"$(printf '%s' "$images" | base64 | tr -d '\\r\\n')" \\
"$valid" \\
"$(printf '%s' "$compose_error" | head -c 2000 | base64 | tr -d '\\r\\n')"
)
done < <(find "$root" -mindepth 2 -maxdepth 4 \\( -type d \\( -name .git -o -name node_modules -o -name .forgeflow -o -name releases -o -name backups -o -name staging -o -name incoming -o -name '_audit_quarantine' -o -name 'devrunbook-validation' -o -name 'source-pre-*' -o -name cache -o -name caches -o -name logs -o -name database -o -name databases${configuredExcludes} \\) -prune \\) -o \\( -type f \\( -name '*compose*.yml' -o -name '*compose*.yaml' -o -name 'stack.yml' -o -name 'stack.yaml' \\) -print0 \\) 2>"$scan_error" || true)
if [ -s "$scan_error" ]; then
scan_message=$(printf 'Inventory scan partially failed for %s: %s' "$root" "$(head -n 1 "$scan_error")")
printf 'W\\t%s\\n' "$(printf '%s' "$scan_message" | base64 | tr -d '\\r\\n')"
fi
rm -f "$scan_error"
done
`;
}
allSshProfiles() {
const result = [];
for (const [repositoryFullName, profiles] of Object.entries(this.store.data?.deploymentProfiles || {})) {
for (const profile of profiles || []) {
if (profile?.provider === "ssh-unraid") result.push({ ...profile, _repositoryFullName: repositoryFullName });
}
}
return result;
}
relativeComposeFiles(workload) {
const workingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
const files = (workload.compose?.configFiles || []).map((file) => {
const value = String(file || "").trim();
if (workingDir && value.startsWith(`${workingDir}/`)) return value.slice(workingDir.length + 1);
return value.startsWith("/") ? path.basename(value) : value;
}).filter(Boolean);
return [...new Set(files.length ? files : ["docker-compose.yml"])];
}
profileFromWorkload(repository, server, workload, { linkSource = "manual", deploymentMode = "server-git", remoteFolder = "" } = {}) {
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
? deploymentMode
: "server-git";
const selectedFolder = safeRemoteFolder(remoteFolder || workload.remoteFolderCandidate || repository.name);
const composeFiles = this.relativeComposeFiles(workload);
const services = [...new Set((workload.compose?.services || [])
.map((service) => String(service || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-"))
.filter(Boolean))];
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
const primaryPort = (primary.ports || []).find((item) => item.hostPort) || primary.ports?.[0] || {};
const remotePath = path.join(server.basePath, selectedFolder);
const preservePaths = new Set([".env", "appdata", "data", "logs", "config", "compose.override.yml"]);
for (const container of workload.containers || []) {
for (const mount of container.mounts || []) {
const source = String(mount.source || "");
if (!source.startsWith(`${remotePath}/`)) continue;
const relative = source.slice(remotePath.length + 1).split("/")[0];
if (relative) preservePaths.add(relative);
}
}
const idPrefix = String(linkSource).startsWith("automatic") ? "auto" : "link";
const profileId = `${idPrefix}-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${workload.workloadId}`).digest("hex").slice(0, 20)}`;
return {
id: profileId,
name: `${server.name} · ${workload.displayName}`,
environment: "production",
provider: "ssh-unraid",
branch: workload.metadata?.branch || repository.defaultBranch || "main",
serverId: server.id,
remoteFolder: selectedFolder,
deploymentMode: effectiveDeploymentMode,
composeFile: composeFiles[0],
composeFiles,
composeProject: workload.compose?.project || "",
composeWorkingDir: workload.compose?.workingDir || "",
composeService: services[0] || String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app",
composeServices: services.length ? services : [String(primary.service || selectedFolder.split("/").pop()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "app"],
containerName: primary.name || selectedFolder.split("/").pop(),
cloneUrl: workload.metadata?.sourceRepository || repository.sshUrl || repository.cloneUrl || "",
alignRemote: false,
hostPort: primaryPort.hostPort || null,
containerPort: primaryPort.containerPort || null,
webUiUrl: workload.metadata?.webUiUrl || workload.dockerMan?.webUiUrl || "",
iconMode: "none",
iconUrl: "",
iconFilePath: "",
serverIconReference: workload.metadata?.iconUrl || workload.dockerMan?.iconUrl || "",
dockerShell: ["/bin/bash", "/bin/sh"].includes(workload.metadata?.shell) ? workload.metadata.shell : "/bin/sh",
preservePaths: [...preservePaths],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: {
workloadId: workload.workloadId,
selector: workload.selector,
linkSource,
linkedAt: new Date().toISOString(),
},
detectedAt: new Date().toISOString(),
detectedMetadata: {
source: `${linkSource}-server-inventory`,
kind: workload.kind,
composeProject: workload.compose?.project || "",
composeFiles,
services,
image: primary.image || "",
dockerManTemplatePath: workload.dockerMan?.templatePath || "",
},
confirmationRequired: !String(linkSource).startsWith("automatic"),
};
}
refreshedProfileFromWorkload(repository, server, workload, existingProfile) {
const detected = this.profileFromWorkload(repository, server, workload, {
linkSource: existingProfile.workloadIdentity?.linkSource || "automatic-compose",
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(existingProfile.deploymentMode)
? existingProfile.deploymentMode
: "push-bundle",
remoteFolder: workload.remoteFolderCandidate || existingProfile.remoteFolder,
});
return {
...existingProfile,
deploymentMode: detected.deploymentMode,
remoteFolder: detected.remoteFolder,
composeFile: detected.composeFile,
composeFiles: detected.composeFiles,
composeProject: detected.composeProject,
composeWorkingDir: detected.composeWorkingDir,
composeService: detected.composeService,
composeServices: detected.composeServices,
containerName: detected.containerName || existingProfile.containerName,
hostPort: detected.hostPort || existingProfile.hostPort || null,
containerPort: detected.containerPort || existingProfile.containerPort || null,
webUiUrl: detected.webUiUrl || existingProfile.webUiUrl || "",
serverIconReference: detected.serverIconReference || existingProfile.serverIconReference || "",
dockerShell: detected.dockerShell || existingProfile.dockerShell || "/bin/sh",
preservePaths: [...new Set([...(existingProfile.preservePaths || []), ...(detected.preservePaths || [])])],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: detected.workloadIdentity,
detectedAt: detected.detectedAt,
detectedMetadata: detected.detectedMetadata,
};
}
async saveWorkloadState(profile, workload, server) {
const candidateSha = String(workload.metadata?.liveRevision || "");
const previousState = this.store.getDeploymentState?.(profile.id) || {};
const observedLiveSha = /^[0-9a-f]{40,64}$/i.test(candidateSha) ? candidateSha.toLowerCase() : null;
const liveSha = observedLiveSha || previousState.liveSha || null;
const profileRemote = inventoryRemoteIdentity(profile.cloneUrl);
const workloadRemote = inventoryRemoteIdentity(workload.metadata?.sourceRepository);
const repositoryMatches = Boolean(observedLiveSha && profileRemote && workloadRemote && profileRemote === workloadRemote);
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
return this.store.saveDeploymentState(profile.id, {
liveSha,
healthy: workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null,
runtimeVerification: workload.runtime.health === "unverified" ? "running-unverified" : workload.runtime.health,
containerRunning: workload.runtime.running,
dockerHealth: primary.health || null,
containerName: primary.name || profile.containerName,
remotePath: path.join(server.basePath, profile.remoteFolder),
provider: "ssh-unraid",
workloadId: workload.workloadId,
composeProject: workload.compose?.project || null,
observedAt: workload.observedAt,
evidence: liveSha ? "container-provenance-label" : "runtime-only",
giteaSha: repositoryMatches ? observedLiveSha : previousState.giteaSha || null,
matchesGitea: repositoryMatches ? true : previousState.matchesGitea === true && previousState.liveSha === liveSha,
previousSha: previousState.previousSha || null,
});
}
async collectServerInventory(serverId, repositories) {
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), {
timeout: 180_000,
maxOutput: 64 * 1024 * 1024,
});
const inventory = parseWorkloadInventory(result.stdout);
const profiles = this.allSshProfiles();
const detectedWorkloads = buildWorkloadInventory({
inventory,
server,
repositories,
profiles,
});
const detectedIds = new Set(detectedWorkloads.map((item) => item.workloadId));
const staleLinks = profiles.filter((profile) => profile.serverId === serverId && profile.workloadIdentity?.workloadId && !detectedIds.has(profile.workloadIdentity.workloadId)).map((profile) => ({
workloadId: profile.workloadIdentity.workloadId,
serverId,
displayName: profile.name || profile.remoteFolder || profile._repositoryFullName,
status: "stale",
link: { profileId: profile.id, repositoryFullName: profile._repositoryFullName },
compose: { project: profile.composeProject || "", workingDir: profile.composeWorkingDir || path.join(server.basePath, profile.remoteFolder || ""), configFiles: profile.composeFiles || [profile.composeFile].filter(Boolean), services: profile.composeServices || [profile.composeService].filter(Boolean) },
containers: [],
runtime: { running: false, health: "missing" },
metadata: { sourceRepository: profile.cloneUrl || "", liveRevision: "", branch: profile.branch || "", staleLink: true },
candidates: [{ repositoryFullName: profile._repositoryFullName, repositoryName: profile._repositoryFullName.split("/").pop(), score: 100, exact: true, reasons: ["persisted deployment profile"] }],
remoteFolderCandidate: profile.remoteFolder || "",
observedAt: new Date().toISOString(),
}));
const workloads = classifyInventory([...detectedWorkloads, ...staleLinks], profiles, this.store.getInventoryReviewDecisions?.(serverId) || []);
return { server, inventory, workloads };
}
inventoryResponse(server, inventory, workloads, changes = {}) {
const summary = {
serverId: server.id,
serverName: server.name,
detected: workloads.length,
adopted: Number(changes.adopted || 0),
refreshed: Number(changes.refreshed || 0),
retired: Number(changes.retired || 0),
staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [],
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
linked: workloads.filter((item) => item.status === "linked").length,
unmatched: workloads.filter((item) => !item.link).length,
needsReview: workloads.filter((item) => !item.reviewDecision && (["suggested", "ambiguous", "unmatched", "duplicate", "stale"].includes(item.status) || ["orphan-container", "historical-compose", "stale-link"].includes(item.classification?.type))).length,
duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length,
excluded: workloads.filter((item) => ["system-container", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(item.classification?.type)).length,
running: workloads.filter((item) => item.runtime.running).length,
stopped: workloads.filter((item) => !item.runtime.running).length,
};
return {
...summary,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
capabilities: inventory.capabilities,
warnings: inventory.warnings,
workloads,
observedAt: new Date().toISOString(),
};
}
async scanServerInventory(serverId, repositories) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const response = this.inventoryResponse(server, inventory, workloads);
await this.diagnostics?.info("unraid.workloads.scanned", {
serverId,
detected: response.detected,
linked: response.linked,
needsReview: response.needsReview,
readOnly: true,
});
return response;
}
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
const additions = [];
const updates = [];
const conflicts = [];
for (const workload of workloads) {
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
if (!workload.reviewDecision && ["duplicate", "historical-compose", "stale-link"].includes(workload.classification?.type)) conflicts.push({ workloadId: workload.workloadId, displayName: workload.displayName, status: workload.classification.type, reason: workload.classification.reason, candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })) });
continue;
}
if (workload.link?.profileId && workload.link?.repositoryFullName) {
updates.push({
workloadId: workload.workloadId,
profileId: workload.link.profileId,
repositoryFullName: workload.link.repositoryFullName,
impact: "Refresh detected Compose identity and observed deployment state",
});
continue;
}
const candidate = workload.candidates?.[0];
const unique = workload.candidates?.length === 1;
const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70));
if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) {
additions.push({
workloadId: workload.workloadId,
repositoryFullName: candidate.repositoryFullName,
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
impact: "Create a server-pull deployment profile; no container changes",
});
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
conflicts.push({
workloadId: workload.workloadId,
displayName: workload.displayName,
status: workload.status,
candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })),
});
}
}
const stale = profiles.filter((profile) =>
String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
&& profile.workloadIdentity?.workloadId
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
).map((profile) => ({
profileId: profile.id,
repositoryFullName: profile._repositoryFullName,
reason: "workload-missing",
impact: "Review only; ForgeFlow will not remove this profile automatically",
}));
const payload = { serverId: server.id, additions, updates, stale, conflicts };
return {
id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"),
createdAt: new Date().toISOString(),
...payload,
summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length },
};
}
async planServerInventoryReconciliation(serverId, repositories, options = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const plan = this.reconciliationPlan(server, workloads, repositories, options);
return { inventory: this.inventoryResponse(server, inventory, workloads), plan };
}
async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink });
if (!expectedPlanId || expectedPlanId !== plan.id) {
const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID.");
error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED";
error.plan = plan;
throw error;
}
const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null;
let adopted = 0;
let refreshed = 0;
let retired = 0;
let staleProfiles = [];
const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning));
if (inventoryStable && workloads.length) {
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
const staleAutomaticProfiles = this.allSshProfiles().filter((profile) =>
profile.serverId === serverId
&& String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
&& profile.workloadIdentity?.workloadId
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
);
const runningRepositoryLinks = new Set(workloads
.filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
const runningProfileIds = new Set(workloads
.filter((workload) => workload.runtime?.running && workload.link?.profileId)
.map((workload) => workload.link.profileId));
const shadowedAutomaticProfiles = workloads
.filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase()))
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()))
.filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic"));
staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, {
profileId: profile.id,
repositoryFullName: profile._repositoryFullName,
reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload",
}])).values()];
}
for (const workload of workloads) {
if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue;
const repository = (repositories || []).find((item) => item.fullName === workload.link.repositoryFullName);
const existingProfile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId && item._repositoryFullName === workload.link.repositoryFullName);
if (!repository || !existingProfile) continue;
const updated = this.refreshedProfileFromWorkload(repository, server, workload, existingProfile);
const saved = await this.store.saveDeploymentProfile(repository.fullName, updated);
await this.saveWorkloadState(saved, workload, server);
refreshed += 1;
}
if (autoLink) {
const alreadyLinkedRepositories = new Set(workloads
.filter((item) => item.runtime?.running && item.link?.repositoryFullName)
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
for (const workload of workloads) {
if (workload.status === "linked") continue;
if (["duplicate", "backup", "release-folder", "historical-compose", "system-container", "manually-excluded"].includes(workload.classification?.type)) continue;
const candidate = workload.candidates[0];
const uniqueCandidate = workload.candidates.length === 1;
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;
const exactMatch = uniqueCandidate && candidate?.exact === true;
const exactRuntimeIdentity = uniqueCandidate
&& candidate?.identityExact === true
&& candidate.score >= 70
&& workload.runtime?.running === true
&& Boolean(workload.remoteFolderCandidate)
&& !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase());
if (!exactMatch && !exactRuntimeIdentity) continue;
const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName);
if (!repository) continue;
const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity";
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
await this.saveWorkloadState(saved, workload, server);
workload.status = "linked";
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
alreadyLinkedRepositories.add(String(repository.fullName).toLowerCase());
adopted += 1;
}
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles });
response.recoverySnapshot = recoverySnapshot;
await this.diagnostics?.info("unraid.workloads.reconciled", {
serverId,
detected: response.detected,
adopted,
refreshed,
retired,
});
return response;
}
async discoverServerWorkloads(serverId, repositories) {
return this.scanServerInventory(serverId, repositories);
}
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
const effectiveDeploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(deploymentMode)
? deploymentMode
: "server-git";
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const inventory = await this.scanServerInventory(serverId, [repository]);
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again.");
const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId);
if (existing && String(existing._repositoryFullName).toLowerCase() !== String(repository.fullName).toLowerCase()) {
const error = new Error(`This workload is already linked to ${existing._repositoryFullName}. Remove or edit that link first.`);
error.code = "WORKLOAD_ALREADY_LINKED";
throw error;
}
const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: effectiveDeploymentMode, remoteFolder });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
const state = await this.saveWorkloadState(saved, workload, server);
await this.diagnostics?.info("unraid.workload.linked", { serverId, workloadId, repository: repository.fullName, profileId: saved.id, deploymentMode: effectiveDeploymentMode });
return { profile: saved, state, workload };
}
}
return UnraidInventoryMethods.prototype;
}
module.exports = { createUnraidInventoryMethods };