Three handlers referenced a dependency they were never given, which made them throw a ReferenceError as soon as they ran: - deployment:preflight for Gitea Actions profiles (`preflight` was passed to registerOperationsIpc but not to registerDeploymentIpc) - Unraid write-access repair (`safeRelativeRemoteFile` was missing from createUnraidAccessMethods) - a dead reference of the same name in unraid-state-methods no-undef and no-unused-vars were disabled for every file, which is why none of these were caught. Both are now enabled for src/main and src/shared, where the dependency graph is explicit. The renderer keeps them off because its functions are deliberately cross-script globals. Performance: - git.status() spawned three processes (rev-parse, status, remote get-url) per call. A directory holding its own .git is by definition the work tree root, so rev-parse is unnecessary, and the remote URL is cached against the mtime of .git/config, including the failure for a repository without that remote. - git status runs with --no-optional-locks so a read no longer rewrites the index. That stops it fighting a concurrent Git command for the index lock, and is what makes filesystem watching viable at all. - One commit issued four `git status` reads; callers that already hold the status now pass it on, leaving two. - The repository monitor is event driven. A watched repository is read on filesystem activity, with a 30s safety net for watchers that stop delivering and a 1s floor so a busy tree cannot drive a read per event. Repositories that cannot be watched keep using the interval. Idle cost for one repository over 35s: 24 git processes before, 3 after. - Resolving one repository by name no longer refreshes the whole workspace. - Concurrent configuration saves share a single write of the latest state. - Repository discovery follows directory junctions again. The filter that skipped them made the realpath cycle guard dead code, and hid any project folder reached through a junction. Renderer: - render() replaced the whole shell on every poll, discarding focus, caret and scroll position while the user was typing. Those are preserved now, and an unchanged render leaves the DOM alone entirely. - The four sections that enhanceRenderedUi() injected after render moved into the views, so the rendered markup is the single source of truth. - The monitor no longer keeps a repository paused forever when it is unlinked mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges no longer block startup, and #app is no longer an aria-live region announcing the entire UI on every render. IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level mutable diagnostics singleton with an argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
462 lines
25 KiB
JavaScript
462 lines
25 KiB
JavaScript
"use strict";
|
|
|
|
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
|
|
class UnraidAccessMethods {
|
|
serverGitRemote(repository, profile) {
|
|
const candidates = [
|
|
repository.localStatus?.remoteUrl,
|
|
repository.sshUrl,
|
|
repository.preferredCloneUrl,
|
|
profile.cloneUrl,
|
|
]
|
|
.map((value) => String(value || "").trim())
|
|
.filter(Boolean);
|
|
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
|
if (!value) {
|
|
const error = new Error("Server pull requires the repository SSH clone URL from Gitea.");
|
|
error.code = "SERVER_GIT_SSH_URL_REQUIRED";
|
|
throw error;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
serverGitHost(repository, profile) {
|
|
const remote = this.serverGitRemote(repository, profile);
|
|
if (/^ssh:\/\//i.test(remote)) {
|
|
const parsed = new URL(remote);
|
|
return { host: parsed.hostname, port: Number(parsed.port || 22) };
|
|
}
|
|
const match = remote.match(/^[^@\s]+@([^:\s]+):/);
|
|
if (!match) throw new Error("Could not determine the Gitea SSH host from the clone URL.");
|
|
return { host: match[1], port: 22 };
|
|
}
|
|
|
|
serverGitCredentialPaths(repository, server) {
|
|
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
|
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", repositoryId);
|
|
return {
|
|
directory,
|
|
privateKey: path.join(directory, "deploy-key"),
|
|
publicKey: path.join(directory, "deploy-key.pub"),
|
|
knownHosts: path.join(directory, "known_hosts"),
|
|
};
|
|
}
|
|
|
|
serverGitEnvironment(repository, profile, server) {
|
|
const credentials = this.serverGitCredentialPaths(repository, server);
|
|
return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${credentials.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${credentials.knownHosts}`)}`;
|
|
}
|
|
|
|
async configureServerGitAccess({ repository, profileId }) {
|
|
const { profile, server } = this.resolve(repository, profileId);
|
|
const remote = this.serverGitRemote(repository, profile);
|
|
const { host, port } = this.serverGitHost(repository, profile);
|
|
const credentials = this.serverGitCredentialPaths(repository, server);
|
|
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
|
const marker = "__FORGEFLOW_DEPLOY_KEY__";
|
|
const setupScript = `
|
|
command -v git >/dev/null 2>&1 || { echo "Git is not installed on the server" >&2; exit 41; }
|
|
command -v ssh-keygen >/dev/null 2>&1 || { echo "ssh-keygen is not installed on the server" >&2; exit 42; }
|
|
command -v ssh-keyscan >/dev/null 2>&1 || { echo "ssh-keyscan is not installed on the server" >&2; exit 43; }
|
|
credential_dir=${shellQuote(credentials.directory)}
|
|
private_key=${shellQuote(credentials.privateKey)}
|
|
public_key=${shellQuote(credentials.publicKey)}
|
|
known_hosts=${shellQuote(credentials.knownHosts)}
|
|
expected_host_fingerprint=${shellQuote(trustedHostFingerprint)}
|
|
mkdir -p "$credential_dir"
|
|
chmod 700 "$credential_dir"
|
|
if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then
|
|
rm -f "$private_key" "$public_key"
|
|
ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow:${repository.fullName}`)} -f "$private_key"
|
|
fi
|
|
chmod 600 "$private_key"
|
|
chmod 644 "$public_key"
|
|
scan_tmp="$known_hosts.$$.tmp"
|
|
scan_ok=false
|
|
for attempt in 1 2 3; do
|
|
ssh-keyscan -T 10 -H -p ${Number(port)} ${shellQuote(host)} > "$scan_tmp" 2>/dev/null || true
|
|
if [ -s "$scan_tmp" ]; then scan_ok=true; break; fi
|
|
sleep $((attempt * 2))
|
|
done
|
|
[ "$scan_ok" = true ] || { rm -f "$scan_tmp"; echo "Gitea SSH host did not return a host key after three attempts" >&2; exit 44; }
|
|
scanned_host_fingerprint="$(ssh-keygen -lf "$scan_tmp" -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)"
|
|
if [ -n "$expected_host_fingerprint" ] && [ "$scanned_host_fingerprint" != "$expected_host_fingerprint" ]; then
|
|
rm -f "$scan_tmp"
|
|
echo "The Gitea SSH host key changed. Verify the Gitea server before replacing trust." >&2
|
|
exit 46
|
|
fi
|
|
mv "$scan_tmp" "$known_hosts"
|
|
chmod 600 "$known_hosts"
|
|
printf '%s\n' ${shellQuote(marker)}
|
|
printf 'publicKey=%s\n' "$(base64 < "$public_key" | tr -d '\\r\\n')"
|
|
printf 'fingerprint=%s\n' "$(ssh-keygen -lf "$public_key" -E sha256 | awk '{print $2}')"
|
|
printf 'hostFingerprint=%s\n' "$scanned_host_fingerprint"
|
|
`;
|
|
const setup = await this.ssh.exec(server.id, bash(setupScript), { timeout: 60_000, maxOutput: 256 * 1024 });
|
|
const output = String(setup.stdout || "");
|
|
const markerIndex = output.lastIndexOf(marker);
|
|
if (markerIndex < 0) throw new Error("The server did not return the generated deploy key.");
|
|
const fields = Object.fromEntries(output.slice(markerIndex + marker.length).trim().split(/\r?\n/).map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
|
}));
|
|
if (trustedHostFingerprint && fields.hostFingerprint && trustedHostFingerprint !== fields.hostFingerprint) {
|
|
const error = new Error("The Gitea SSH host key changed. Server pull was not reconfigured. Verify the Gitea server before replacing trust.");
|
|
error.code = "GITEA_SSH_HOST_KEY_MISMATCH";
|
|
throw error;
|
|
}
|
|
const publicKey = Buffer.from(fields.publicKey || "", "base64").toString("utf8").trim();
|
|
const [owner, repo] = String(repository.fullName || "").split("/");
|
|
if (!owner || !repo) throw new Error("A full Gitea repository name is required to configure server pull.");
|
|
const deployKey = await this.gitea.ensureReadOnlyDeployKey({
|
|
owner,
|
|
repo,
|
|
title: `ForgeFlow · ${server.name} · read-only`,
|
|
publicKey,
|
|
});
|
|
const probeCommand = `${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`;
|
|
const probe = await this.ssh.exec(
|
|
server.id,
|
|
bash(`probe_error=''
|
|
for attempt in 1 2 3; do
|
|
if probe_output=$(${probeCommand} 2>&1); then printf '%s\n' "$probe_output"; exit 0; fi
|
|
probe_error=$probe_output
|
|
sleep $((attempt * 2))
|
|
done
|
|
printf '%s\n' "$probe_error" >&2
|
|
exit 45`),
|
|
{ timeout: 45_000, maxOutput: 256 * 1024 },
|
|
);
|
|
const remoteSha = String(probe.stdout || "").trim().split(/\s+/)[0] || null;
|
|
const updated = await this.store.saveDeploymentProfile(repository.fullName, {
|
|
...profile,
|
|
deploymentMode: "server-git",
|
|
cloneUrl: remote,
|
|
serverGitAccess: {
|
|
configured: true,
|
|
deployKeyId: deployKey.id || null,
|
|
keyFingerprint: fields.fingerprint || null,
|
|
hostFingerprint: fields.hostFingerprint || null,
|
|
configuredAt: new Date().toISOString(),
|
|
},
|
|
});
|
|
return {
|
|
profile: updated,
|
|
created: deployKey.created === true,
|
|
remoteSha,
|
|
keyFingerprint: fields.fingerprint || null,
|
|
hostFingerprint: fields.hostFingerprint || null,
|
|
};
|
|
}
|
|
|
|
async probeServerGitAccess({ repository, profile, server }) {
|
|
try {
|
|
const remote = this.serverGitRemote(repository, profile);
|
|
const credentials = this.serverGitCredentialPaths(repository, server);
|
|
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
|
|
const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
|
|
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
|
|
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
|
|
const output = String(result.stdout || "");
|
|
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
|
|
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
|
|
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
|
|
}));
|
|
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
|
|
} catch (error) {
|
|
return { ready: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
async verifyServerGitProfile({ repository, profileId }) {
|
|
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
|
const checks = [];
|
|
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
|
|
if (profile.deploymentMode === "monitor-only") {
|
|
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
|
|
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
|
}
|
|
if (profile.deploymentMode !== "server-git") {
|
|
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
|
|
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
|
|
}
|
|
let branchSha = null;
|
|
try {
|
|
const [owner, repo] = String(repository.fullName || "").split("/");
|
|
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
|
|
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
|
|
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
|
|
const keys = await this.gitea.listDeployKeys(owner, repo);
|
|
const keyId = Number(profile.serverGitAccess?.deployKeyId);
|
|
const key = keys.find((item) => Number(item.id) === keyId);
|
|
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
|
|
} catch (error) {
|
|
add("gitea-access", "Gitea verification", "fail", error.message);
|
|
}
|
|
const access = await this.probeServerGitAccess({ repository, profile, server });
|
|
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
|
|
let inspection = null;
|
|
try {
|
|
inspection = await this.inspect({ repository, profileId });
|
|
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
|
|
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
|
|
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
|
|
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
|
|
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
|
|
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
|
|
} catch (error) {
|
|
add("server-inspection", "Server inspection", "fail", error.message);
|
|
}
|
|
const state = this.store.getDeploymentState(profile.id) || {};
|
|
const liveSha = state.liveSha || inspection?.head || null;
|
|
const running = state.containerRunning;
|
|
const healthy = state.healthy;
|
|
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
|
|
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
|
|
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
|
|
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
|
|
const deploymentCheckIds = new Set(["gitea-access", "remote-branch", "deploy-key-scope", "server-git-access", "server-inspection"]);
|
|
const deploymentBlockers = checks.filter((item) => deploymentCheckIds.has(item.id) && item.status !== "pass");
|
|
const deployReady = Boolean(branchSha) && deploymentBlockers.length === 0;
|
|
const failed = checks.some((item) => item.status === "fail");
|
|
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
|
|
const readiness = deploymentBlockers.length
|
|
? "Access failed"
|
|
: failed
|
|
? "Deploy-ready; runtime unhealthy"
|
|
: incomplete
|
|
? (branchSha && liveSha && branchSha !== liveSha ? "Deployable update available" : "Deploy-ready; runtime verification incomplete")
|
|
: "Ready";
|
|
return { readiness, ready: deployReady, deployReady, deploymentBlockers, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
|
|
}
|
|
|
|
permissionTargets(profile, server, remotePath) {
|
|
const targets = [
|
|
{
|
|
id: "server-base",
|
|
label: "Configured deployment base",
|
|
path: server.basePath,
|
|
kind: "directory",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "project-root",
|
|
label: "Project folder",
|
|
path: remotePath,
|
|
kind: "directory",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "forgeflow-state",
|
|
label: "ForgeFlow upload and rollback storage",
|
|
path: path.join(remotePath, ".forgeflow"),
|
|
kind: "directory",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "forgeflow-incoming",
|
|
label: "ForgeFlow incoming upload folder",
|
|
path: path.join(remotePath, ".forgeflow", "incoming"),
|
|
kind: "directory",
|
|
required: true,
|
|
},
|
|
];
|
|
if (!profile.generatedCompose) {
|
|
for (const file of this.deploymentComposeFiles(profile)) {
|
|
targets.push({
|
|
id: `compose:${file}`,
|
|
label: `Compose file ${file}`,
|
|
path: path.join(remotePath, file),
|
|
kind: "file",
|
|
required: true,
|
|
});
|
|
}
|
|
}
|
|
const unique = new Map();
|
|
for (const target of targets) unique.set(`${target.kind}:${target.path}`, target);
|
|
return [...unique.values()];
|
|
}
|
|
|
|
permissionInspectionScript(profile, server, remotePath) {
|
|
const targetCalls = this.permissionTargets(profile, server, remotePath)
|
|
.map(
|
|
(target) =>
|
|
`probe ${shellQuote(target.id)} ${shellQuote(target.label)} ${shellQuote(target.path)} ${shellQuote(target.kind)} ${target.required ? "true" : "false"}`,
|
|
)
|
|
.join("\n");
|
|
return `
|
|
encode() { printf '%s' "$1" | base64 | tr -d '\\r\\n'; }
|
|
can_elevate=false
|
|
[ "$(id -u)" = 0 ] && can_elevate=true
|
|
if [ "$can_elevate" != true ] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then can_elevate=true; fi
|
|
has_acl=false
|
|
command -v setfacl >/dev/null 2>&1 && has_acl=true
|
|
printf '__FORGEFLOW_PERMISSIONS__\\n'
|
|
printf 'I\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
|
"$(encode "$(id -un 2>/dev/null || echo unknown)")" \
|
|
"$(id -u 2>/dev/null || echo -1)" \
|
|
"$(id -g 2>/dev/null || echo -1)" \
|
|
"$(encode "$(id -Gn 2>/dev/null || true)")" \
|
|
"$has_acl" "$can_elevate"
|
|
probe() {
|
|
target_id=$1
|
|
label=$2
|
|
target=$3
|
|
kind=$4
|
|
required=$5
|
|
exists=false; readable=false; writable=false; parent_writable=false; effective=false
|
|
owner=''; group=''; mode=''; detail=''; nearest=''
|
|
if [ -e "$target" ] || [ -L "$target" ]; then
|
|
exists=true
|
|
[ -r "$target" ] && readable=true
|
|
[ -w "$target" ] && writable=true
|
|
owner=$(stat -c '%U' "$target" 2>/dev/null || true)
|
|
group=$(stat -c '%G' "$target" 2>/dev/null || true)
|
|
mode=$(stat -c '%a' "$target" 2>/dev/null || true)
|
|
fi
|
|
parent=$(dirname "$target")
|
|
ancestor=$parent
|
|
while [ ! -d "$ancestor" ] && [ "$ancestor" != / ]; do ancestor=$(dirname "$ancestor"); done
|
|
nearest=$ancestor
|
|
marker="$ancestor/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
|
if [ -d "$ancestor" ] && (umask 077; : > "$marker") 2>/dev/null; then
|
|
rm -f -- "$marker" >/dev/null 2>&1 || true
|
|
parent_writable=true
|
|
fi
|
|
if [ "$kind" = directory ]; then
|
|
if [ -d "$target" ]; then
|
|
marker="$target/.forgeflow-write-test-$$-\${RANDOM:-0}"
|
|
if (umask 077; : > "$marker") 2>/dev/null; then
|
|
rm -f -- "$marker" >/dev/null 2>&1 || true
|
|
effective=true
|
|
fi
|
|
elif [ "$parent_writable" = true ]; then
|
|
effective=true
|
|
fi
|
|
else
|
|
if [ "$exists" = true ] && [ ! -f "$target" ]; then
|
|
detail='Path exists but is not a regular file.'
|
|
elif [ "$exists" = true ] && [ "$readable" = true ] && { [ "$writable" = true ] || [ "$parent_writable" = true ]; }; then
|
|
effective=true
|
|
elif [ "$exists" = false ] && [ "$parent_writable" = true ]; then
|
|
effective=true
|
|
detail='File is absent but can be created by the deployment user.'
|
|
fi
|
|
fi
|
|
if [ -z "$detail" ]; then
|
|
if [ "$effective" = true ]; then detail='Read/write probe passed.'
|
|
else detail="No safe create/replace access for $(id -un 2>/dev/null || echo 'the SSH user')."; fi
|
|
fi
|
|
printf 'P\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
|
|
"$(encode "$target_id")" "$(encode "$label")" "$(encode "$target")" "$kind" "$required" \
|
|
"$exists" "$readable" "$writable" "$parent_writable" "$effective" \
|
|
"$(encode "$owner")" "$(encode "$group")" "$mode" "$(encode "$nearest")" "$(encode "$detail")"
|
|
}
|
|
${targetCalls}
|
|
`;
|
|
}
|
|
|
|
async inspectWriteAccess({ repository, profileId }) {
|
|
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
|
const result = await this.ssh.exec(
|
|
server.id,
|
|
bash(this.permissionInspectionScript(profile, server, remotePath)),
|
|
{ timeout: 45_000, maxOutput: 2 * 1024 * 1024 },
|
|
);
|
|
const report = parsePermissionInspection(result.stdout);
|
|
report.serverId = server.id;
|
|
report.remotePath = remotePath;
|
|
return report;
|
|
}
|
|
|
|
permissionRepairScript(profile, server, remotePath) {
|
|
const preserve = [
|
|
".git",
|
|
"node_modules",
|
|
".venv",
|
|
"venv",
|
|
"__pycache__",
|
|
...(profile.preservePaths || []),
|
|
]
|
|
.map((value) => safeRelativeRemoteFile(value))
|
|
.filter(Boolean);
|
|
const pruneExpression = preserve.length
|
|
? preserve
|
|
.map((value) => `-path ${shellQuote(path.join(remotePath, value))} -o -path ${shellQuote(path.join(remotePath, value, "*"))}`)
|
|
.join(" -o ")
|
|
: "-false";
|
|
const composePaths = this.deploymentComposeFiles(profile)
|
|
.map((file) => shellQuote(path.join(remotePath, file)))
|
|
.join(" ");
|
|
return `
|
|
root=${shellQuote(remotePath)}
|
|
base=${shellQuote(server.basePath)}
|
|
case "$root" in "$base"|"$base"/*) ;; *) echo "Refusing permission repair outside configured base path: $root" >&2; exit 81 ;; esac
|
|
run_privileged() {
|
|
if [ "$(id -u)" = 0 ]; then "$@";
|
|
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -n "$@";
|
|
else "$@";
|
|
fi
|
|
}
|
|
mkdir_cmd=mkdir
|
|
if ! mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null; then
|
|
run_privileged mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
|
fi
|
|
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
|
|
if command -v setfacl >/dev/null 2>&1; then
|
|
run_privileged setfacl -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
|
run_privileged setfacl -d -m "u:$(id -un):rwx,g:$share_group:rwx,m:rwx" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
|
fi
|
|
run_privileged chgrp "$share_group" "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups" 2>/dev/null || true
|
|
run_privileged chmod 2775 "$root" "$root/.forgeflow" "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
|
if [ -d "$root" ]; then
|
|
while IFS= read -r -d '' entry; do
|
|
case "$entry" in
|
|
"$root/.forgeflow"|"$root/.forgeflow"/*) continue ;;
|
|
esac
|
|
run_privileged chgrp "$share_group" "$entry" 2>/dev/null || true
|
|
if [ -d "$entry" ]; then run_privileged chmod u+rwx,g+rwx,g+s "$entry"; else run_privileged chmod u+rw,g+rw "$entry"; fi
|
|
done < <(find "$root" -mindepth 1 \\( ${pruneExpression} \\) -prune -o -print0)
|
|
fi
|
|
for compose_file in ${composePaths || ""}; do
|
|
[ -e "$compose_file" ] || continue
|
|
run_privileged chgrp "$share_group" "$compose_file" 2>/dev/null || true
|
|
run_privileged chmod u+rw,g+rw "$compose_file"
|
|
done
|
|
echo "ForgeFlow repaired project write access for $(id -un) and group $share_group without changing preserved runtime paths."
|
|
`;
|
|
}
|
|
|
|
async repairWriteAccess({ repository, profileId }) {
|
|
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
|
const before = await this.inspectWriteAccess({ repository, profileId });
|
|
await this.ssh.exec(server.id, bash(this.permissionRepairScript(profile, server, remotePath)), {
|
|
timeout: 5 * 60_000,
|
|
maxOutput: 4 * 1024 * 1024,
|
|
});
|
|
const after = await this.inspectWriteAccess({ repository, profileId });
|
|
if (!after.ready) {
|
|
const error = new Error(
|
|
`Write-access repair did not make every required path writable: ${after.blocking.map((item) => item.path).join(", ")}`,
|
|
);
|
|
error.code = "WRITE_ACCESS_REPAIR_INCOMPLETE";
|
|
error.permissionReport = after;
|
|
throw error;
|
|
}
|
|
await this.diagnostics?.info("unraid.write-access.repaired", {
|
|
repository: repository.fullName,
|
|
profileId,
|
|
serverId: server.id,
|
|
remotePath,
|
|
user: after.identity.user,
|
|
});
|
|
return { changed: true, normalized: true, before, after };
|
|
}
|
|
}
|
|
return UnraidAccessMethods.prototype;
|
|
}
|
|
|
|
module.exports = { createUnraidAccessMethods };
|