"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 # Use Docker's own JSON document instead of a Go template. Accessing an # absent .State.Health map key makes the formatted Docker inspect fail for # every container without a healthcheck, which previously made those # containers look as if they disappeared during the scan. 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 <<< "$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#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 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) { 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) => { 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 { 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, }); 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) { return this.scanServerInventory(serverId, repositories, { autoLink: true }); } 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 };