Release ForgeFlow 0.8.7 automatic server discovery
This commit is contained in:
+34
-3
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user