refactor: split renderer ipc and unraid domains
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user