refactor: split renderer ipc and unraid domains

This commit is contained in:
NuklearRabbit committed 2026-07-29 18:11:07 +02:00
1 parent 5d3731a853
commit 7b05c953b6
36 files changed
+9316 -8330

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
"use strict";
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto }) {
class UnraidAccessMethods {
serverGitRemote(repository, profile) {
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
.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 failed = checks.some((item) => item.status === "fail");
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", 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 };