Release ForgeFlow 0.8.7 automatic server discovery

This commit is contained in:
NuklearRabbit
2026-07-26 04:22:02 +02:00
parent 70840839f8
commit 40d41f9a32
13 changed files with 638 additions and 130 deletions
+34 -3
View File
@@ -1140,10 +1140,41 @@ function registerIpc({
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:profile-state", ({ fullName, profileId }) => {
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,
detected: 0,
adopted: 0,
verified: 0,
unmatched: 0,
error: error.message,
});
}
}
return results;
});
register("deployment:profile-state", async ({ fullName, profileId }) => {
const profile = store.getDeploymentProfile(fullName, profileId);
if (profile?.provider === "ssh-unraid")
return unraid.refreshProfileState(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(
+298 -3
View File
@@ -349,11 +349,92 @@ function iconReferenceLocalPath(iconReference) {
return "";
}
function decodeInventoryValue(value) {
try {
return Buffer.from(String(value || ""), "base64").toString("utf8");
} catch {
return "";
}
}
function remoteIdentity(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}` : "";
}
function parseServerInventory(output) {
const marker = "__FORGEFLOW_INVENTORY__";
const index = String(output || "").lastIndexOf(marker);
if (index < 0)
throw new Error(
"The server did not return a ForgeFlow workload inventory.",
);
const checkouts = [];
const containers = [];
for (const line of String(output)
.slice(index + marker.length)
.trim()
.split(/\r?\n/)) {
const [kind, ...parts] = line.split("\t");
if (kind === "R" && parts.length >= 4) {
checkouts.push({
root: decodeInventoryValue(parts[0]),
remote: decodeInventoryValue(parts[1]),
liveSha: parts[2],
branch: decodeInventoryValue(parts[3]),
});
} else if (kind === "C" && parts[0]) {
try {
const parsed = JSON.parse(decodeInventoryValue(parts[0]));
if (Array.isArray(parsed) && parsed[0]) containers.push(parsed[0]);
} catch {}
}
}
return { checkouts, containers };
}
function inventoryContainerMatch(checkout, repository, container) {
if (!container?.State?.Running) return 0;
const labels = container.Config?.Labels || {};
const workingDir = String(
labels["com.docker.compose.project.working_dir"] || "",
).replace(/\/$/, "");
const source = remoteIdentity(
labels["org.opencontainers.image.source"] || "",
);
const mounts = Array.isArray(container.Mounts) ? container.Mounts : [];
const root = String(checkout.root || "").replace(/\/$/, "");
const name = String(container.Name || "").replace(/^\//, "");
const project = String(labels["com.docker.compose.project"] || "");
const expectedNames = new Set(
[repository.name, root.split("/").pop()].filter(Boolean).map((value) =>
String(value)
.toLowerCase()
.replace(/[^a-z0-9]/g, ""),
),
);
const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, "");
const normalizedProject = project.toLowerCase().replace(/[^a-z0-9]/g, "");
if (workingDir && workingDir === root) return 100;
if (
mounts.some((mount) => {
const mountSource = String(mount.Source || "").replace(/\/$/, "");
return mountSource === root || mountSource.startsWith(`${root}/`);
})
)
return 90;
if (source && source === remoteIdentity(checkout.remote)) return 85;
if (expectedNames.has(normalizedProject)) return 70;
if (expectedNames.has(normalizedName)) return 60;
return 0;
}
class UnraidDeploymentService {
constructor({
store,
ssh,
git,
gitea,
diagnostics,
sourcePath = process.cwd(),
onOperationChange = null,
@@ -361,11 +442,204 @@ class UnraidDeploymentService {
this.store = store;
this.ssh = ssh;
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.sourcePath = sourcePath;
this.onOperationChange = onOperationChange;
}
async discoverServerWorkloads(serverId, repositories) {
const server = this.store.getServer(serverId);
if (!server) throw new Error("The deployment server no longer exists.");
const script = `
base=${shellQuote(server.basePath)}
printf '__FORGEFLOW_INVENTORY__\\n'
if [ -d "$base" ]; then
find "$base" -mindepth 2 -maxdepth 2 -type d -name .git -print0 2>/dev/null | while IFS= read -r -d '' gitdir; do
root=\${gitdir%/.git}
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null || true)
[ -n "$remote" ] || continue
printf 'R\\t%s\\t%s\\t%s\\t%s\\n' "$(printf '%s' "$root" | base64 | tr -d '\\r\\n')" "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" "$live" "$(printf '%s' "$branch" | base64 | tr -d '\\r\\n')"
done
fi
for id in $(docker ps -aq 2>/dev/null || true); do
printf 'C\\t%s\\n' "$(docker inspect "$id" 2>/dev/null | base64 | tr -d '\\r\\n')"
done
`;
const result = await this.ssh.exec(server.id, bash(script), {
timeout: 45_000,
maxOutput: 8 * 1024 * 1024,
});
const inventory = parseServerInventory(result.stdout);
const workloads = [...inventory.checkouts];
const knownRemotes = new Set(
workloads.map((checkout) => remoteIdentity(checkout.remote)),
);
for (const container of inventory.containers) {
const labels = container.Config?.Labels || {};
const remote =
labels["org.opencontainers.image.source"] ||
labels["tech.itworx.forgeflow.repository"] ||
"";
const revision =
labels["org.opencontainers.image.revision"] ||
labels["tech.itworx.forgeflow.commit"] ||
"";
if (
!remoteIdentity(remote) ||
knownRemotes.has(remoteIdentity(remote)) ||
!/^[0-9a-f]{40}$/i.test(revision)
)
continue;
const workingDir = String(
labels["com.docker.compose.project.working_dir"] || "",
);
const mountedRoot = (container.Mounts || [])
.map((mount) => String(mount.Source || ""))
.find((source) => source.startsWith(`${server.basePath}/`));
workloads.push({
root: workingDir || mountedRoot || "",
remote,
liveSha: revision,
branch: labels["tech.itworx.forgeflow.branch"] || "",
imageMetadata: true,
});
knownRemotes.add(remoteIdentity(remote));
}
const remoteMap = new Map();
for (const repository of repositories || []) {
for (const remote of [
repository.cloneUrl,
repository.sshUrl,
repository.htmlUrl,
]) {
const normalized = remoteIdentity(remote);
if (normalized) remoteMap.set(normalized, repository);
}
}
const summary = {
serverId,
detected: 0,
adopted: 0,
verified: 0,
unmatched: 0,
};
for (const checkout of workloads) {
const repository = remoteMap.get(remoteIdentity(checkout.remote));
if (!repository) {
summary.unmatched += 1;
continue;
}
if (!checkout.root)
checkout.root = path.join(server.basePath, repository.name);
const candidates = inventory.containers
.map((container) => ({
container,
score: inventoryContainerMatch(checkout, repository, container),
}))
.filter((candidate) => candidate.score >= 60)
.sort((left, right) => right.score - left.score);
if (!candidates.length || candidates[1]?.score === candidates[0].score) {
summary.unmatched += 1;
continue;
}
summary.detected += 1;
const container = candidates[0].container;
const containerName = String(container.Name || "").replace(/^\//, "");
const remoteFolder = checkout.root
.slice(server.basePath.length)
.replace(/^\/+/, "");
if (!/^[A-Za-z0-9._-]+$/.test(remoteFolder)) {
summary.unmatched += 1;
continue;
}
const existing = this.store
.getDeploymentProfiles(repository.fullName)
.find(
(profile) =>
profile.provider === "ssh-unraid" &&
profile.serverId === server.id &&
(profile.containerName === containerName ||
profile.remoteFolder === remoteFolder),
);
const labels = container.Config?.Labels || {};
const portEntry = Object.entries(
container.NetworkSettings?.Ports || {},
).find(
([, bindings]) => Array.isArray(bindings) && bindings[0]?.HostPort,
);
const containerPort = portEntry
? Number(String(portEntry[0]).split("/")[0]) || null
: null;
const hostPort = portEntry
? Number(portEntry[1][0].HostPort) || null
: null;
const profile =
existing ||
(await this.store.saveDeploymentProfile(repository.fullName, {
id: `auto-${crypto.createHash("sha256").update(`${server.id}:${repository.fullName}:${containerName}`).digest("hex").slice(0, 20)}`,
name: `${server.name} · ${containerName}`,
environment: "production",
provider: "ssh-unraid",
branch: checkout.branch || repository.defaultBranch || "main",
serverId: server.id,
remoteFolder,
composeFile:
String(labels["com.docker.compose.project.config_files"] || "")
.split(",")[0]
.split("/")
.pop() || "docker-compose.yml",
composeService:
String(labels["com.docker.compose.service"] || remoteFolder)
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "-") || remoteFolder.toLowerCase(),
containerName,
cloneUrl: checkout.remote,
hostPort,
containerPort,
webUiUrl: labels["net.unraid.docker.webui"] || "",
adoptedFromServer: true,
serverSourceOfTruth: true,
detectedAt: new Date().toISOString(),
detectedMetadata: {
confidence: candidates[0].score,
source: "automatic-server-inventory",
},
}));
if (!existing) summary.adopted += 1;
let giteaSha = null;
try {
const [owner, repo] = repository.fullName.split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
giteaSha = branch?.commit?.id || branch?.commit?.sha || null;
} catch {}
const dockerHealth = String(container.State?.Health?.Status || "");
const healthy = dockerHealth
? dockerHealth === "healthy"
: container.State?.Running === true;
await this.store.saveDeploymentState(profile.id, {
liveSha: /^[0-9a-f]{40}$/i.test(checkout.liveSha)
? checkout.liveSha
: null,
giteaSha: /^[0-9a-f]{40}$/i.test(giteaSha || "") ? giteaSha : null,
matchesGitea: Boolean(giteaSha && checkout.liveSha === giteaSha),
healthy,
containerRunning: container.State?.Running === true,
dockerHealth: dockerHealth || null,
containerName,
remotePath: checkout.root,
provider: "ssh-unraid",
discovery: { automatic: true, confidence: candidates[0].score },
});
if (healthy && giteaSha && checkout.liveSha === giteaSha)
summary.verified += 1;
}
await this.diagnostics?.info("unraid.workloads.discovered", summary);
return summary;
}
async saveOperation(operation) {
const saved = await this.store.addOperation(operation);
this.onOperationChange?.({ operations: [saved] });
@@ -1148,7 +1422,7 @@ chmod 0644 ${shellQuote(templatePath)}
${this.iconCacheRefresh(profile, repository, iconReference)}`;
}
metadataCompose(profile, repository, iconReference = "") {
metadataCompose(profile, repository, iconReference = "", deployment = {}) {
const service =
String(profile.composeService || repository.name || "app")
.trim()
@@ -1165,7 +1439,16 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
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,
"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;
@@ -1327,7 +1610,10 @@ ${this.iconCacheRefresh(profile, repository, iconReference)}`;
? this.generatedCompose(profile, repository)
: "";
const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference);
const metadata = this.metadataCompose(profile, repository, iconReference, {
sha: targetSha,
repositoryUrl: cloneUrl,
});
const compose = this.composeInvocation(profile, repository, composeFile);
const branch = String(profile.branch || "main");
const statusJson = JSON.stringify({
@@ -1513,7 +1799,11 @@ FORGEFLOW_STATUS
? ".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 metadata = this.metadataCompose(profile, repository, iconReference, {
sha: target,
repositoryUrl:
profile.cloneUrl || repository.sshUrl || repository.cloneUrl || "",
});
const compose = this.composeInvocation(profile, repository, composeFile);
const requestId = crypto.randomUUID();
const operation = await this.saveOperation({
@@ -1639,6 +1929,8 @@ if docker inspect "$container" >/dev/null 2>&1; then
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"
@@ -1886,6 +2178,9 @@ module.exports = {
iconReferenceLocalPath,
decodeBase64Json,
parseDockerManXml,
parseServerInventory,
inventoryContainerMatch,
remoteIdentity,
deriveDetectedProfile,
bash,
};
+24 -3
View File
@@ -159,6 +159,7 @@ const ui = {
diagnosticsStatus: null,
troubleshooter: null,
deploymentDiscovery: null,
serverDiscovery: [],
lastDiagnosticBundle: null,
setupDraft: {
baseUrl: "https://",
@@ -415,13 +416,33 @@ async function refreshActiveOperations(showErrors = true) {
}
async function refreshDeploymentTruth(showErrors = false) {
let discovery = [];
try {
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
ui.serverDiscovery = discovery;
const adopted = discovery.reduce(
(total, server) => total + Number(server.adopted || 0),
0,
);
if (adopted > 0) {
await refreshRepositories(false, true);
showToast(
"Server workloads discovered",
`${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`,
"success",
);
}
} catch (error) {
if (showErrors)
showToast("Server discovery unavailable", error.message, "error");
}
const targets = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({
repository,
profile,
})),
);
if (!targets.length) return { checked: 0, failed: 0 };
if (!targets.length) return { checked: 0, failed: 0, discovery };
const failures = [];
const queue = [...targets];
@@ -454,7 +475,7 @@ async function refreshDeploymentTruth(showErrors = false) {
"error",
);
}
return { checked: targets.length, failed: failures.length };
return { checked: targets.length, failed: failures.length, discovery };
}
function selectRepository(id, shouldRender = true) {
@@ -903,7 +924,7 @@ function renderProfileCard(repository, profile, compact = false) {
repository.localStatus?.branch.head === profile.branch;
const isSsh = profile.provider === "ssh-unraid";
const providerDetail = isSsh
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile);
+13 -1
View File
@@ -564,7 +564,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.8.6-demo",
appVersion: "0.8.7-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -1421,6 +1421,18 @@
syncState();
return clone(target.state);
},
async discoverServerDeployments() {
await wait(80);
return [
{
serverId: "unraid-primary",
detected: 2,
adopted: 0,
verified: 1,
unmatched: 0,
},
];
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {