Files
ForgeFlow/src/main/unraid-preflight-methods.cjs
T

594 lines
26 KiB
JavaScript

"use strict";
function createUnraidPreflightMethods({
safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary,
inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote,
assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs,
}) {
class UnraidPreflightMethods {
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
return saved;
}
resolve(repository, profileId) {
const profile = this.store.getDeploymentProfile(
repository.fullName,
profileId,
);
if (!profile || profile.provider !== "ssh-unraid")
throw new Error("The SSH / Unraid deployment profile no longer exists.");
const server = this.store.getServer(profile.serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const remoteFolder = safeRemoteFolder(
profile.remoteFolder || repository.name,
);
const remotePath = path.join(server.basePath, remoteFolder);
if (!remotePath.startsWith(`${server.basePath}/`))
throw new Error(
"Remote project path escapes the configured server base path.",
);
const effectiveProfile = {
...profile,
deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle",
};
return { profile: effectiveProfile, server, remoteFolder, remotePath };
}
async discoverExisting({ repository, serverId, remoteFolder = "" }) {
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const folder = safeRemoteFolder(remoteFolder || repository.name);
const remotePath = path.join(server.basePath, folder);
const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false });
const workload = inventory.workloads.find((item) =>
item.remoteFolderCandidate === folder ||
item.compose?.workingDir === remotePath ||
item.containers.some((container) => (container.mounts || []).some((mount) => {
const source = String(mount.source || "").replace(/\/+$/, "");
return source === remotePath || source.startsWith(`${remotePath}/`);
}))
);
if (!workload) {
const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`);
error.code = "SERVER_WORKLOAD_NOT_FOUND";
throw error;
}
const profile = this.profileFromWorkload(repository, server, workload, {
linkSource: "manual",
deploymentMode: "server-git",
remoteFolder: folder,
});
const source = (value, origin, confidence = "confirmed") => ({
value,
origin,
confidence,
detectedAt: new Date().toISOString(),
overridden: false,
});
const provenance = {
remoteFolder: source(folder, "server-inventory"),
cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"),
branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"),
composeFile: source(profile.composeFile, "docker-compose-labels"),
composeService: source(profile.composeService, "docker-compose-labels"),
containerName: source(profile.containerName, "docker-inspect"),
hostPort: source(profile.hostPort, "docker-inspect"),
containerPort: source(profile.containerPort, "docker-inspect"),
webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"),
iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"),
dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"),
};
return {
repository: repository.fullName,
profile: { ...profile, id: undefined, provenance },
provenance,
workload,
runtime: {
remotePath,
containerRunning: workload.runtime.running,
containers: workload.containers.length,
services: workload.compose?.services?.length || workload.containers.length,
ports: workload.runtime.ports,
mounts: workload.containers.flatMap((container) => container.mounts || []),
networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))],
envNames: [],
},
};
}
async inspect({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const preserveProbe = (profile.preservePaths || [])
.map(
(relativePath) =>
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`,
)
.join("\n");
const script = `
root=${shellQuote(remotePath)}
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
if [ -d "$root" ]; then
exists=true
if [ -d "$root/.git" ]; then
root_git=true
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
fi
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
[ -f "$root/Dockerfile" ] && dockerfile=true
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true)
fi
printf '__FORGEFLOW_KV__\\n'
printf 'exists=%s\\n' "$exists"
printf 'rootGit=%s\\n' "$root_git"
printf 'head=%s\\n' "$head"
printf 'branch=%s\\n' "$branch"
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
printf 'trackedChanges=%s\\n' "$tracked_changes"
printf 'composeFiles=%s\\n' "$compose_files"
printf 'nestedGit=%s\\n' "$nested_git"
printf 'dockerfile=%s\\n' "$dockerfile"
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
`;
const wrapped = bash(script);
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
const parsed = parseInspection(result.stdout);
const contextCandidates = [
...new Set([
...(parsed.existingPreservePaths || []),
...(parsed.nestedGit || []),
]),
];
const inspection = {
...parsed,
dockerignore: Boolean(parsed.dockerignoreContent),
dockerignoreGitExcluded: dockerIgnoreHasPath(
parsed.dockerignoreContent,
".git",
),
dockerContextExclusionsMissing: parsed.dockerfile
? contextCandidates.filter(
(item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item),
)
: [],
serverId: server.id,
serverName: server.name,
remotePath,
profileId: profile.id,
};
await this.diagnostics?.info("unraid.inspected", {
repository: repository.fullName,
serverId: server.id,
remotePath,
exists: inspection.exists,
rootGit: inspection.rootGit,
head: inspection.head,
composeFiles: inspection.composeFiles,
nestedGitCount: inspection.nestedGit.length,
trackedChangeCount: inspection.trackedChanges.length,
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing,
});
return inspection;
}
async preflight({ repository, profileId, sha = null }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
let requestedSha = sha || repository.localStatus?.head;
if (deploymentMode === "server-git" && !sha) {
const [owner, repo] = String(repository.fullName || "").split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
requestedSha = branch?.commit?.id || branch?.commit?.sha || null;
}
const targetSha = assertFullCommitSha(requestedSha);
const checks = [];
let inspection = null;
let connectionCapabilities = null;
let permissions = null;
if (deploymentMode === "monitor-only") checks.push({
id: "deployment-mode",
label: "Deployment mode",
status: "fail",
detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.",
});
else checks.push({
id: "deployment-mode",
label: "Deployment mode",
status: "pass",
detail: deploymentMode === "server-git"
? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key."
: "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.",
});
if (!repository.localPath) {
checks.push({
id: "local-repository",
label: "Local repository",
status: deploymentMode === "server-git" ? "pass" : "fail",
detail: deploymentMode === "server-git"
? "Not required: the exact commit is fetched from Gitea by the server."
: "Link or clone the repository locally before using Direct copy.",
});
} else {
try {
const localStatus = await this.git.status(repository.localPath);
checks.push({
id: "local-repository",
label: "Local repository",
status: "pass",
detail: localStatus.root,
});
checks.push({
id: "local-branch",
label: "Allowed branch",
status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`,
});
checks.push({
id: "local-clean",
label: "Clean local working tree",
status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail",
detail: localStatus.clean
? "No uncommitted changes."
: `${localStatus.counts.changed} changed file(s) remain.`,
});
checks.push({
id: "deployment-source",
label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source",
status: "pass",
detail: deploymentMode === "server-git"
? "Local files are not uploaded; the exact requested commit is fetched from Gitea."
: "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.",
});
const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose
? [nativePath.join(repository.localPath, "Dockerfile")]
: deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) =>
nativePath.join(repository.localPath, safeRelativeRemoteFile(file)),
) : [];
const missingDeploymentFiles = [];
for (const file of localDeploymentFiles) {
if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file);
}
if (deploymentMode === "push-bundle") checks.push({
id: "local-deployment-file",
label: profile.generatedCompose
? "Dockerfile in repository"
: localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository",
status: missingDeploymentFiles.length ? "fail" : "pass",
detail: missingDeploymentFiles.length
? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}`
: localDeploymentFiles.join(", "),
});
} catch (error) {
checks.push({
id: "local-repository",
label: "Local repository",
status: "fail",
detail: error.message,
});
}
}
try {
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
connectionCapabilities = connection.capabilities || {};
checks.push({
id: "ssh",
label: "Desktop → Unraid SSH",
status: "pass",
detail: `${server.username}@${server.host}:${server.port}`,
});
checks.push({
id: "docker-runtime",
label: "Docker runtime",
status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail",
detail: connectionCapabilities.dockerReady
? "Docker is reachable by the configured SSH user."
: connectionCapabilities.docker
? "Docker is installed, but the configured SSH user cannot query the daemon."
: "Docker was not detected on the server.",
});
checks.push({
id: "compose-command",
label: "Docker Compose",
status: connectionCapabilities.compose ? "pass" : "fail",
detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.",
});
checks.push({
id: "bundle-tools",
label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools",
status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail",
detail: deploymentMode === "server-git"
? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.`
: connectionCapabilities.tar && connectionCapabilities.checksum
? "tar and a SHA-256 checksum tool are available."
: `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`,
});
checks.push({
id: "server-base-writable",
label: "Deployment storage writable",
status: connectionCapabilities.baseWritable ? "pass" : "fail",
detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`,
});
} catch (error) {
checks.push({
id: "ssh",
label: "Desktop → Unraid SSH",
status: "fail",
detail: error.message,
});
}
if (!server.hostFingerprint) checks.push({
id: "host-key",
label: "Server identity",
status: "fail",
detail: "Test and trust the SSH host key first.",
});
else checks.push({
id: "host-key",
label: "Server identity",
status: "pass",
detail: server.hostFingerprint,
});
if (deploymentMode === "server-git") {
const access = await this.probeServerGitAccess({ repository, profile, server });
checks.push({
id: "server-git-access",
label: "Unraid → Gitea read access",
status: access.ready ? "pass" : "fail",
detail: access.ready
? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.`
: access.error,
repairAction: access.ready ? null : "configure-server-git-access",
repairLabel: "Configure read-only deploy key",
});
} else checks.push({
id: "transfer-path",
label: "Desktop → Unraid transfer",
status: "pass",
detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.",
});
try {
permissions = await this.inspectWriteAccess({ repository, profileId });
const blockingPaths = permissions.blocking.map((target) => target.path);
checks.push({
id: "project-write-access",
label: "Project write access",
status: permissions.ready ? "pass" : "fail",
detail: permissions.ready
? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.`
: `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`,
help: permissions.ready
? "ForgeFlow rechecks these paths immediately before every upload and Compose activation."
: "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.",
repairAction: permissions.ready ? null : "repair-deployment-write-access",
repairLabel: "Fix write access",
});
for (const target of permissions.targets.filter(
(item) => item.required && !item.effectiveWritable,
)) {
checks.push({
id: `write-path:${target.id}`,
label: target.label,
status: "fail",
detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`,
repairAction: "repair-deployment-write-access",
repairLabel: "Fix write access",
});
}
} catch (error) {
checks.push({
id: "project-write-access",
label: "Project write access",
status: "fail",
detail: error.message,
});
}
try {
inspection = await this.inspect({ repository, profileId });
if (!inspection.exists) {
checks.push({
id: "remote-folder",
label: "Remote project folder",
status: "pass",
detail: `${remotePath} will be created.`,
});
} else {
checks.push({
id: "remote-folder",
label: inspection.rootGit ? "Remote project folder" : "Existing server installation",
status: "pass",
detail: inspection.rootGit
? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.`
: `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`,
});
}
if (inspection.rootGit) {
checks.push({
id: "tracked-changes",
label: "Server-side tracked changes",
status: inspection.trackedChanges.length ? "warning" : "pass",
detail: inspection.trackedChanges.length
? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.`
: "No tracked server-only edits detected.",
});
}
if (inspection.nestedGit.length) {
checks.push({
id: "nested-git",
label: "Nested Git repositories",
status: "warning",
detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`,
});
}
if (inspection.dockerfile && !inspection.dockerignore) {
checks.push({
id: "dockerignore",
label: "Docker build context",
status: "warning",
detail:
"A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.",
});
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
checks.push({
id: "dockerignore-git",
label: "Git metadata excluded from Docker",
status: "warning",
detail: ".dockerignore does not explicitly exclude .git.",
});
} else if (inspection.dockerfile) {
checks.push({
id: "dockerignore-git",
label: "Git metadata excluded from Docker",
status: "pass",
detail: ".git is excluded from the Docker build context.",
});
}
if (inspection.dockerContextExclusionsMissing.length) {
checks.push({
id: "dockerignore-runtime",
label: "Runtime data excluded from Docker",
status: "warning",
detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`,
});
} else if (
inspection.dockerfile &&
inspection.existingPreservePaths.length
) {
checks.push({
id: "dockerignore-runtime",
label: "Runtime data excluded from Docker",
status: "pass",
detail:
"Detected preserved runtime paths are excluded from the Docker build context.",
});
}
const composeFiles = profile.generatedCompose
? [".forgeflow/compose.forgeflow.yml"]
: (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"])
.map((value) => safeRelativeRemoteFile(value));
const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile));
checks.push({
id: "compose-file",
label: "Compose configuration",
status: "pass",
detail: profile.generatedCompose
? "ForgeFlow will generate an isolated Compose file."
: missingRemoteCompose.length
? `${composeFiles.join(", ")} will be uploaded from the exact local commit.`
: composeFiles.join(", "),
});
} catch (error) {
checks.push({
id: "inspection",
label: "Server project inspection",
status: "fail",
detail: error.message,
});
}
const iconMode =
profile.iconMode ||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
if (iconMode === "upload") {
const iconStat = await fs.stat(profile.iconFilePath).catch(() => null);
checks.push({
id: "dockerman-icon-file",
label: "DockerMan icon upload",
status:
iconStat?.isFile() &&
nativePath.extname(profile.iconFilePath).toLowerCase() === ".png"
? "pass"
: "fail",
detail: iconStat?.isFile()
? profile.iconFilePath
: "The selected local PNG icon file was not found.",
});
} else if (iconMode === "builtin") {
const builtinIcon = nativePath.join(
this.sourcePath,
"src",
"renderer",
"assets",
"itworx-mark.png",
);
const iconStat = await fs.stat(builtinIcon).catch(() => null);
checks.push({
id: "dockerman-icon-builtin",
label: "DockerMan icon",
status: iconStat?.isFile() ? "pass" : "fail",
detail: iconStat?.isFile()
? "Built-in high-contrast ITWorx mark."
: "The built-in ITWorx icon asset is missing.",
});
} else if (iconMode === "url")
checks.push({
id: "dockerman-icon",
label: "DockerMan icon",
status: profile.iconUrl ? "pass" : "fail",
detail:
profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.",
});
else
checks.push({
id: "dockerman-icon",
label: "DockerMan icon",
status: "warning",
detail: "Custom DockerMan icon disabled.",
});
const webUiLabel = this.dockerManWebUi(profile);
checks.push({
id: "dockerman-webui",
label: "DockerMan Web UI action",
status: webUiLabel ? "pass" : "warning",
detail: webUiLabel || "No Web UI URL or host port is configured.",
});
checks.push({
id: "compose-identity",
label: "Safe Docker Compose identity",
status: "pass",
detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`,
});
checks.push({
id: "exact-sha",
label: "Exact deployment commit",
status: "pass",
detail: targetSha,
});
return {
provider: "ssh-unraid",
repository: repository.fullName,
environment: profile.environment,
sha: targetSha,
server: { id: server.id, name: server.name, host: server.host },
remotePath,
inspection,
permissions,
checks,
summary: checksSummary(checks),
};
}
}
return UnraidPreflightMethods.prototype;
}
module.exports = { createUnraidPreflightMethods };