Files
ForgeFlow/src/main/unraid-inventory-methods.cjs
T
NuklearRabbit 32ed4fcb5e
ForgeFlow quality gate / quality (push) Canceled after 0s
perf: streamline repository and deployment awareness
2026-08-12 15:26:53 +02:00

708 lines
42 KiB
JavaScript

"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
mapfile -t container_ids <<< "$ids"
# Docker accepts multiple IDs and returns one JSON array. This avoids one
# daemon round-trip per container on larger Unraid installations.
if inspect=$(docker inspect "\${container_ids[@]}" 2>/dev/null); then
printf 'C\\t%s\\n' "$(printf '%s' "$inspect" | base64 | tr -d '\\r\\n')"
else
# A container can disappear between docker ps and inspect. Fall back to
# individual reads so the remaining inventory stays complete.
for container_id in "\${container_ids[@]}"; do
[ -n "$container_id" ] || continue
if inspect=$(docker inspect "$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
fi
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
has_override=false
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
has_override=true
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=''; compose_error=''
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)
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
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker compose "$@" config --images 2>/dev/null || true); fi
else compose_error=$services; services=''; fi
else
if services=$(cd "$dir" && docker-compose "$@" config --services 2>&1); then
valid=true
if [ "$has_override" = true ] || [ -z "$images" ] || printf '%s' "$images" | grep -q '\$'; then images=$(cd "$dir" && docker-compose "$@" config --images 2>/dev/null || true); fi
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
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 configuredRoot = path.join(server.basePath, existingProfile.remoteFolder || "").replace(/\/+$/, "");
const composeWorkingDir = String(workload.compose?.workingDir || "").replace(/\/+$/, "");
const configuredRootOwnsCompose = Boolean(
configuredRoot
&& composeWorkingDir
&& (composeWorkingDir === configuredRoot || composeWorkingDir.startsWith(`${configuredRoot}/`)),
);
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: configuredRootOwnsCompose
? existingProfile.remoteFolder
: workload.remoteFolderCandidate || existingProfile.remoteFolder,
});
const repositoryRelativeComposeFiles = configuredRootOwnsCompose
? [...new Set((workload.compose?.configFiles || []).map((file) => {
const value = String(file || "").trim().replace(/\\/g, "/");
if (value.startsWith(`${configuredRoot}/`)) return value.slice(configuredRoot.length + 1);
return value.startsWith("/") ? "" : value;
}).filter(Boolean))]
: [];
const composeFiles = repositoryRelativeComposeFiles.length
? repositoryRelativeComposeFiles
: detected.composeFiles;
return {
...existingProfile,
deploymentMode: detected.deploymentMode,
remoteFolder: detected.remoteFolder,
composeFile: composeFiles[0],
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, { expectedGiteaSha = null, health = null } = {}) {
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 verifiedGiteaSha = /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
? String(expectedGiteaSha).toLowerCase()
: null;
const matchesGitea = Boolean(repositoryMatches && verifiedGiteaSha && observedLiveSha === verifiedGiteaSha);
const primary = workload.containers.find((container) => container.running) || workload.containers[0] || {};
const dockerHealthy = workload.runtime.health === "healthy" ? true : workload.runtime.health === "unhealthy" ? false : null;
const effectiveHealthy = workload.runtime.running === false
? false
: health?.configured ? health.healthy : dockerHealthy;
return this.store.saveDeploymentState(profile.id, {
liveSha,
healthy: effectiveHealthy,
healthStatus: health?.status ?? null,
healthLatencyMs: health?.latencyMs ?? null,
runtimeVerification: workload.runtime.running === false ? "stopped" : health?.configured ? "desktop-healthcheck" : 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: verifiedGiteaSha,
matchesGitea,
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) => {
if (item.reviewDecision) return false;
const type = item.classification?.type;
if (type === "duplicate") return item.runtime?.running === true;
if (type === "stale-link") return true;
if (["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(type)) return false;
return item.runtime?.running && ["suggested", "ambiguous", "unmatched"].includes(item.status);
}).length,
duplicates: workloads.filter((item) => item.classification?.type === "duplicate").length,
excluded: workloads.filter((item) => ["system-container", "external-container", "temporary-runtime", "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, { autoLink = false } = {}) {
const started = Date.now();
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
let adopted = 0;
const adoptedLinks = [];
if (autoLink) {
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
const linkedRepositories = new Set(workloads
.filter((workload) => workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
for (const addition of plan.additions) {
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
const key = String(repository?.fullName || "").toLowerCase();
if (!workload || !repository || linkedRepositories.has(key)) continue;
const linkSource = addition.evidence === "exact-provenance" ? "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 };
linkedRepositories.add(key);
adopted += 1;
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
}
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
await this.diagnostics?.info("unraid.workloads.scanned", {
serverId,
detected: response.detected,
linked: response.linked,
needsReview: response.needsReview,
adopted,
adoptedLinks,
readOnly: !autoLink,
durationMs: Date.now() - started,
});
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", "external-container", "temporary-runtime", "manually-excluded", "stale-link"].includes(workload.classification?.type)) {
if (!workload.reviewDecision && (["historical-compose", "stale-link"].includes(workload.classification?.type) || (workload.classification?.type === "duplicate" && workload.runtime?.running))) 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",
});
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
} 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.shadowedLink?.profileId && !runningProfileIds.has(workload.shadowedLink.profileId) && runningRepositoryLinks.has(String(workload.shadowedLink.repositoryFullName).toLowerCase()))
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.shadowedLink.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.shadowedLink.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) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
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", "external-container", "temporary-runtime", "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) => String(item.fullName).toLowerCase() === String(candidate.repositoryFullName).toLowerCase());
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;
}
}
for (const stale of staleProfiles) {
await this.store.deleteDeploymentProfile(stale.repositoryFullName, stale.profileId);
retired += 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) {
const started = Date.now();
const inventory = await this.scanServerInventory(serverId, repositories, { autoLink: true });
const server = this.store.getServer(serverId);
const queue = inventory.workloads.filter((workload) => workload.link?.profileId && workload.link?.repositoryFullName);
const refreshedProfileIds = [];
let giteaUnavailable = false;
let giteaFailureReported = false;
const workers = Array.from({ length: Math.min(5, queue.length) }, async () => {
while (queue.length) {
const workload = queue.shift();
const repository = repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
const profile = this.store.getDeploymentProfile?.(workload.link.repositoryFullName, workload.link.profileId)
|| this.store.getDeploymentProfiles?.(workload.link.repositoryFullName)?.find((item) => item.id === workload.link.profileId)
|| this.allSshProfiles().find((item) => item.id === workload.link.profileId);
if (!repository || !profile) continue;
let expectedGiteaSha = null;
const status = repository.localStatus;
if (status?.head && status.branch?.head === profile.branch && status.branch?.upstream && status.branch.ahead === 0 && status.branch.behind === 0) {
expectedGiteaSha = status.head;
} else if (!giteaUnavailable) {
try {
const [owner, repo] = String(repository.fullName).split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
expectedGiteaSha = branch?.commit?.id || branch?.commit?.sha || null;
} catch (error) {
if (!error?.status || Number(error.status) >= 500) {
giteaUnavailable = true;
if (!giteaFailureReported) {
giteaFailureReported = true;
await this.diagnostics?.warning("unraid.workloads.gitea-verification-degraded", {
serverId,
message: error.message,
});
}
}
}
}
const health = workload.runtime.running
? await this.checkHealth(profile.healthcheckUrl)
: { configured: false, healthy: false, skipped: "container-stopped" };
await this.saveWorkloadState(profile, workload, server, { expectedGiteaSha, health });
refreshedProfileIds.push(profile.id);
}
});
await Promise.all(workers);
await this.diagnostics?.debug("unraid.workloads.states-refreshed", {
serverId,
profiles: refreshedProfileIds.length,
durationMs: Date.now() - started,
});
return { ...inventory, refreshedProfiles: refreshedProfileIds.length, refreshedProfileIds };
}
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 };