refactor: split renderer ipc and unraid domains
This commit is contained in:
+14
-744
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs/promises");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
@@ -9,12 +8,14 @@ const {
|
||||
cloneDirectoryName,
|
||||
resolveCloneTarget,
|
||||
} = require("../shared/clone-target.cjs");
|
||||
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
|
||||
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
|
||||
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
|
||||
const {
|
||||
createEncryptedBackup,
|
||||
readEncryptedBackup,
|
||||
} = require("./configuration-backup.cjs");
|
||||
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
|
||||
|
||||
let diagnosticsService = null;
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(
|
||||
__dirname,
|
||||
@@ -76,7 +77,6 @@ function register(channel, handler) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc({
|
||||
store,
|
||||
git,
|
||||
@@ -502,383 +502,11 @@ function registerIpc({
|
||||
}),
|
||||
);
|
||||
|
||||
register("repositories:refresh", async () => {
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repositories:discover", async ({ roots }) => {
|
||||
const paths = await repositories.discoverAll(
|
||||
roots || store.data.workspaceRoots,
|
||||
);
|
||||
return repositories.getLocalDescriptors(paths);
|
||||
});
|
||||
|
||||
register("repository:favorite", async ({ fullName, favorite }) =>
|
||||
store.setFavorite(fullName, favorite),
|
||||
);
|
||||
|
||||
register("repository:link", async ({ fullName, localPath }) => {
|
||||
await git.ensureRepository(localPath);
|
||||
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
|
||||
if (
|
||||
!remoteUrl ||
|
||||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
|
||||
) {
|
||||
throw new Error(
|
||||
`The selected folder's origin does not match ${fullName}.`,
|
||||
);
|
||||
}
|
||||
await store.saveMapping(fullName, localPath);
|
||||
await diagnostics.info("repository.linked", { fullName, localPath });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:unlink", async ({ fullName }) => {
|
||||
await store.removeMapping(fullName);
|
||||
await diagnostics.info("repository.unlinked", { fullName });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:status", async ({ localPath }) =>
|
||||
git.status(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:diff", async ({ localPath, filePath, staged }) =>
|
||||
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
|
||||
);
|
||||
register("repository:diff-hunks", async ({ localPath, filePath }) =>
|
||||
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
|
||||
);
|
||||
register(
|
||||
"repository:stage-hunks",
|
||||
async ({ localPath, filePath, hunkIndexes }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.stageHunks(safePath, filePath, hunkIndexes),
|
||||
);
|
||||
},
|
||||
);
|
||||
register("repository:conflicts", async ({ localPath }) =>
|
||||
git.conflictState(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register(
|
||||
"repository:resolve-conflict",
|
||||
async ({ localPath, filePath, resolution }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.resolveConflict(safePath, filePath, resolution),
|
||||
);
|
||||
await audit.append("git.conflict.resolved", {
|
||||
localPath: safePath,
|
||||
filePath,
|
||||
resolution,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:continue-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.continueInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.continued", { localPath: safePath });
|
||||
return result;
|
||||
});
|
||||
register("repository:abort-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.abortInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.aborted", {
|
||||
localPath: safePath,
|
||||
operation: result.aborted,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("repository:stage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
|
||||
});
|
||||
register("repository:unstage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
|
||||
});
|
||||
register("repository:commit", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commit(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStaged(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged-push", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStagedAndPush(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-push", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitAndPush(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:push", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.push(safePath));
|
||||
});
|
||||
register("repository:fetch", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.fetch(safePath));
|
||||
});
|
||||
register("repository:pull", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.pullFastForward(safePath),
|
||||
);
|
||||
});
|
||||
register("repository:history", async ({ localPath, limit }) =>
|
||||
git.history(await assertKnownRepositoryPath(localPath), limit),
|
||||
);
|
||||
register("repository:branch-protection", async ({ fullName, branch }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.getBranchProtection(
|
||||
repository.owner.login,
|
||||
repository.name,
|
||||
branch ||
|
||||
repository.localStatus?.branch?.head ||
|
||||
repository.defaultBranch,
|
||||
);
|
||||
});
|
||||
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.listPullRequests({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
state,
|
||||
});
|
||||
});
|
||||
register(
|
||||
"repository:create-pull-request",
|
||||
async ({ fullName, title, body, base }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
if (!repository.localPath || !repository.localStatus?.clean)
|
||||
throw new Error(
|
||||
"A clean linked repository is required before creating a pull request.",
|
||||
);
|
||||
const head = repository.localStatus.branch?.head;
|
||||
if (!head || !repository.localStatus.branch?.upstream)
|
||||
throw new Error(
|
||||
"Publish the current branch before creating a pull request.",
|
||||
);
|
||||
if (repository.localStatus.branch.ahead > 0)
|
||||
throw new Error(
|
||||
"Push all local commits before creating a pull request.",
|
||||
);
|
||||
const pullRequest = await gitea.createPullRequest({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
await audit.append("pull-request.created", {
|
||||
repository: repository.fullName,
|
||||
number: pullRequest.number,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
url: pullRequest.html_url,
|
||||
});
|
||||
return pullRequest;
|
||||
},
|
||||
);
|
||||
register("repository:branches", async ({ localPath }) =>
|
||||
git.branches(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:checkout-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.checkoutBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:create-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.createBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:stash", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
|
||||
});
|
||||
register("repository:stash-list", async ({ localPath }) =>
|
||||
git.stashList(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:stash-pop", async ({ localPath, ref }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
|
||||
});
|
||||
register("repository:index-lock", async ({ localPath }) =>
|
||||
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:git-recovery-status", async ({ localPath }) =>
|
||||
git.reconcile(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:repair-index-lock", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.removeStaleIndexLock(safePath),
|
||||
);
|
||||
});
|
||||
register(
|
||||
"repository:repair-git-locks",
|
||||
async ({ localPath, force = false }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.repairStaleGitLocks(safePath, {
|
||||
minimumAgeMs: force ? 0 : 10_000,
|
||||
allowWithoutProcessProbe: force === true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
register("repository:reconcile", async ({ localPath }) =>
|
||||
git.reconcile(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:repair-sync", async ({ localPath, strategy }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.repairSync(safePath, strategy),
|
||||
);
|
||||
});
|
||||
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.setRemoteUrl(safePath, remoteUrl),
|
||||
);
|
||||
});
|
||||
|
||||
register("repositories:normalize-origins", async () => {
|
||||
const current = await repositories.refresh();
|
||||
const changes = [];
|
||||
for (const repository of current) {
|
||||
if (!repository.localPath || !repository.sshUrl) continue;
|
||||
const actual = await git
|
||||
.getRemoteUrl(repository.localPath)
|
||||
.catch(() => "");
|
||||
if (actual === repository.sshUrl) continue;
|
||||
await withRepositoryMutation(repository.localPath, () =>
|
||||
git.setRemoteUrl(repository.localPath, repository.sshUrl),
|
||||
);
|
||||
changes.push({
|
||||
fullName: repository.fullName,
|
||||
previous: actual,
|
||||
next: repository.sshUrl,
|
||||
});
|
||||
}
|
||||
const refreshed = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info("repositories.origins.normalized", {
|
||||
count: changes.length,
|
||||
changes,
|
||||
});
|
||||
return { changes, repositories: refreshed };
|
||||
});
|
||||
|
||||
register("repository:clone", async ({ fullName, mode = "default" }) => {
|
||||
if (!["default", "custom"].includes(mode))
|
||||
throw new Error("Unsupported clone location mode.");
|
||||
|
||||
let projectRoot = store.data.workspaceRoots[0] || null;
|
||||
if (mode === "custom" || !projectRoot) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: `Choose a project root for ${String(fullName || "repository")}`,
|
||||
defaultPath: projectRoot || undefined,
|
||||
buttonLabel: "Use this project root",
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
|
||||
projectRoot = result.filePaths[0];
|
||||
}
|
||||
|
||||
return cloneRepositoryInto(fullName, projectRoot);
|
||||
});
|
||||
|
||||
register("repository:open-path", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const error = await shell.openPath(safePath);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"repository:open-editor",
|
||||
async ({ localPath, filePath = "", line = 1 }) =>
|
||||
externalTools.launch(
|
||||
"editor",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
filePath,
|
||||
line,
|
||||
),
|
||||
);
|
||||
register("repository:open-terminal", async ({ localPath }) =>
|
||||
externalTools.launch(
|
||||
"terminal",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
),
|
||||
);
|
||||
|
||||
register("external:open", async ({ url }) => {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new Error("Only HTTP and HTTPS links can be opened.");
|
||||
await shell.openExternal(parsed.toString());
|
||||
return true;
|
||||
});
|
||||
|
||||
register("git-validator:scan", async ({ fullName }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
await diagnostics.info("git-validator.scan.completed", {
|
||||
repository: repository.fullName,
|
||||
score: report.score,
|
||||
summary: report.summary,
|
||||
});
|
||||
return report;
|
||||
});
|
||||
register("git-validator:repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const allowed = new Set([
|
||||
"align-origin",
|
||||
"configure-local-safety",
|
||||
"add-gitignore",
|
||||
"add-gitattributes",
|
||||
"add-editorconfig",
|
||||
"protect-default-branch",
|
||||
]);
|
||||
if (!allowed.has(check?.fixAction))
|
||||
throw new Error("Unsupported Git Validator repair request.");
|
||||
const result = await gitValidator.repair(repository, check);
|
||||
await audit.append("git-validator.repair", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
await diagnostics.info("git-validator.repair.completed", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
return result;
|
||||
registerRepositoryIpc({
|
||||
register, repositories, store, git, gitea, monitor, diagnostics, audit,
|
||||
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
});
|
||||
|
||||
register("troubleshooter:scan", async ({ fullName = null }) => {
|
||||
@@ -1102,371 +730,13 @@ function registerIpc({
|
||||
return results;
|
||||
});
|
||||
|
||||
register("deployment:save-profile", async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
await diagnostics.info("deployment.profile.saved", {
|
||||
repository: fullName,
|
||||
profile: saved,
|
||||
});
|
||||
return { profile: saved, state: store.getPublicState() };
|
||||
registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
});
|
||||
register("deployment:delete-profile", async ({ fullName, profileId }) => {
|
||||
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
|
||||
await diagnostics.info("deployment.profile.deleted", {
|
||||
repository: fullName,
|
||||
profileId,
|
||||
});
|
||||
return { profiles, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:preflight", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.preflight({ repository: current, profileId });
|
||||
return preflight.runDeployment({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:repair-write-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider !== "ssh-unraid")
|
||||
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
|
||||
const result = await unraid.repairWriteAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.write-access.repaired", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
changed: result.changed,
|
||||
remotePath: result.after?.remotePath || result.before?.remotePath || null,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register(
|
||||
"deployment:dispatch",
|
||||
async ({
|
||||
repository,
|
||||
profileId,
|
||||
sha,
|
||||
note = "",
|
||||
override = false,
|
||||
overrideReason = "",
|
||||
}) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
const policy = evaluateDeploymentPolicy(profile, {
|
||||
note,
|
||||
override,
|
||||
reason: overrideReason,
|
||||
});
|
||||
await audit.append("deployment.requested", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
sha,
|
||||
note: policy.note,
|
||||
overridden: policy.overridden,
|
||||
overrideReason: policy.reason,
|
||||
});
|
||||
const operation =
|
||||
profile?.provider === "ssh-unraid"
|
||||
? await unraid.deploy({ repository: current, profileId, sha })
|
||||
: await deployments.deploy({ repository: current, profileId, sha });
|
||||
if (operation?.id)
|
||||
await store.addOperation({
|
||||
...operation,
|
||||
releaseNote: policy.note,
|
||||
policyOverride: policy.overridden
|
||||
? { reason: policy.reason, violations: policy.violations }
|
||||
: null,
|
||||
});
|
||||
return operation;
|
||||
},
|
||||
);
|
||||
register(
|
||||
"deployment:rollback",
|
||||
async ({ repository, profileId, targetSha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.rollback({ repository: current, profileId, targetSha });
|
||||
return deployments.rollback({
|
||||
repository: current,
|
||||
profileId,
|
||||
targetSha,
|
||||
});
|
||||
},
|
||||
);
|
||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.linkServerWorkload({
|
||||
repository: current,
|
||||
serverId,
|
||||
workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-configured", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
keyFingerprint: result.keyFingerprint,
|
||||
hostFingerprint: result.hostFingerprint,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-verified", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
readiness: result.readiness,
|
||||
ready: result.ready,
|
||||
checkedAt: result.checkedAt,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.inventory({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRotation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRevocation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.restore({ repository: current, profileId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
(repository) => repository.owner?.login !== "local",
|
||||
);
|
||||
const results = [];
|
||||
for (const server of store.data.servers || []) {
|
||||
try {
|
||||
results.push(
|
||||
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
|
||||
);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
linked: 0,
|
||||
unmatched: 0,
|
||||
needsReview: 0,
|
||||
capabilities: {},
|
||||
warnings: [],
|
||||
workloads: [],
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
|
||||
await audit.append("deployment.server-reconciliation-planned", {
|
||||
serverId,
|
||||
planId: result.plan.id,
|
||||
summary: result.plan.summary,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
|
||||
await audit.append("deployment.server-reconciliation-applied", {
|
||||
serverId,
|
||||
planId,
|
||||
adopted: result.adopted,
|
||||
refreshed: result.refreshed,
|
||||
retired: result.retired,
|
||||
recoverySnapshot: result.recoverySnapshot?.filePath || null,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
});
|
||||
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
|
||||
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid") {
|
||||
let giteaSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch {}
|
||||
return unraid.refreshProfileState(fullName, profileId, giteaSha);
|
||||
}
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
});
|
||||
register(
|
||||
"deployment:apply-dockerman-metadata",
|
||||
async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return unraid.applyDockerManMetadata({ repository: current, profileId });
|
||||
},
|
||||
);
|
||||
register("deployment:reconcile", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider !== "ssh-unraid")
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
const giteaSha =
|
||||
branch?.commit?.id ||
|
||||
branch?.commit?.sha ||
|
||||
branch?.commit?.commit?.id ||
|
||||
null;
|
||||
const state = await unraid.refreshProfileState(
|
||||
fullName,
|
||||
profileId,
|
||||
giteaSha,
|
||||
);
|
||||
const operations = store.data.operations.filter(
|
||||
(item) =>
|
||||
item.profileId === profileId &&
|
||||
item.provider === "ssh-unraid" &&
|
||||
!["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
item.status,
|
||||
),
|
||||
);
|
||||
for (const operation of operations)
|
||||
await unraid.refreshOperation(operation.id);
|
||||
return {
|
||||
state,
|
||||
operations: await unraid.reconcileRecordedOperations(profileId, state),
|
||||
};
|
||||
});
|
||||
register("operations:refresh", async ({ operationId }) => {
|
||||
if (operationId) {
|
||||
const operation = store.getOperation(operationId);
|
||||
if (operation?.provider === "ssh-unraid")
|
||||
return unraid.refreshOperation(operationId);
|
||||
return deployments.refreshOperation(operationId);
|
||||
}
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
deployments.refreshActiveOperations(),
|
||||
unraid.refreshActiveOperations(),
|
||||
]);
|
||||
return [...actions, ...sshOperations];
|
||||
});
|
||||
register("operations:get", ({ operationId }) =>
|
||||
store.getOperation(operationId),
|
||||
);
|
||||
|
||||
register("diagnostics:status", () => diagnostics.getStatus());
|
||||
register("diagnostics:clear", () => diagnostics.clear());
|
||||
register("diagnostics:open-folder", async () => {
|
||||
const error = await shell.openPath(diagnostics.logDirectory);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
|
||||
if (!["standard", "strict"].includes(privacyMode))
|
||||
throw new Error("Unsupported diagnostic privacy mode.");
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Export ForgeFlow diagnostic bundle",
|
||||
defaultPath: path.join(
|
||||
app.getPath("downloads"),
|
||||
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
|
||||
),
|
||||
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const repositoryState = await repositories.refresh().catch((error) => {
|
||||
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
|
||||
return [];
|
||||
});
|
||||
const systemPreflight = await preflight
|
||||
.runSystem()
|
||||
.catch((error) => ({ error: error.message }));
|
||||
const destinationPath =
|
||||
path.extname(result.filePath).toLowerCase() === ".zip"
|
||||
? result.filePath
|
||||
: `${result.filePath}.zip`;
|
||||
return diagnostics.exportSupportBundle({
|
||||
destinationPath,
|
||||
publicState: store.getPublicState(),
|
||||
repositories: repositoryState,
|
||||
operations: store.data.operations,
|
||||
preflight: systemPreflight,
|
||||
privacyMode,
|
||||
extra: {
|
||||
appVersion: app.getVersion(),
|
||||
setupComplete: store.data.setupComplete,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("diagnostics:show-bundle", async ({ filePath }) => {
|
||||
if (!diagnostics.isKnownBundlePath(filePath))
|
||||
throw new Error(
|
||||
"Only the most recently generated support bundle can be revealed.",
|
||||
);
|
||||
shell.showItemInFolder(filePath);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"renderer:report",
|
||||
async ({ level = "info", event = "renderer.event", details = {} }) => {
|
||||
const method = ["debug", "info", "warning", "error"].includes(level)
|
||||
? level
|
||||
: "info";
|
||||
await diagnostics[method](
|
||||
`renderer.${String(event || "event").slice(0, 120)}`,
|
||||
details,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
register("app:reset", async () => {
|
||||
await diagnostics.info("app.reset.requested", {});
|
||||
store.data = store.migrate({});
|
||||
store.sessionToken = null;
|
||||
await store.save();
|
||||
monitor?.setPaths([]);
|
||||
monitor?.restart();
|
||||
return store.getPublicState();
|
||||
registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
repositories, preflight, monitor,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use strict";
|
||||
|
||||
function registerDeploymentIpc({
|
||||
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
|
||||
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
|
||||
}) {
|
||||
register("deployment:save-profile", async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
await diagnostics.info("deployment.profile.saved", {
|
||||
repository: fullName,
|
||||
profile: saved,
|
||||
});
|
||||
return { profile: saved, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:delete-profile", async ({ fullName, profileId }) => {
|
||||
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
|
||||
await diagnostics.info("deployment.profile.deleted", {
|
||||
repository: fullName,
|
||||
profileId,
|
||||
});
|
||||
return { profiles, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:preflight", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.preflight({ repository: current, profileId });
|
||||
return preflight.runDeployment({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:repair-write-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider !== "ssh-unraid")
|
||||
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
|
||||
const result = await unraid.repairWriteAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.write-access.repaired", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
changed: result.changed,
|
||||
remotePath: result.after?.remotePath || result.before?.remotePath || null,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register(
|
||||
"deployment:dispatch",
|
||||
async ({
|
||||
repository,
|
||||
profileId,
|
||||
sha,
|
||||
note = "",
|
||||
override = false,
|
||||
overrideReason = "",
|
||||
}) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
const policy = evaluateDeploymentPolicy(profile, {
|
||||
note,
|
||||
override,
|
||||
reason: overrideReason,
|
||||
});
|
||||
await audit.append("deployment.requested", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
sha,
|
||||
note: policy.note,
|
||||
overridden: policy.overridden,
|
||||
overrideReason: policy.reason,
|
||||
});
|
||||
const operation =
|
||||
profile?.provider === "ssh-unraid"
|
||||
? await unraid.deploy({ repository: current, profileId, sha })
|
||||
: await deployments.deploy({ repository: current, profileId, sha });
|
||||
if (operation?.id)
|
||||
await store.addOperation({
|
||||
...operation,
|
||||
releaseNote: policy.note,
|
||||
policyOverride: policy.overridden
|
||||
? { reason: policy.reason, violations: policy.violations }
|
||||
: null,
|
||||
});
|
||||
return operation;
|
||||
},
|
||||
);
|
||||
register(
|
||||
"deployment:rollback",
|
||||
async ({ repository, profileId, targetSha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid")
|
||||
return unraid.rollback({ repository: current, profileId, targetSha });
|
||||
return deployments.rollback({
|
||||
repository: current,
|
||||
profileId,
|
||||
targetSha,
|
||||
});
|
||||
},
|
||||
);
|
||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.linkServerWorkload({
|
||||
repository: current,
|
||||
serverId,
|
||||
workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-configured", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
keyFingerprint: result.keyFingerprint,
|
||||
hostFingerprint: result.hostFingerprint,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
|
||||
await audit.append("deployment.server-git-access-verified", {
|
||||
repository: current.fullName,
|
||||
profileId,
|
||||
readiness: result.readiness,
|
||||
ready: result.ready,
|
||||
checkedAt: result.checkedAt,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.inventory({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRotation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRevocation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.restore({ repository: current, profileId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
(repository) => repository.owner?.login !== "local",
|
||||
);
|
||||
const results = [];
|
||||
for (const server of store.data.servers || []) {
|
||||
try {
|
||||
results.push(
|
||||
await unraid.discoverServerWorkloads(server.id, remoteRepositories),
|
||||
);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
linked: 0,
|
||||
unmatched: 0,
|
||||
needsReview: 0,
|
||||
capabilities: {},
|
||||
warnings: [],
|
||||
workloads: [],
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
|
||||
await audit.append("deployment.server-reconciliation-planned", {
|
||||
serverId,
|
||||
planId: result.plan.id,
|
||||
summary: result.plan.summary,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
|
||||
await audit.append("deployment.server-reconciliation-applied", {
|
||||
serverId,
|
||||
planId,
|
||||
adopted: result.adopted,
|
||||
refreshed: result.refreshed,
|
||||
retired: result.retired,
|
||||
recoverySnapshot: result.recoverySnapshot?.filePath || null,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before reviewing it."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
return inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
});
|
||||
register("deployment:apply-inventory-review", async ({ serverId, workloadId, action, reason = "", repositoryFullName = null, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const inventory = await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local"));
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw Object.assign(new Error("The workload changed or disappeared. Rescan before applying the review."), { code: "INVENTORY_REVIEW_WORKLOAD_STALE" });
|
||||
const plan = inventoryReviews.preview({ serverId, workload, action, reason, repositoryFullName });
|
||||
const result = await inventoryReviews.apply({ plan, expectedPlanId: planId });
|
||||
return { ...result, inventory: await unraid.scanServerInventory(serverId, repositoryList.filter((item) => item.owner?.login !== "local")), state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid") {
|
||||
let giteaSha = null;
|
||||
try {
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
|
||||
} catch {}
|
||||
return unraid.refreshProfileState(fullName, profileId, giteaSha);
|
||||
}
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
});
|
||||
register(
|
||||
"deployment:apply-dockerman-metadata",
|
||||
async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return unraid.applyDockerManMetadata({ repository: current, profileId });
|
||||
},
|
||||
);
|
||||
register("deployment:reconcile", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider !== "ssh-unraid")
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
const [owner, repo] = String(fullName || "").split("/");
|
||||
const branch = await gitea.getBranch(owner, repo, profile.branch);
|
||||
const giteaSha =
|
||||
branch?.commit?.id ||
|
||||
branch?.commit?.sha ||
|
||||
branch?.commit?.commit?.id ||
|
||||
null;
|
||||
const state = await unraid.refreshProfileState(
|
||||
fullName,
|
||||
profileId,
|
||||
giteaSha,
|
||||
);
|
||||
const operations = store.data.operations.filter(
|
||||
(item) =>
|
||||
item.profileId === profileId &&
|
||||
item.provider === "ssh-unraid" &&
|
||||
!["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
item.status,
|
||||
),
|
||||
);
|
||||
for (const operation of operations)
|
||||
await unraid.refreshOperation(operation.id);
|
||||
return {
|
||||
state,
|
||||
operations: await unraid.reconcileRecordedOperations(profileId, state),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDeploymentIpc };
|
||||
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
|
||||
function registerOperationsIpc({
|
||||
register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
|
||||
repositories, preflight, monitor,
|
||||
}) {
|
||||
register("operations:refresh", async ({ operationId }) => {
|
||||
if (operationId) {
|
||||
const operation = store.getOperation(operationId);
|
||||
if (operation?.provider === "ssh-unraid")
|
||||
return unraid.refreshOperation(operationId);
|
||||
return deployments.refreshOperation(operationId);
|
||||
}
|
||||
const [actions, sshOperations] = await Promise.all([
|
||||
deployments.refreshActiveOperations(),
|
||||
unraid.refreshActiveOperations(),
|
||||
]);
|
||||
return [...actions, ...sshOperations];
|
||||
});
|
||||
register("operations:get", ({ operationId }) =>
|
||||
store.getOperation(operationId),
|
||||
);
|
||||
|
||||
register("diagnostics:status", () => diagnostics.getStatus());
|
||||
register("diagnostics:clear", () => diagnostics.clear());
|
||||
register("diagnostics:open-folder", async () => {
|
||||
const error = await shell.openPath(diagnostics.logDirectory);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register("diagnostics:export", async ({ privacyMode = "standard" }) => {
|
||||
if (!["standard", "strict"].includes(privacyMode))
|
||||
throw new Error("Unsupported diagnostic privacy mode.");
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Export ForgeFlow diagnostic bundle",
|
||||
defaultPath: path.join(
|
||||
app.getPath("downloads"),
|
||||
`ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`,
|
||||
),
|
||||
filters: [{ name: "ZIP archive", extensions: ["zip"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const repositoryState = await repositories.refresh().catch((error) => {
|
||||
diagnostics.warning("diagnostics.repository-snapshot.failed", error);
|
||||
return [];
|
||||
});
|
||||
const systemPreflight = await preflight
|
||||
.runSystem()
|
||||
.catch((error) => ({ error: error.message }));
|
||||
const destinationPath =
|
||||
path.extname(result.filePath).toLowerCase() === ".zip"
|
||||
? result.filePath
|
||||
: `${result.filePath}.zip`;
|
||||
return diagnostics.exportSupportBundle({
|
||||
destinationPath,
|
||||
publicState: store.getPublicState(),
|
||||
repositories: repositoryState,
|
||||
operations: store.data.operations,
|
||||
preflight: systemPreflight,
|
||||
privacyMode,
|
||||
extra: {
|
||||
appVersion: app.getVersion(),
|
||||
setupComplete: store.data.setupComplete,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("diagnostics:show-bundle", async ({ filePath }) => {
|
||||
if (!diagnostics.isKnownBundlePath(filePath))
|
||||
throw new Error(
|
||||
"Only the most recently generated support bundle can be revealed.",
|
||||
);
|
||||
shell.showItemInFolder(filePath);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"renderer:report",
|
||||
async ({ level = "info", event = "renderer.event", details = {} }) => {
|
||||
const method = ["debug", "info", "warning", "error"].includes(level)
|
||||
? level
|
||||
: "info";
|
||||
await diagnostics[method](
|
||||
`renderer.${String(event || "event").slice(0, 120)}`,
|
||||
details,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
register("app:reset", async () => {
|
||||
await diagnostics.info("app.reset.requested", {});
|
||||
store.data = store.migrate({});
|
||||
store.sessionToken = null;
|
||||
await store.save();
|
||||
monitor?.setPaths([]);
|
||||
monitor?.restart();
|
||||
return store.getPublicState();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerOperationsIpc };
|
||||
@@ -0,0 +1,389 @@
|
||||
"use strict";
|
||||
|
||||
function registerRepositoryIpc({
|
||||
register, repositories, store, git, gitea, monitor, diagnostics, audit,
|
||||
externalTools, gitValidator, withRepositoryMutation, assertKnownRepositoryPath,
|
||||
resolveRepository, cloneRepositoryInto, cloneDirectoryName,
|
||||
matchRemoteToRepository, shell, dialog,
|
||||
}) {
|
||||
register("repositories:refresh", async () => {
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repositories:discover", async ({ roots }) => {
|
||||
const paths = await repositories.discoverAll(
|
||||
roots || store.data.workspaceRoots,
|
||||
);
|
||||
return repositories.getLocalDescriptors(paths);
|
||||
});
|
||||
|
||||
register("repository:favorite", async ({ fullName, favorite }) =>
|
||||
store.setFavorite(fullName, favorite),
|
||||
);
|
||||
|
||||
register("repository:link", async ({ fullName, localPath }) => {
|
||||
await git.ensureRepository(localPath);
|
||||
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => "");
|
||||
if (
|
||||
!remoteUrl ||
|
||||
!matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])
|
||||
) {
|
||||
throw new Error(
|
||||
`The selected folder's origin does not match ${fullName}.`,
|
||||
);
|
||||
}
|
||||
await store.saveMapping(fullName, localPath);
|
||||
await diagnostics.info("repository.linked", { fullName, localPath });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:unlink", async ({ fullName }) => {
|
||||
await store.removeMapping(fullName);
|
||||
await diagnostics.info("repository.unlinked", { fullName });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register("repository:status", async ({ localPath }) =>
|
||||
git.status(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:diff", async ({ localPath, filePath, staged }) =>
|
||||
git.diff(await assertKnownRepositoryPath(localPath), filePath, staged),
|
||||
);
|
||||
register("repository:diff-hunks", async ({ localPath, filePath }) =>
|
||||
git.diffHunks(await assertKnownRepositoryPath(localPath), filePath),
|
||||
);
|
||||
register(
|
||||
"repository:stage-hunks",
|
||||
async ({ localPath, filePath, hunkIndexes }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.stageHunks(safePath, filePath, hunkIndexes),
|
||||
);
|
||||
},
|
||||
);
|
||||
register("repository:conflicts", async ({ localPath }) =>
|
||||
git.conflictState(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register(
|
||||
"repository:resolve-conflict",
|
||||
async ({ localPath, filePath, resolution }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.resolveConflict(safePath, filePath, resolution),
|
||||
);
|
||||
await audit.append("git.conflict.resolved", {
|
||||
localPath: safePath,
|
||||
filePath,
|
||||
resolution,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:continue-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.continueInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.continued", { localPath: safePath });
|
||||
return result;
|
||||
});
|
||||
register("repository:abort-operation", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.abortInterruptedOperation(safePath),
|
||||
);
|
||||
await audit.append("git.operation.aborted", {
|
||||
localPath: safePath,
|
||||
operation: result.aborted,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("repository:stage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stage(safePath, files));
|
||||
});
|
||||
register("repository:unstage", async ({ localPath, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.unstage(safePath, files));
|
||||
});
|
||||
register("repository:commit", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commit(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStaged(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-staged-push", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitStagedAndPush(safePath, message),
|
||||
);
|
||||
});
|
||||
register("repository:commit-push", async ({ localPath, message, files }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.commitAndPush(safePath, message, files),
|
||||
);
|
||||
});
|
||||
register("repository:push", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.push(safePath));
|
||||
});
|
||||
register("repository:fetch", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.fetch(safePath));
|
||||
});
|
||||
register("repository:pull", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.pullFastForward(safePath),
|
||||
);
|
||||
});
|
||||
register("repository:history", async ({ localPath, limit }) =>
|
||||
git.history(await assertKnownRepositoryPath(localPath), limit),
|
||||
);
|
||||
register("repository:branch-protection", async ({ fullName, branch }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.getBranchProtection(
|
||||
repository.owner.login,
|
||||
repository.name,
|
||||
branch ||
|
||||
repository.localStatus?.branch?.head ||
|
||||
repository.defaultBranch,
|
||||
);
|
||||
});
|
||||
register("repository:pull-requests", async ({ fullName, state = "open" }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
return gitea.listPullRequests({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
state,
|
||||
});
|
||||
});
|
||||
register(
|
||||
"repository:create-pull-request",
|
||||
async ({ fullName, title, body, base }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
if (!repository.localPath || !repository.localStatus?.clean)
|
||||
throw new Error(
|
||||
"A clean linked repository is required before creating a pull request.",
|
||||
);
|
||||
const head = repository.localStatus.branch?.head;
|
||||
if (!head || !repository.localStatus.branch?.upstream)
|
||||
throw new Error(
|
||||
"Publish the current branch before creating a pull request.",
|
||||
);
|
||||
if (repository.localStatus.branch.ahead > 0)
|
||||
throw new Error(
|
||||
"Push all local commits before creating a pull request.",
|
||||
);
|
||||
const pullRequest = await gitea.createPullRequest({
|
||||
owner: repository.owner.login,
|
||||
repo: repository.name,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
await audit.append("pull-request.created", {
|
||||
repository: repository.fullName,
|
||||
number: pullRequest.number,
|
||||
head,
|
||||
base: base || repository.defaultBranch,
|
||||
url: pullRequest.html_url,
|
||||
});
|
||||
return pullRequest;
|
||||
},
|
||||
);
|
||||
register("repository:branches", async ({ localPath }) =>
|
||||
git.branches(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:checkout-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.checkoutBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:create-branch", async ({ localPath, branch }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.createBranch(safePath, branch),
|
||||
);
|
||||
});
|
||||
register("repository:stash", async ({ localPath, message }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.stash(safePath, message));
|
||||
});
|
||||
register("repository:stash-list", async ({ localPath }) =>
|
||||
git.stashList(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:stash-pop", async ({ localPath, ref }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () => git.popStash(safePath, ref));
|
||||
});
|
||||
register("repository:index-lock", async ({ localPath }) =>
|
||||
git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:git-recovery-status", async ({ localPath }) =>
|
||||
git.reconcile(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:repair-index-lock", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.removeStaleIndexLock(safePath),
|
||||
);
|
||||
});
|
||||
register(
|
||||
"repository:repair-git-locks",
|
||||
async ({ localPath, force = false }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.repairStaleGitLocks(safePath, {
|
||||
minimumAgeMs: force ? 0 : 10_000,
|
||||
allowWithoutProcessProbe: force === true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
register("repository:reconcile", async ({ localPath }) =>
|
||||
git.reconcile(await assertKnownRepositoryPath(localPath)),
|
||||
);
|
||||
register("repository:repair-sync", async ({ localPath, strategy }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.repairSync(safePath, strategy),
|
||||
);
|
||||
});
|
||||
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
git.setRemoteUrl(safePath, remoteUrl),
|
||||
);
|
||||
});
|
||||
|
||||
register("repositories:normalize-origins", async () => {
|
||||
const current = await repositories.refresh();
|
||||
const changes = [];
|
||||
for (const repository of current) {
|
||||
if (!repository.localPath || !repository.sshUrl) continue;
|
||||
const actual = await git
|
||||
.getRemoteUrl(repository.localPath)
|
||||
.catch(() => "");
|
||||
if (actual === repository.sshUrl) continue;
|
||||
await withRepositoryMutation(repository.localPath, () =>
|
||||
git.setRemoteUrl(repository.localPath, repository.sshUrl),
|
||||
);
|
||||
changes.push({
|
||||
fullName: repository.fullName,
|
||||
previous: actual,
|
||||
next: repository.sshUrl,
|
||||
});
|
||||
}
|
||||
const refreshed = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info("repositories.origins.normalized", {
|
||||
count: changes.length,
|
||||
changes,
|
||||
});
|
||||
return { changes, repositories: refreshed };
|
||||
});
|
||||
|
||||
register("repository:clone", async ({ fullName, mode = "default" }) => {
|
||||
if (!["default", "custom"].includes(mode))
|
||||
throw new Error("Unsupported clone location mode.");
|
||||
|
||||
let projectRoot = store.data.workspaceRoots[0] || null;
|
||||
if (mode === "custom" || !projectRoot) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: `Choose a project root for ${String(fullName || "repository")}`,
|
||||
defaultPath: projectRoot || undefined,
|
||||
buttonLabel: "Use this project root",
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
|
||||
projectRoot = result.filePaths[0];
|
||||
}
|
||||
|
||||
return cloneRepositoryInto(fullName, projectRoot);
|
||||
});
|
||||
|
||||
register("repository:open-path", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const error = await shell.openPath(safePath);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register(
|
||||
"repository:open-editor",
|
||||
async ({ localPath, filePath = "", line = 1 }) =>
|
||||
externalTools.launch(
|
||||
"editor",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
filePath,
|
||||
line,
|
||||
),
|
||||
);
|
||||
register("repository:open-terminal", async ({ localPath }) =>
|
||||
externalTools.launch(
|
||||
"terminal",
|
||||
await assertKnownRepositoryPath(localPath),
|
||||
),
|
||||
);
|
||||
|
||||
register("external:open", async ({ url }) => {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol))
|
||||
throw new Error("Only HTTP and HTTPS links can be opened.");
|
||||
await shell.openExternal(parsed.toString());
|
||||
return true;
|
||||
});
|
||||
|
||||
register("git-validator:scan", async ({ fullName }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const report = await gitValidator.scan(repository);
|
||||
await diagnostics.info("git-validator.scan.completed", {
|
||||
repository: repository.fullName,
|
||||
score: report.score,
|
||||
summary: report.summary,
|
||||
});
|
||||
return report;
|
||||
});
|
||||
register("git-validator:repair", async ({ fullName, check }) => {
|
||||
const repository = await resolveRepository({ fullName });
|
||||
const allowed = new Set([
|
||||
"align-origin",
|
||||
"configure-local-safety",
|
||||
"add-gitignore",
|
||||
"add-gitattributes",
|
||||
"add-editorconfig",
|
||||
"protect-default-branch",
|
||||
]);
|
||||
if (!allowed.has(check?.fixAction))
|
||||
throw new Error("Unsupported Git Validator repair request.");
|
||||
const result = await gitValidator.repair(repository, check);
|
||||
await audit.append("git-validator.repair", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
await diagnostics.info("git-validator.repair.completed", {
|
||||
repository: repository.fullName,
|
||||
checkId: check.id,
|
||||
action: check.fixAction,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerRepositoryIpc };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,576 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidDeploymentMethods({
|
||||
path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs,
|
||||
}) {
|
||||
class UnraidDeploymentMethods {
|
||||
pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest, metadata, generated, iconReference, rollback = false }) {
|
||||
const compose = this.composeInvocation(profile, repository);
|
||||
const project = String(
|
||||
profile.composeProject || this.internalSlug(profile, repository),
|
||||
).trim();
|
||||
const candidateFiles = [...this.deploymentComposeFiles(profile)];
|
||||
if (profile.generatedCompose) candidateFiles.push(".forgeflow/compose.metadata.yml");
|
||||
const candidateCompose = `forgeflow_compose -p ${shellQuote(project)} ${candidateFiles
|
||||
.map((file) => `-f "$release"/${shellQuote(file)}`)
|
||||
.join(" ")}`;
|
||||
const preservePayload = Buffer.from(
|
||||
[".forgeflow", ".git", ...(profile.preservePaths || [])].join("\n"),
|
||||
"utf8",
|
||||
).toString("base64");
|
||||
const statusJson = this.deploymentStatusDocument({
|
||||
repository,
|
||||
profile,
|
||||
targetSha,
|
||||
requestId,
|
||||
rollback,
|
||||
});
|
||||
const verification = this.containerVerificationScript(
|
||||
profile,
|
||||
repository,
|
||||
compose,
|
||||
{ requireRecreated: true },
|
||||
);
|
||||
const containerHint = String(
|
||||
profile.containerName || profile.remoteFolder || repository.name || "",
|
||||
).trim();
|
||||
return `
|
||||
root=${shellQuote(remotePath)}
|
||||
expected_project=${shellQuote(project)}
|
||||
tracked_container_hint=${shellQuote(containerHint)}
|
||||
target=${shellQuote(targetSha)}
|
||||
request_id=${shellQuote(requestId)}
|
||||
incoming=${shellQuote(remotePart)}
|
||||
expected_digest=${shellQuote(digest)}
|
||||
release_root="$root/.forgeflow/releases/$target"
|
||||
release="$release_root/source"
|
||||
staging="$root/.forgeflow/staging/$request_id"
|
||||
backup="$root/.forgeflow/backups/$request_id"
|
||||
lock="$root/.forgeflow/deploy.lock"
|
||||
mkdir -p "$root/.forgeflow/incoming" "$root/.forgeflow/releases" "$root/.forgeflow/staging" "$root/.forgeflow/backups"
|
||||
if [ -d "$lock" ] && find "$lock" -maxdepth 0 -mmin +120 -print -quit | grep -q .; then
|
||||
lock_pid=$(cat "$lock/pid" 2>/dev/null || true)
|
||||
if [ -z "$lock_pid" ] || ! kill -0 "$lock_pid" 2>/dev/null; then rm -rf "$lock"; fi
|
||||
fi
|
||||
mkdir "$lock" 2>/dev/null || { echo "Another ForgeFlow deployment is active for $root" >&2; exit 70; }
|
||||
printf '%s\n' "$request_id" > "$lock/request-id"
|
||||
printf '%s\n' "$$" > "$lock/pid"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$lock/started-at"
|
||||
restore_needed=false
|
||||
activation_started=false
|
||||
is_preserved() {
|
||||
rel="$1"
|
||||
while IFS= read -r keep; do
|
||||
[ -n "$keep" ] || continue
|
||||
if [ "$rel" = "$keep" ] || [[ "$rel" == "$keep/"* ]]; then return 0; fi
|
||||
done < "$staging.preserve"
|
||||
return 1
|
||||
}
|
||||
restore_files() {
|
||||
if [ -f "$backup/present" ]; then
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
mkdir -p -- "$root/$(dirname "$rel")"
|
||||
temp="$root/$rel.forgeflow-restore-$request_id"
|
||||
cp -a -- "$backup/source/$rel" "$temp" && mv -f -- "$temp" "$root/$rel"
|
||||
done < "$backup/present"
|
||||
fi
|
||||
if [ -f "$backup/absent" ]; then
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
case "$rel" in .forgeflow/*) continue ;; esac
|
||||
[ -e "$root/$rel" ] || [ -L "$root/$rel" ] || continue
|
||||
rm -f -- "$root/$rel"
|
||||
done < "$backup/absent"
|
||||
fi
|
||||
if [ -f "$backup/generated.present" ]; then
|
||||
cp -a "$backup/compose.forgeflow.yml" "$root/.forgeflow/compose.forgeflow.yml"
|
||||
elif [ -f "$backup/generated.created" ]; then
|
||||
rm -f "$root/.forgeflow/compose.forgeflow.yml"
|
||||
fi
|
||||
if [ -f "$backup/metadata.present" ]; then
|
||||
cp -a "$backup/compose.metadata.yml" "$root/.forgeflow/compose.metadata.yml"
|
||||
elif [ -f "$backup/metadata.created" ]; then
|
||||
rm -f "$root/.forgeflow/compose.metadata.yml"
|
||||
fi
|
||||
}
|
||||
restore_images() {
|
||||
[ -f "$backup/containers.before" ] || return 0
|
||||
while IFS=$'\t' read -r service container_id image_id image_ref_b64; do
|
||||
[ -n "$image_id" ] || continue
|
||||
docker image inspect "$image_id" >/dev/null 2>&1 || continue
|
||||
image_ref=$(printf '%s' "$image_ref_b64" | base64 -d 2>/dev/null || true)
|
||||
case "$image_ref" in ''|sha256:*|*@sha256:*) continue ;; esac
|
||||
docker image tag "$image_id" "$image_ref" >/dev/null 2>&1 || true
|
||||
done < "$backup/containers.before"
|
||||
}
|
||||
restore_runtime() {
|
||||
[ "$activation_started" = true ] || return 0
|
||||
restore_images
|
||||
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
|
||||
${compose} up -d --no-build >/dev/null 2>&1 || return 1
|
||||
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
printf '%s\n' "$old_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
old_id=$(${compose} ps -q "$service" | head -n1)
|
||||
[ -n "$old_id" ] || exit 1
|
||||
[ "$(docker inspect -f '{{.State.Running}}' "$old_id" 2>/dev/null || echo false)" = true ] || exit 1
|
||||
done
|
||||
fi
|
||||
}
|
||||
finish() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
if [ "$status" -ne 0 ] && [ "$restore_needed" = true ]; then
|
||||
restore_files
|
||||
if ! restore_runtime; then
|
||||
echo "CRITICAL: source files were restored, but the previous Compose runtime could not be restarted automatically. Backup: $backup" >&2
|
||||
else
|
||||
echo "ForgeFlow restored the previous source and runtime after the failed activation." >&2
|
||||
fi
|
||||
fi
|
||||
rm -rf "$staging" "$lock"
|
||||
exit "$status"
|
||||
}
|
||||
trap finish EXIT
|
||||
actual_digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
|
||||
[ "$actual_digest" = "$expected_digest" ] || { echo "Uploaded bundle checksum mismatch" >&2; exit 71; }
|
||||
tar -tf "$incoming" > "$staging.entries"
|
||||
if grep -E '(^/|(^|/)\\.\\.(/|$))' "$staging.entries" >/dev/null; then echo "Unsafe path detected in deployment bundle" >&2; exit 72; fi
|
||||
rm -rf "$staging" "$release_root.pending"
|
||||
mkdir -p "$staging/source" "$release_root.pending"
|
||||
tar -xf "$incoming" -C "$staging/source"
|
||||
if find "$staging/source" -type l -print -quit | grep -q .; then echo "Symbolic links are not accepted in push bundles" >&2; exit 73; fi
|
||||
mv "$staging/source" "$release_root.pending/source"
|
||||
find "$release_root.pending/source" -type f -printf '%P\n' | LC_ALL=C sort > "$release_root.pending/managed-files"
|
||||
rm -rf "$release_root"
|
||||
mv "$release_root.pending" "$release_root"
|
||||
rm -f "$incoming" "$staging.entries"
|
||||
printf '%s' ${shellQuote(preservePayload)} | base64 -d > "$staging.preserve"
|
||||
for runtime_config in .env compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
|
||||
if [ -f "$root/$runtime_config" ] && [ ! -e "$release/$runtime_config" ]; then
|
||||
mkdir -p "$release/$(dirname "$runtime_config")"
|
||||
cp -a "$root/$runtime_config" "$release/$runtime_config"
|
||||
fi
|
||||
done
|
||||
${profile.generatedCompose ? `mkdir -p "$release/.forgeflow"
|
||||
cat > "$release/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'
|
||||
${generated}FORGEFLOW_COMPOSE
|
||||
cat > "$release/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA` : ""}
|
||||
cd "$release"
|
||||
${candidateCompose} config >/dev/null
|
||||
candidate_services=$(${candidateCompose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$candidate_services" ] || { echo "Candidate Compose project defines no services" >&2; exit 60; }
|
||||
mkdir -p "$backup/source"
|
||||
: > "$backup/present"
|
||||
: > "$backup/absent"
|
||||
: > "$backup/containers.before"
|
||||
had_existing_compose=false
|
||||
if cd "$root" 2>/dev/null && ${compose} config >/dev/null 2>&1; then
|
||||
had_existing_compose=true
|
||||
old_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
printf '%s\n' "$old_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
container_id=$(${compose} ps -q "$service" | head -n1)
|
||||
image_id=''; image_ref=''
|
||||
if [ -n "$container_id" ] && docker inspect "$container_id" >/dev/null 2>&1; then
|
||||
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
|
||||
image_ref=$(docker inspect -f '{{.Config.Image}}' "$container_id" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\n' "$service" "$container_id" "$image_id" "$(printf '%s' "$image_ref" | base64 | tr -d '\r\n')"
|
||||
done >> "$backup/containers.before"
|
||||
fi
|
||||
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
|
||||
hint_project=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$tracked_container_hint" 2>/dev/null || true)
|
||||
if [ -n "$hint_project" ] && [ "$hint_project" != "$expected_project" ]; then
|
||||
echo "Refusing activation: container $tracked_container_hint belongs to Compose project $hint_project, not $expected_project" >&2
|
||||
exit 67
|
||||
fi
|
||||
fi
|
||||
# Build all candidate images before any running container is touched.
|
||||
cd "$release"
|
||||
${candidateCompose} build
|
||||
new_manifest="$release_root/managed-files"
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
parent=$(dirname "$rel")
|
||||
current="$root"
|
||||
if [ "$parent" != . ]; then
|
||||
old_ifs=$IFS; IFS='/'; read -r -a parts <<< "$parent"; IFS=$old_ifs
|
||||
for part in "\${parts[@]}"; do
|
||||
current="$current/$part"
|
||||
[ ! -L "$current" ] || { echo "Refusing to deploy through symlinked parent $current" >&2; exit 74; }
|
||||
done
|
||||
fi
|
||||
[ ! -L "$root/$rel" ] || { echo "Refusing to replace symlinked managed path $rel" >&2; exit 74; }
|
||||
if [ -d "$root/$rel" ]; then echo "A directory conflicts with managed file $rel" >&2; exit 75; fi
|
||||
if [ -e "$root/$rel" ]; then
|
||||
mkdir -p "$backup/source/$(dirname "$rel")"
|
||||
cp -a -- "$root/$rel" "$backup/source/$rel"
|
||||
printf '%s\n' "$rel" >> "$backup/present"
|
||||
else
|
||||
printf '%s\n' "$rel" >> "$backup/absent"
|
||||
fi
|
||||
done < "$new_manifest"
|
||||
[ -f "$root/.forgeflow/compose.metadata.yml" ] && { cp -a "$root/.forgeflow/compose.metadata.yml" "$backup/compose.metadata.yml"; touch "$backup/metadata.present"; }
|
||||
[ -f "$root/.forgeflow/compose.forgeflow.yml" ] && { cp -a "$root/.forgeflow/compose.forgeflow.yml" "$backup/compose.forgeflow.yml"; touch "$backup/generated.present"; }
|
||||
restore_needed=true
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
mkdir -p -- "$root/$(dirname "$rel")"
|
||||
temp="$root/$rel.forgeflow-new-$request_id"
|
||||
cp -a -- "$release/$rel" "$temp"
|
||||
mv -f -- "$temp" "$root/$rel"
|
||||
done < "$new_manifest"
|
||||
mkdir -p "$root/.forgeflow"
|
||||
${profile.generatedCompose ? `if [ ! -f "$backup/generated.present" ]; then touch "$backup/generated.created"; fi
|
||||
if [ ! -f "$backup/metadata.present" ]; then touch "$backup/metadata.created"; fi
|
||||
cat > "$root/.forgeflow/compose.forgeflow.yml.pending" <<'FORGEFLOW_COMPOSE'
|
||||
${generated}FORGEFLOW_COMPOSE
|
||||
mv "$root/.forgeflow/compose.forgeflow.yml.pending" "$root/.forgeflow/compose.forgeflow.yml"
|
||||
cat > "$root/.forgeflow/compose.metadata.yml.pending" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA
|
||||
mv "$root/.forgeflow/compose.metadata.yml.pending" "$root/.forgeflow/compose.metadata.yml"` : `cat > "$root/.forgeflow/deployment-metadata.json.pending" <<'FORGEFLOW_METADATA_JSON'
|
||||
${JSON.stringify({ repository: repository.fullName, environment: profile.environment, commit: targetSha, requestId })}
|
||||
FORGEFLOW_METADATA_JSON
|
||||
mv "$root/.forgeflow/deployment-metadata.json.pending" "$root/.forgeflow/deployment-metadata.json"`}
|
||||
share_group=$(getent group users >/dev/null 2>&1 && echo users || id -gn)
|
||||
chgrp "$share_group" "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
chmod g+rwx "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
chmod g+s "$root" "$root/.forgeflow" 2>/dev/null || true
|
||||
while IFS= read -r rel; do
|
||||
[ -n "$rel" ] || continue
|
||||
is_preserved "$rel" && continue
|
||||
chgrp "$share_group" "$root/$rel" 2>/dev/null || true
|
||||
chmod u+rw,g+rw "$root/$rel" 2>/dev/null || true
|
||||
parent="$root/$(dirname "$rel")"
|
||||
chgrp "$share_group" "$parent" 2>/dev/null || true
|
||||
chmod g+rwx,g+s "$parent" 2>/dev/null || true
|
||||
done < "$new_manifest"
|
||||
cd "$root"
|
||||
${compose} config >/dev/null
|
||||
actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
|
||||
if [ "$(printf '%s\n' "$candidate_services" | LC_ALL=C sort)" != "$(printf '%s\n' "$actual_services" | LC_ALL=C sort)" ]; then
|
||||
echo "Refusing activation because candidate and server Compose service sets differ" >&2
|
||||
exit 68
|
||||
fi
|
||||
before_containers="$backup/containers.before"
|
||||
hint_before_id=''
|
||||
if [ -n "$tracked_container_hint" ] && docker inspect "$tracked_container_hint" >/dev/null 2>&1; then
|
||||
hint_before_id=$(docker inspect -f '{{.Id}}' "$tracked_container_hint" 2>/dev/null || true)
|
||||
fi
|
||||
activation_started=true
|
||||
${compose} up -d --no-build
|
||||
${verification}
|
||||
if [ -n "$hint_before_id" ] && docker inspect "$hint_before_id" >/dev/null 2>&1; then
|
||||
old_hint_running=$(docker inspect -f '{{.State.Running}}' "$hint_before_id" 2>/dev/null || echo false)
|
||||
[ "$old_hint_running" != true ] || { echo "Compose left the previous container $tracked_container_hint ($hint_before_id) running" >&2; exit 66; }
|
||||
fi
|
||||
${this.dockerManRefreshScript(profile, repository, iconReference)}
|
||||
previous=$(cat "$root/.forgeflow/current-sha" 2>/dev/null || true)
|
||||
[ -n "$previous" ] || previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
[ -n "$previous" ] && printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
|
||||
cp "$new_manifest" "$root/.forgeflow/managed-files.pending"
|
||||
mv "$root/.forgeflow/managed-files.pending" "$root/.forgeflow/managed-files"
|
||||
printf '%s' "$target" > "$root/.forgeflow/current-sha.pending"
|
||||
mv "$root/.forgeflow/current-sha.pending" "$root/.forgeflow/current-sha"
|
||||
cat > "$root/.forgeflow/status.json.pending" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
mv "$root/.forgeflow/status.json.pending" "$root/.forgeflow/status.json"
|
||||
restore_needed=false
|
||||
printf '%s\n' "successful" > "$backup/result"
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ > "$backup/completed-at"
|
||||
echo "ForgeFlow safely activated push bundle $target; rollback evidence retained at $backup"
|
||||
`;
|
||||
}
|
||||
|
||||
async executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
|
||||
const permissionReport = await this.inspectWriteAccess({
|
||||
repository,
|
||||
profileId: profile.id,
|
||||
});
|
||||
if (!permissionReport.ready) {
|
||||
const error = new Error(
|
||||
`Deployment stopped before upload because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`,
|
||||
);
|
||||
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
|
||||
error.permissionReport = permissionReport;
|
||||
throw error;
|
||||
}
|
||||
const bundle = await this.createCommitBundle(repository, targetSha, requestId);
|
||||
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
|
||||
try {
|
||||
await this.ssh.exec(server.id, bash(`mkdir -p ${shellQuote(path.dirname(remotePart))}`), { timeout: 30_000 });
|
||||
await this.ssh.uploadFile(server.id, bundle.archivePath, remotePart, { mode: 0o600 });
|
||||
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart, digest: bundle.sha256, metadata, generated, iconReference, rollback });
|
||||
return await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
|
||||
} finally {
|
||||
await fs.rm(bundle.archivePath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId }) {
|
||||
const remote = this.serverGitRemote(repository, profile);
|
||||
const repositoryId = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const cache = path.join(server.basePath, ".forgeflow", "git-cache", `${repositoryId}.git`);
|
||||
const remotePart = path.join(remotePath, ".forgeflow", "incoming", `${requestId}-${targetSha}.tar.part`);
|
||||
const marker = "__FORGEFLOW_SERVER_ARCHIVE__";
|
||||
const script = `
|
||||
cache=${shellQuote(cache)}
|
||||
incoming=${shellQuote(remotePart)}
|
||||
remote=${shellQuote(remote)}
|
||||
branch=${shellQuote(profile.branch)}
|
||||
target=${shellQuote(targetSha)}
|
||||
mkdir -p "$(dirname "$cache")" "$(dirname "$incoming")"
|
||||
if [ ! -d "$cache" ]; then git init --bare "$cache" >/dev/null; fi
|
||||
if git --git-dir="$cache" remote get-url origin >/dev/null 2>&1; then
|
||||
git --git-dir="$cache" remote set-url origin "$remote"
|
||||
else
|
||||
git --git-dir="$cache" remote add origin "$remote"
|
||||
fi
|
||||
${this.serverGitEnvironment(repository, profile, server)} git --git-dir="$cache" fetch --force --prune origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git --git-dir="$cache" cat-file -e "$target^{commit}"
|
||||
git --git-dir="$cache" merge-base --is-ancestor "$target" "refs/remotes/origin/$branch"
|
||||
archive_tmp="$incoming.$$.tmp"
|
||||
git --git-dir="$cache" archive --format=tar --output="$archive_tmp" "$target"
|
||||
[ -s "$archive_tmp" ] || { rm -f "$archive_tmp"; echo "Gitea produced an empty deployment archive" >&2; exit 45; }
|
||||
mv "$archive_tmp" "$incoming"
|
||||
digest=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum "$incoming" | awk '{print $1}'; else shasum -a 256 "$incoming" | awk '{print $1}'; fi)
|
||||
printf '%s\n' ${shellQuote(marker)}
|
||||
printf 'digest=%s\n' "$digest"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 5 * 60_000, maxOutput: 512 * 1024 });
|
||||
const output = String(result.stdout || "");
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
const digest = markerIndex >= 0
|
||||
? String(output.slice(markerIndex + marker.length).match(/(?:^|\n)digest=([0-9a-f]{64})(?:\n|$)/i)?.[1] || "").toLowerCase()
|
||||
: "";
|
||||
if (!digest) throw new Error("The server did not return a valid checksum for the Gitea archive.");
|
||||
return { remotePart, digest };
|
||||
}
|
||||
|
||||
async executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference, rollback = false }) {
|
||||
const permissionReport = await this.inspectWriteAccess({ repository, profileId: profile.id });
|
||||
if (!permissionReport.ready) {
|
||||
const error = new Error(`Deployment stopped before the Gitea fetch because write access is missing for: ${permissionReport.blocking.map((item) => item.path).join(", ")}`);
|
||||
error.code = "REMOTE_WRITE_ACCESS_REQUIRED";
|
||||
error.permissionReport = permissionReport;
|
||||
throw error;
|
||||
}
|
||||
const bundle = await this.createServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId });
|
||||
const script = this.pushBundleScript({ repository, profile, remotePath, targetSha, requestId, remotePart: bundle.remotePart, digest: bundle.digest, metadata, generated, iconReference, rollback });
|
||||
return this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 8 * 1024 * 1024 });
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
const targetSha = assertFullCommitSha(sha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
if (profile.deploymentMode === "server-git") {
|
||||
const verification = await this.verifyServerGitProfile({ repository, profileId });
|
||||
const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"];
|
||||
const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass");
|
||||
if (blocked.length || !verification.branchSha) {
|
||||
const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`);
|
||||
error.code = "SERVER_GIT_VERIFICATION_FAILED";
|
||||
error.verification = verification;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
|
||||
if (!preflight.summary.ready) {
|
||||
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`);
|
||||
error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED";
|
||||
throw error;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const mode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: "deployment",
|
||||
action: "deploy",
|
||||
provider: "ssh-unraid",
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
shortSha: targetSha.slice(0, 7),
|
||||
status: "running",
|
||||
logs: [
|
||||
"Preflight passed.",
|
||||
mode === "push-bundle"
|
||||
? "Creating and uploading the exact committed local project directly to Unraid."
|
||||
: mode === "server-git"
|
||||
? "Fetching the exact commit from Gitea with a repository-scoped read-only deploy key."
|
||||
: "This workload is monitor-only and cannot be deployed.",
|
||||
`Deploying exact commit ${targetSha} in the background.`,
|
||||
],
|
||||
});
|
||||
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: targetSha,
|
||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
||||
});
|
||||
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before deploying.");
|
||||
const result = mode === "server-git"
|
||||
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference })
|
||||
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha, requestId, metadata, generated, iconReference });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? "failed" : "success";
|
||||
const completed = await this.saveOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
|
||||
health,
|
||||
logs: [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
|
||||
"Docker Compose activation and runtime verification completed.",
|
||||
health.configured
|
||||
? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.`
|
||||
: "No desktop healthcheck configured; running containers were verified and health remains unverified.",
|
||||
],
|
||||
error: health.healthy === false ? "The application healthcheck did not pass after deployment." : null,
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: targetSha,
|
||||
previousSha: previousState?.liveSha || preflight.inspection?.head || null,
|
||||
healthy: health.configured ? health.healthy : null,
|
||||
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
|
||||
healthStatus: health.status ?? null,
|
||||
healthLatencyMs: health.latencyMs ?? null,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: "ssh-unraid",
|
||||
deploymentMode: mode,
|
||||
containerName: String(profile.containerName || profile.remoteFolder || repository.name),
|
||||
containerRunning: true,
|
||||
dockerMan: {
|
||||
webUi: this.dockerManWebUi(profile),
|
||||
icon: iconReference,
|
||||
shell: this.dockerManShell(profile),
|
||||
templateExists: profile.manageDockerMan === true || previousState?.dockerMan?.templateExists === true,
|
||||
configured: Boolean(this.dockerManWebUi(profile) || iconReference || previousState?.dockerMan?.configured),
|
||||
},
|
||||
webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null),
|
||||
});
|
||||
void this.refreshProfileState(repository.fullName, profileId).catch(() => {});
|
||||
await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, status: completed.status });
|
||||
} catch (error) {
|
||||
await this.saveOperation({
|
||||
...operation,
|
||||
status: "failed",
|
||||
error: error.message,
|
||||
failure: { stage: mode === "server-git" ? "Gitea server pull / Compose activation" : "Direct copy / Compose activation", message: error.message },
|
||||
logs: [...operation.logs, error.message, "The live SHA was not promoted. Previous release evidence remains authoritative."],
|
||||
});
|
||||
await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error });
|
||||
}
|
||||
})();
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
async rollback({ repository, profileId, targetSha }) {
|
||||
const target = assertFullCommitSha(targetSha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const deploymentState = this.store.getDeploymentState(profileId);
|
||||
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
|
||||
const error = new Error("Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.");
|
||||
error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA";
|
||||
throw error;
|
||||
}
|
||||
const rollbackMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: "push-bundle";
|
||||
if (rollbackMode === "push-bundle" && !repository.localPath)
|
||||
throw new Error("A linked local repository is required for Direct copy rollback verification.");
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.saveOperation({
|
||||
id: requestId,
|
||||
type: "deployment",
|
||||
action: "rollback",
|
||||
provider: "ssh-unraid",
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: target,
|
||||
shortSha: target.slice(0, 7),
|
||||
status: "running",
|
||||
logs: [`Rolling back to exact commit ${target}.`],
|
||||
});
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: target,
|
||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
||||
});
|
||||
try {
|
||||
const mode = rollbackMode;
|
||||
if (mode === "monitor-only") throw new Error("This workload is monitor-only. Select Server pull or Direct copy before rolling back.");
|
||||
const result = mode === "server-git"
|
||||
? await this.executeServerGitBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true })
|
||||
: await this.executePushBundle({ repository, profile, server, remotePath, targetSha: target, requestId, metadata, generated, iconReference, rollback: true });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? "failed" : "rolled-back";
|
||||
const completed = await this.saveOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: deploymentState.liveSha || null,
|
||||
health,
|
||||
error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null,
|
||||
logs: [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split("\n").filter(Boolean).slice(-80),
|
||||
"Rollback activation completed.",
|
||||
health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}.` : "Runtime is running; no desktop healthcheck was configured.",
|
||||
],
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: target,
|
||||
previousSha: deploymentState.liveSha || null,
|
||||
healthy: health.configured ? health.healthy : null,
|
||||
runtimeVerification: health.configured ? "desktop-healthcheck" : "running-unverified",
|
||||
healthStatus: health.status ?? null,
|
||||
healthLatencyMs: health.latencyMs ?? null,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: "ssh-unraid",
|
||||
containerRunning: true,
|
||||
});
|
||||
if (health.healthy === false) {
|
||||
const error = new Error("Rollback completed, but the configured healthcheck failed.");
|
||||
error.code = "ROLLBACK_HEALTHCHECK_FAILED";
|
||||
error.operationId = completed.id;
|
||||
throw error;
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") {
|
||||
await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return UnraidDeploymentMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidDeploymentMethods };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -0,0 +1,593 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,387 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidRuntimeMethods({
|
||||
safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run,
|
||||
bash, shellQuote, iconReferenceLocalPath,
|
||||
}) {
|
||||
class UnraidRuntimeMethods {
|
||||
internalSlug(profile, repository) {
|
||||
return (
|
||||
String(
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
profile.composeService ||
|
||||
"app",
|
||||
)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "app"
|
||||
);
|
||||
}
|
||||
|
||||
generatedCompose(profile, repository) {
|
||||
const service =
|
||||
String(profile.composeService || repository.name || "app")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, "-") || "app";
|
||||
const containerName =
|
||||
String(
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
service,
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || service;
|
||||
if (!profile.hostPort || !profile.containerPort)
|
||||
throw new Error(
|
||||
"Host and container ports are required for generated Compose.",
|
||||
);
|
||||
return (
|
||||
[
|
||||
"services:",
|
||||
` ${service}:`,
|
||||
` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`,
|
||||
" build:",
|
||||
" context: ..",
|
||||
` container_name: ${containerName}`,
|
||||
" restart: unless-stopped",
|
||||
" ports:",
|
||||
` - "${profile.hostPort}:${profile.containerPort}"`,
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
dockerManWebUi(profile) {
|
||||
if (profile.hostPort) {
|
||||
let suffix = "/";
|
||||
try {
|
||||
const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null;
|
||||
suffix = parsed
|
||||
? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}`
|
||||
: "/";
|
||||
} catch {}
|
||||
if (!suffix.startsWith("/")) suffix = `/${suffix}`;
|
||||
return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`;
|
||||
}
|
||||
return profile.webUiUrl || "";
|
||||
}
|
||||
|
||||
dockerManShell(profile) {
|
||||
return String(profile.dockerShell || "/bin/sh")
|
||||
.toLowerCase()
|
||||
.includes("bash")
|
||||
? "bash"
|
||||
: "sh";
|
||||
}
|
||||
|
||||
dockerManTemplatePath(profile, repository) {
|
||||
const containerName =
|
||||
String(
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
"app",
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`;
|
||||
}
|
||||
|
||||
dockerManTemplate(profile, repository, iconReference = "") {
|
||||
const containerName =
|
||||
String(
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
"app",
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
const slug = this.internalSlug(profile, repository);
|
||||
const environment =
|
||||
String(profile.environment || "production")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, "-") || "production";
|
||||
const image = `forgeflow/${slug}:${environment}`;
|
||||
const webUi = this.dockerManWebUi(profile);
|
||||
return (
|
||||
[
|
||||
'<?xml version="1.0"?>',
|
||||
'<Container version="2">',
|
||||
` <Name>${xmlEscape(containerName)}</Name>`,
|
||||
` <Repository>${xmlEscape(image)}</Repository>`,
|
||||
" <Registry/>",
|
||||
" <Network>bridge</Network>",
|
||||
" <MyIP/>",
|
||||
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
|
||||
" <Privileged>false</Privileged>",
|
||||
" <Support/>",
|
||||
" <Project/>",
|
||||
" <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>",
|
||||
" <Category>Tools:</Category>",
|
||||
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
|
||||
" <TemplateURL/>",
|
||||
` <Icon>${xmlEscape(iconReference)}</Icon>`,
|
||||
" <ExtraParams/>",
|
||||
" <PostArgs/>",
|
||||
" <CPUset/>",
|
||||
" <DonateText/>",
|
||||
" <DonateLink/>",
|
||||
"</Container>",
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
iconCacheRefresh(profile, repository, iconReference = "") {
|
||||
const containerName =
|
||||
String(
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
"app",
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`;
|
||||
const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`;
|
||||
const localIconPath = iconReferenceLocalPath(iconReference);
|
||||
if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`;
|
||||
return `${cacheLoop}
|
||||
if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi
|
||||
${invalidateMetadata}`;
|
||||
}
|
||||
|
||||
dockerManRefreshScript(profile, repository, iconReference = "") {
|
||||
if (profile.manageDockerMan !== true || profile.adoptedFromServer === true || profile.generatedCompose !== true) {
|
||||
return `echo 'ForgeFlow left the existing DockerMan template unchanged.'`;
|
||||
}
|
||||
const templatePath = this.dockerManTemplatePath(profile, repository);
|
||||
const template = this.dockerManTemplate(profile, repository, iconReference);
|
||||
return `mkdir -p /boot/config/plugins/dockerMan/templates-user
|
||||
cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE'
|
||||
${template}FORGEFLOW_DOCKERMAN_TEMPLATE
|
||||
chmod 0644 ${shellQuote(templatePath)}
|
||||
${this.iconCacheRefresh(profile, repository, iconReference)}`;
|
||||
}
|
||||
|
||||
deploymentServices(profile, repository) {
|
||||
const values = profile.generatedCompose
|
||||
? [profile.composeService || repository.name || "app"]
|
||||
: (profile.composeServices?.length ? profile.composeServices : [profile.composeService || repository.name || "app"]);
|
||||
return [...new Set(values.map((value) => String(value || "").trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-")).filter(Boolean))];
|
||||
}
|
||||
|
||||
deploymentComposeFiles(profile) {
|
||||
if (profile.generatedCompose) return [".forgeflow/compose.forgeflow.yml"];
|
||||
const values = profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"];
|
||||
return [...new Set(values
|
||||
.map((value) => safeRelativeRemoteFile(value))
|
||||
.filter((value) => value !== ".forgeflow/compose.metadata.yml" && value !== ".forgeflow/compose.forgeflow.yml"))];
|
||||
}
|
||||
|
||||
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
|
||||
const services = this.deploymentServices(profile, repository);
|
||||
const labels = {
|
||||
"net.unraid.docker.managed": "dockerman",
|
||||
"net.unraid.docker.shell": this.dockerManShell(profile),
|
||||
"tech.itworx.forgeflow.repository":
|
||||
deployment.repositoryUrl ||
|
||||
profile.cloneUrl ||
|
||||
repository.sshUrl ||
|
||||
repository.cloneUrl ||
|
||||
repository.htmlUrl ||
|
||||
repository.fullName || repository.name || "unknown",
|
||||
"tech.itworx.forgeflow.branch": profile.branch || "main",
|
||||
};
|
||||
if (deployment.sha) labels["tech.itworx.forgeflow.commit"] = deployment.sha;
|
||||
const webUiLabel = this.dockerManWebUi(profile);
|
||||
if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel;
|
||||
if (iconReference) labels["net.unraid.docker.icon"] = iconReference;
|
||||
|
||||
const output = ["services:"];
|
||||
for (const service of services) {
|
||||
output.push(` ${service}:`);
|
||||
if (profile.generatedCompose) {
|
||||
const containerName = String(
|
||||
profile.containerName || profile.remoteFolder || repository.name || service,
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
output.push(` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase().replace(/[^a-z0-9._-]/g, "-")}`);
|
||||
output.push(` container_name: ${containerName}`);
|
||||
}
|
||||
output.push(" labels:");
|
||||
output.push(...Object.entries(labels).map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`));
|
||||
}
|
||||
return `${output.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async prepareIcon(profile, repository, server) {
|
||||
const mode =
|
||||
profile.iconMode ||
|
||||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
|
||||
if (mode === "none") return profile.serverIconReference || "";
|
||||
if (mode === "url") {
|
||||
if (!profile.iconUrl)
|
||||
throw new Error(
|
||||
"DockerMan icon URL mode is selected, but no icon URL is configured.",
|
||||
);
|
||||
return profile.iconUrl;
|
||||
}
|
||||
const localIconPath =
|
||||
mode === "builtin"
|
||||
? nativePath.join(
|
||||
this.sourcePath,
|
||||
"src",
|
||||
"renderer",
|
||||
"assets",
|
||||
"itworx-mark.png",
|
||||
)
|
||||
: profile.iconFilePath;
|
||||
const stat = await fs.stat(localIconPath).catch(() => null);
|
||||
if (!stat?.isFile())
|
||||
throw new Error(
|
||||
mode === "builtin"
|
||||
? "The built-in ITWorx DockerMan icon is missing."
|
||||
: `The selected DockerMan icon file no longer exists: ${localIconPath}`,
|
||||
);
|
||||
if (nativePath.extname(localIconPath).toLowerCase() !== ".png")
|
||||
throw new Error(
|
||||
"DockerMan icon upload currently accepts PNG files only.",
|
||||
);
|
||||
const containerName =
|
||||
String(
|
||||
profile.containerName ||
|
||||
profile.remoteFolder ||
|
||||
repository.name ||
|
||||
"app",
|
||||
).replace(/[^A-Za-z0-9._-]/g, "-") || "app";
|
||||
const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`;
|
||||
await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, {
|
||||
mode: 0o644,
|
||||
});
|
||||
return `file://${remoteIconPath}`;
|
||||
}
|
||||
|
||||
composeInvocation(profile, repository) {
|
||||
const project = String(profile.composeProject || this.internalSlug(profile, repository)).trim();
|
||||
const files = [...this.deploymentComposeFiles(profile)];
|
||||
// A labels-only Compose fragment is valid only when every service key also
|
||||
// exists in the base definition. Imported profiles can contain stale service
|
||||
// hints, so adopted workloads must activate from their real server Compose
|
||||
// files only. ForgeFlow tracks the deployed SHA in .forgeflow/status.json.
|
||||
if (profile.generatedCompose) files.push(".forgeflow/compose.metadata.yml");
|
||||
return `forgeflow_compose -p ${shellQuote(project)} ${files.map((file) => `-f ${shellQuote(file)}`).join(" ")}`;
|
||||
}
|
||||
|
||||
composeUpFlags(profile) {
|
||||
// ForgeFlow never adds destructive recreation or orphan-removal flags.
|
||||
// Compose may replace a service when its built image or configuration changed,
|
||||
// but unrelated containers are never deleted by ForgeFlow.
|
||||
void profile;
|
||||
return "";
|
||||
}
|
||||
|
||||
containerVerificationScript(profile, repository, compose, { requireRecreated = false } = {}) {
|
||||
const recreationCheck = requireRecreated
|
||||
? ` before_id=$(awk -F '\t' -v wanted="$service" '$1 == wanted { print $2; exit }' "$before_containers" 2>/dev/null || true)
|
||||
if [ -n "$before_id" ] && [ "$before_id" = "$container_id" ]; then
|
||||
echo "Compose reported success but service $service still uses the previous container $container_id" >&2
|
||||
exit 65
|
||||
fi`
|
||||
: ` before_id=""`;
|
||||
return `actual_services=$(${compose} config --services 2>/dev/null | sed '/^$/d')
|
||||
[ -n "$actual_services" ] || { echo "Compose project defines no services" >&2; exit 60; }
|
||||
printf '%s\n' "$actual_services" | while IFS= read -r service; do
|
||||
[ -n "$service" ] || continue
|
||||
attempt=0; container_id=''; running=false; health=''
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
container_id=$(${compose} ps -q "$service" | head -n1)
|
||||
if [ -n "$container_id" ]; then
|
||||
running=$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || echo false)
|
||||
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true)
|
||||
if [ "$running" = true ] && [ "$health" != unhealthy ] && [ "$health" != starting ]; then break; fi
|
||||
fi
|
||||
attempt=$((attempt + 1)); sleep 2
|
||||
done
|
||||
[ -n "$container_id" ] || { echo "Compose service $service did not create a container" >&2; exit 61; }
|
||||
[ "$running" = true ] || { echo "Compose service $service is not running after 60 seconds" >&2; exit 62; }
|
||||
[ "$health" != unhealthy ] && [ "$health" != starting ] || { echo "Compose service $service did not become healthy" >&2; exit 63; }
|
||||
${recreationCheck}
|
||||
image_id=$(docker inspect -f '{{.Image}}' "$container_id" 2>/dev/null || true)
|
||||
printf 'ForgeFlow verified service %s: container=%s previous=%s image=%s\n' "$service" "$container_id" "\${before_id:-none}" "\${image_id:-unknown}"
|
||||
done`;
|
||||
}
|
||||
|
||||
async checkHealth(url) {
|
||||
if (!url)
|
||||
return {
|
||||
configured: false,
|
||||
healthy: null,
|
||||
status: null,
|
||||
latencyMs: null,
|
||||
};
|
||||
let last = null;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
redirect: "manual",
|
||||
});
|
||||
last = {
|
||||
configured: true,
|
||||
healthy: response.ok,
|
||||
status: response.status,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
if (response.ok) return last;
|
||||
} catch (error) {
|
||||
last = {
|
||||
configured: true,
|
||||
healthy: false,
|
||||
status: null,
|
||||
latencyMs: Date.now() - started,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
if (attempt < 5)
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
hashFile(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const stream = fileSystem.createReadStream(filePath);
|
||||
stream.on("error", reject);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async createCommitBundle(repository, sha, requestId) {
|
||||
if (!repository.localPath) throw new Error("A linked local repository is required to create a push bundle.");
|
||||
const bundleDirectory = nativePath.join(os.tmpdir(), "forgeflow-bundles");
|
||||
await fs.mkdir(bundleDirectory, { recursive: true });
|
||||
const archivePath = nativePath.join(bundleDirectory, `${requestId}-${sha}.tar`);
|
||||
await run("git", ["-C", repository.localPath, "archive", "--format=tar", `--output=${archivePath}`, sha], {
|
||||
timeout: 5 * 60_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
const stat = await fs.stat(archivePath);
|
||||
if (!stat.isFile() || stat.size <= 0) throw new Error("Git produced an empty deployment bundle.");
|
||||
return { archivePath, bytes: stat.size, sha256: await this.hashFile(archivePath) };
|
||||
}
|
||||
|
||||
deploymentStatusDocument({ repository, profile, targetSha, requestId, rollback = false }) {
|
||||
return JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
requested_sha: targetSha,
|
||||
live_sha: targetSha,
|
||||
request_id: requestId,
|
||||
healthy: null,
|
||||
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
|
||||
rollback,
|
||||
deployment_mode: profile.deploymentMode || "push-bundle",
|
||||
deployed_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return UnraidRuntimeMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidRuntimeMethods };
|
||||
@@ -0,0 +1,282 @@
|
||||
"use strict";
|
||||
|
||||
function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) {
|
||||
class UnraidStateMethods {
|
||||
async refreshProfileState(fullName, profileId, expectedGiteaSha = null) {
|
||||
const repository = { fullName, name: fullName.split("/").pop() };
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const containerName = String(
|
||||
profile.containerName || profile.remoteFolder || repository.name,
|
||||
);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
container=${shellQuote(containerName)}
|
||||
template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")}
|
||||
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
|
||||
[ -f "$template_path" ] && template_exists=true
|
||||
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
|
||||
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
|
||||
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
|
||||
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
||||
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
||||
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
||||
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true)
|
||||
fi
|
||||
printf '__FORGEFLOW_KV__\n'
|
||||
printf 'liveSha=%s\n' "$live"
|
||||
printf 'previousSha=%s\n' "$previous"
|
||||
printf 'containerRunning=%s\n' "$running"
|
||||
printf 'dockerHealth=%s\n' "$docker_health"
|
||||
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
|
||||
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
|
||||
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
|
||||
printf 'templateExists=%s\n' "$template_exists"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__");
|
||||
if (marker < 0)
|
||||
throw new Error(
|
||||
"Unraid state inspection did not return a ForgeFlow marker.",
|
||||
);
|
||||
const fields = {};
|
||||
for (const line of result.stdout
|
||||
.slice(marker + "__FORGEFLOW_KV__".length)
|
||||
.trim()
|
||||
.split(/\r?\n/)) {
|
||||
const index = line.indexOf("=");
|
||||
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
|
||||
}
|
||||
const decode = (value) => {
|
||||
try {
|
||||
return value ? Buffer.from(value, "base64").toString("utf8") : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const dockerHealthy = fields.dockerHealth
|
||||
? fields.dockerHealth === "healthy"
|
||||
: null;
|
||||
const effectiveHealthy = health.configured ? health.healthy : dockerHealthy;
|
||||
const runtimeVerification = health.configured
|
||||
? "desktop-healthcheck"
|
||||
: dockerHealthy === true
|
||||
? "docker-healthcheck"
|
||||
: dockerHealthy === false
|
||||
? "docker-unhealthy"
|
||||
: fields.containerRunning === "true"
|
||||
? "running-unverified"
|
||||
: "stopped";
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "")
|
||||
? fields.liveSha
|
||||
: null,
|
||||
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "")
|
||||
? fields.previousSha
|
||||
: null,
|
||||
healthy: effectiveHealthy,
|
||||
runtimeVerification,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
containerName,
|
||||
containerRunning: fields.containerRunning === "true",
|
||||
dockerHealth: fields.dockerHealth || null,
|
||||
dockerMan: {
|
||||
webUi: decode(fields.webUiLabel),
|
||||
icon: decode(fields.iconLabel),
|
||||
shell: decode(fields.shellLabel),
|
||||
templateExists: fields.templateExists === "true",
|
||||
configured: Boolean(
|
||||
decode(fields.webUiLabel) ||
|
||||
decode(fields.iconLabel) ||
|
||||
fields.templateExists === "true",
|
||||
),
|
||||
},
|
||||
webUiUrl:
|
||||
profile.webUiUrl ||
|
||||
(profile.hostPort
|
||||
? `http://${server.host}:${profile.hostPort}/`
|
||||
: null),
|
||||
remotePath,
|
||||
provider: "ssh-unraid",
|
||||
giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
||||
? expectedGiteaSha
|
||||
: null,
|
||||
matchesGitea:
|
||||
/^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) &&
|
||||
fields.liveSha === expectedGiteaSha,
|
||||
});
|
||||
}
|
||||
|
||||
async applyDockerManMetadata({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
if (profile.generatedCompose !== true) {
|
||||
// Existing Compose files remain authoritative. Applying a generated
|
||||
// labels-only service fragment can create a phantom service when a stale
|
||||
// profile hint no longer matches the real Compose service keys.
|
||||
return this.refreshProfileState(repository.fullName, profileId);
|
||||
}
|
||||
const composeFile = profile.generatedCompose
|
||||
? ".forgeflow/compose.forgeflow.yml"
|
||||
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference);
|
||||
const compose = this.composeInvocation(profile, repository);
|
||||
const flags = this.composeUpFlags(profile);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root"
|
||||
mkdir -p "$root/.forgeflow"
|
||||
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
|
||||
${metadata}FORGEFLOW_METADATA
|
||||
cd "$root"
|
||||
${compose} config >/dev/null
|
||||
${compose} up -d --build ${flags}
|
||||
${this.containerVerificationScript(profile, repository, compose)}
|
||||
${this.dockerManRefreshScript(profile, repository, iconReference)}
|
||||
`;
|
||||
await this.ssh.exec(server.id, bash(script), {
|
||||
timeout: 10 * 60_000,
|
||||
maxOutput: 2 * 1024 * 1024,
|
||||
});
|
||||
return this.refreshProfileState(repository.fullName, profileId);
|
||||
}
|
||||
|
||||
async refreshOperation(
|
||||
operationId,
|
||||
{ includeTerminal = false, state: suppliedState = null } = {},
|
||||
) {
|
||||
const operation = this.store.getOperation(operationId);
|
||||
if (!operation || operation.provider !== "ssh-unraid") return operation;
|
||||
if (
|
||||
!includeTerminal &&
|
||||
["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
operation.status,
|
||||
)
|
||||
)
|
||||
return operation;
|
||||
try {
|
||||
const state =
|
||||
suppliedState ||
|
||||
(await this.refreshProfileState(
|
||||
operation.repository,
|
||||
operation.profileId,
|
||||
));
|
||||
if (
|
||||
state.liveSha === operation.sha &&
|
||||
state.containerRunning &&
|
||||
state.healthy !== false
|
||||
) {
|
||||
return this.saveOperation({
|
||||
...operation,
|
||||
status: operation.action === "rollback" ? "rolled-back" : "success",
|
||||
health: { healthy: state.healthy, status: state.healthStatus },
|
||||
logs: [
|
||||
...(operation.logs || []),
|
||||
"Deployment state reconciled from Unraid.",
|
||||
],
|
||||
});
|
||||
}
|
||||
if (
|
||||
/^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) &&
|
||||
state.liveSha !== operation.sha &&
|
||||
state.containerRunning &&
|
||||
state.healthy !== false
|
||||
) {
|
||||
return this.saveOperation({
|
||||
...operation,
|
||||
status: "cancelled",
|
||||
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
|
||||
health: { healthy: state.healthy, status: state.healthStatus },
|
||||
logs: [
|
||||
...(operation.logs || []),
|
||||
`Operation superseded by live Unraid commit ${state.liveSha}.`,
|
||||
],
|
||||
});
|
||||
}
|
||||
const ageMs =
|
||||
Date.now() -
|
||||
new Date(operation.updatedAt || operation.createdAt || 0).getTime();
|
||||
if (ageMs > 45 * 60_000) {
|
||||
return this.saveOperation({
|
||||
...operation,
|
||||
status: "failed",
|
||||
error:
|
||||
"Deployment was interrupted or did not reach the requested commit within 45 minutes.",
|
||||
logs: [
|
||||
...(operation.logs || []),
|
||||
"Stale deployment was marked failed during reconciliation.",
|
||||
],
|
||||
});
|
||||
}
|
||||
return operation;
|
||||
} catch {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
|
||||
async reconcileRecordedOperations(profileId, state) {
|
||||
const operations = this.store.data.operations
|
||||
.filter(
|
||||
(item) =>
|
||||
item.profileId === profileId && item.provider === "ssh-unraid",
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt || right.createdAt || 0) -
|
||||
new Date(left.updatedAt || left.createdAt || 0),
|
||||
);
|
||||
const matching = operations.find(
|
||||
(item) => item.sha === state.liveSha && item.status === "failed",
|
||||
);
|
||||
if (matching && state.containerRunning && state.healthy !== false) {
|
||||
await this.refreshOperation(matching.id, {
|
||||
includeTerminal: true,
|
||||
state,
|
||||
});
|
||||
}
|
||||
const latestFailed = operations.find((item) => item.status === "failed");
|
||||
if (
|
||||
latestFailed &&
|
||||
latestFailed.id !== matching?.id &&
|
||||
state.matchesGitea &&
|
||||
state.containerRunning &&
|
||||
state.healthy !== false
|
||||
) {
|
||||
await this.saveOperation({
|
||||
...latestFailed,
|
||||
status: "cancelled",
|
||||
error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`,
|
||||
logs: [
|
||||
...(latestFailed.logs || []),
|
||||
`Reconciled: Gitea and Unraid now both report ${state.liveSha}.`,
|
||||
],
|
||||
});
|
||||
}
|
||||
return this.store.data.operations
|
||||
.filter((item) => item.profileId === profileId)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
async refreshActiveOperations() {
|
||||
const active = this.store.data.operations.filter(
|
||||
(item) =>
|
||||
item.provider === "ssh-unraid" &&
|
||||
item.type === "deployment" &&
|
||||
!["success", "failed", "cancelled", "rolled-back"].includes(
|
||||
item.status,
|
||||
),
|
||||
);
|
||||
return Promise.all(active.map((item) => this.refreshOperation(item.id)));
|
||||
}
|
||||
}
|
||||
return UnraidStateMethods.prototype;
|
||||
}
|
||||
|
||||
module.exports = { createUnraidStateMethods };
|
||||
Reference in New Issue
Block a user