feat: harden server pull deployments and git hygiene
This commit is contained in:
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 9,
|
||||
schemaVersion: 11,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -79,7 +79,7 @@ class ConfigStore {
|
||||
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
|
||||
? requestedDeploymentMode
|
||||
: 'server-git';
|
||||
: 'push-bundle';
|
||||
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
|
||||
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
|
||||
return {
|
||||
@@ -198,7 +198,7 @@ class ConfigStore {
|
||||
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
|
||||
const username = String(source.username || existing?.username || '').trim();
|
||||
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
|
||||
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
|
||||
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password');
|
||||
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
||||
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
|
||||
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
|
||||
@@ -400,7 +400,9 @@ class ConfigStore {
|
||||
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
|
||||
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
|
||||
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle';
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode)
|
||||
? profile.deploymentMode
|
||||
: 'push-bundle';
|
||||
return {
|
||||
...common,
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
@@ -436,6 +438,13 @@ class ConfigStore {
|
||||
forceRecreate: profile.forceRecreate === true,
|
||||
removeOrphans: profile.removeOrphans === true,
|
||||
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
|
||||
serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? {
|
||||
configured: profile.serverGitAccess.configured === true,
|
||||
deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null,
|
||||
keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null,
|
||||
hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null,
|
||||
configuredAt: profile.serverGitAccess.configuredAt || null
|
||||
} : null,
|
||||
detectedAt: profile.detectedAt || null,
|
||||
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
||||
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
||||
|
||||
@@ -24,6 +24,32 @@ coverage/
|
||||
Thumbs.db
|
||||
`;
|
||||
|
||||
const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.zip binary
|
||||
`;
|
||||
|
||||
const RECOMMENDED_EDITORCONFIG = `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{bat,cmd,ps1}]
|
||||
end_of_line = crlf
|
||||
`;
|
||||
|
||||
function sameRemote(left, right) {
|
||||
const a = normalizeRemoteUrl(left);
|
||||
const b = normalizeRemoteUrl(right);
|
||||
@@ -275,6 +301,36 @@ class GitValidatorService {
|
||||
},
|
||||
),
|
||||
);
|
||||
for (const [id, title, filename, action] of [
|
||||
["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"],
|
||||
["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"],
|
||||
]) {
|
||||
const present = lowerFiles.includes(filename);
|
||||
checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning",
|
||||
present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, {
|
||||
weight: 5,
|
||||
fixAction: present ? null : action,
|
||||
safe: false,
|
||||
confirmation: `Create a recommended ${filename} in the working tree for review?`,
|
||||
}));
|
||||
}
|
||||
|
||||
const packageManagers = [
|
||||
{ manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] },
|
||||
{ manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] },
|
||||
{ manifests: ["composer.json"], locks: ["composer.lock"] },
|
||||
{ manifests: ["gemfile"], locks: ["gemfile.lock"] },
|
||||
];
|
||||
const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name)));
|
||||
if (lockCheck) {
|
||||
const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name));
|
||||
checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning",
|
||||
lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 }));
|
||||
}
|
||||
|
||||
const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file));
|
||||
checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning",
|
||||
hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 }));
|
||||
|
||||
const sensitive = tracked.filter(isSensitiveTrackedPath);
|
||||
checks.push(
|
||||
@@ -397,6 +453,16 @@ class GitValidatorService {
|
||||
});
|
||||
return { created: ".gitignore" };
|
||||
}
|
||||
if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) {
|
||||
const definition = check.fixAction === "add-gitattributes"
|
||||
? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES }
|
||||
: { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG };
|
||||
const target = path.join(root, definition.name);
|
||||
if (await fs.stat(target).catch(() => null))
|
||||
throw new Error(`${definition.name} already exists; rescan before repairing.`);
|
||||
await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" });
|
||||
return { created: definition.name };
|
||||
}
|
||||
if (check.fixAction === "protect-default-branch") {
|
||||
return this.gitea.createBranchProtection(
|
||||
repository.owner.login,
|
||||
@@ -411,6 +477,8 @@ class GitValidatorService {
|
||||
module.exports = {
|
||||
GitValidatorService,
|
||||
RECOMMENDED_GITIGNORE,
|
||||
RECOMMENDED_GITATTRIBUTES,
|
||||
RECOMMENDED_EDITORCONFIG,
|
||||
sameRemote,
|
||||
isSensitiveTrackedPath,
|
||||
};
|
||||
|
||||
@@ -226,6 +226,44 @@ class GiteaService {
|
||||
).data;
|
||||
}
|
||||
|
||||
async listDeployKeys(owner, repo) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
|
||||
);
|
||||
return Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
|
||||
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||
const key = String(publicKey || "").trim();
|
||||
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
|
||||
throw new Error("The server did not return a valid SSH public key.");
|
||||
const keys = await this.listDeployKeys(owner, repo);
|
||||
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
|
||||
const existing = keys.find((item) =>
|
||||
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.read_only !== true) {
|
||||
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
|
||||
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
|
||||
throw error;
|
||||
}
|
||||
return { ...existing, created: false };
|
||||
}
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
|
||||
key,
|
||||
read_only: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { ...result.data, created: true };
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
|
||||
+28
-1
@@ -859,6 +859,8 @@ function registerIpc({
|
||||
"align-origin",
|
||||
"configure-local-safety",
|
||||
"add-gitignore",
|
||||
"add-gitattributes",
|
||||
"add-editorconfig",
|
||||
"protect-default-branch",
|
||||
]);
|
||||
if (!allowed.has(check?.fixAction))
|
||||
@@ -1121,6 +1123,20 @@ function registerIpc({
|
||||
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 ({
|
||||
@@ -1176,7 +1192,7 @@ function registerIpc({
|
||||
},
|
||||
);
|
||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => {
|
||||
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,
|
||||
@@ -1187,6 +1203,17 @@ function registerIpc({
|
||||
});
|
||||
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:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
|
||||
+192
-18
@@ -64,6 +64,8 @@ function parseServerInventory(output) {
|
||||
checkouts: [],
|
||||
containers: [],
|
||||
dockerMan: [],
|
||||
composeProjects: [],
|
||||
composeDefinitions: [],
|
||||
warnings: [],
|
||||
};
|
||||
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
@@ -91,7 +93,7 @@ function parseServerInventory(output) {
|
||||
const parsed = safeJson(decodeBase64(parts[0]), null);
|
||||
if (!parsed) continue;
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0]));
|
||||
for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item));
|
||||
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
|
||||
else inventory.containers.push({
|
||||
...parsed,
|
||||
@@ -111,6 +113,32 @@ function parseServerInventory(output) {
|
||||
repository: decodeBase64(parts[5]),
|
||||
network: decodeBase64(parts[6]),
|
||||
});
|
||||
} else if (kind === 'P' && parts[0]) {
|
||||
const parsed = safeJson(decodeBase64(parts[0]), []);
|
||||
const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
||||
for (const project of projects) {
|
||||
const name = String(project?.Name || project?.name || '').trim();
|
||||
if (!name) continue;
|
||||
const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || [];
|
||||
const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(','))
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
inventory.composeProjects.push({
|
||||
name,
|
||||
status: String(project?.Status || project?.status || ''),
|
||||
configFiles,
|
||||
});
|
||||
}
|
||||
} else if (kind === 'Y' && parts[0]) {
|
||||
inventory.composeDefinitions.push({
|
||||
workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''),
|
||||
configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
projectName: decodeBase64(parts[2]).trim(),
|
||||
services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
valid: parts[5] === 'true',
|
||||
error: decodeBase64(parts[6]).trim(),
|
||||
});
|
||||
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
|
||||
}
|
||||
return inventory;
|
||||
@@ -154,6 +182,44 @@ function topLevelRelativeToBase(basePath, candidate) {
|
||||
return relative ? relative.split('/')[0] : '';
|
||||
}
|
||||
|
||||
function canonicalServerAppdataPath(basePath, candidate) {
|
||||
const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!value) return '';
|
||||
const bases = [...new Set([
|
||||
String(basePath || '').replace(/\/+$/, ''),
|
||||
'/mnt/user/appdata',
|
||||
'/mnt/cache/appdata',
|
||||
].filter(Boolean))];
|
||||
for (const base of bases) {
|
||||
const relative = safeRelativeToBase(base, value);
|
||||
if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`;
|
||||
if (value === base) return String(basePath || base).replace(/\/+$/, '');
|
||||
}
|
||||
const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i);
|
||||
if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function isDeploymentBackupPath(value) {
|
||||
const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return segments.some((segment) =>
|
||||
/^source-pre-[0-9a-f]{7,64}$/i.test(segment)
|
||||
|| /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment)
|
||||
|| ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentRootCandidate(relativePath) {
|
||||
const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const forgeFlowIndex = segments.indexOf('.forgeflow');
|
||||
if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/');
|
||||
const releasesIndex = segments.indexOf('releases');
|
||||
if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/');
|
||||
const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment));
|
||||
if (backupIndex > 0) return segments.slice(0, backupIndex).join('/');
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function workloadSelector(group) {
|
||||
if (group.composeProject) return {
|
||||
kind: 'compose',
|
||||
@@ -182,6 +248,7 @@ function profileMatchesWorkload(profile, workload) {
|
||||
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
|
||||
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
|
||||
}
|
||||
if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true;
|
||||
return workload.containers.some((container) => container.name === profile.containerName);
|
||||
}
|
||||
|
||||
@@ -198,11 +265,12 @@ function repositoryRemoteMap(repositories) {
|
||||
|
||||
function candidateRepositories(workload, repositories, checkouts) {
|
||||
const candidates = new Map();
|
||||
const add = (repository, points, reason, exact = false) => {
|
||||
const add = (repository, points, reason, exact = false, identityExact = false) => {
|
||||
if (!repository?.fullName) return;
|
||||
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] };
|
||||
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] };
|
||||
current.score += points;
|
||||
current.exact ||= exact;
|
||||
current.identityExact ||= identityExact;
|
||||
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
|
||||
candidates.set(repository.fullName, current);
|
||||
};
|
||||
@@ -230,31 +298,42 @@ function candidateRepositories(workload, repositories, checkouts) {
|
||||
const repository = remotes.get(id);
|
||||
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
|
||||
}
|
||||
const names = new Set([
|
||||
workload.compose.project,
|
||||
path.basename(workload.compose.workingDir || ''),
|
||||
...workload.containers.map((container) => container.name),
|
||||
const composeProjectName = normalizedName(workload.compose.project);
|
||||
const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || ''));
|
||||
const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]);
|
||||
const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean));
|
||||
const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean));
|
||||
const imageNames = new Set([
|
||||
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
|
||||
].filter(Boolean).map(normalizedName));
|
||||
...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]),
|
||||
].map(normalizedName).filter(Boolean));
|
||||
for (const repository of repositories || []) {
|
||||
const repoName = normalizedName(repository.name);
|
||||
if (!repoName || !names.has(repoName)) continue;
|
||||
add(repository, workload.compose.project && normalizedName(workload.compose.project) === repoName ? 35 : 20, 'Name similarity only; manual confirmation required');
|
||||
if (!repoName) continue;
|
||||
if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true);
|
||||
if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true);
|
||||
if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository');
|
||||
if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository');
|
||||
if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true);
|
||||
if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository');
|
||||
}
|
||||
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
|
||||
...candidate,
|
||||
reasons: candidate.exact
|
||||
? candidate.reasons
|
||||
: [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'],
|
||||
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
|
||||
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [item.name, item]));
|
||||
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), item]));
|
||||
const groups = new Map();
|
||||
for (const container of inventory.containers || []) {
|
||||
const labels = container.labels || {};
|
||||
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
|
||||
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/+$/, '');
|
||||
const configFiles = configFilesFor(container);
|
||||
const workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']);
|
||||
const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const key = composeProject
|
||||
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
|
||||
: `container:${container.name}`;
|
||||
@@ -263,15 +342,103 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [],
|
||||
images: [],
|
||||
containers: [],
|
||||
dockerMan: null,
|
||||
};
|
||||
group.containers.push(container);
|
||||
const service = String(labels['com.docker.compose.service'] || '').trim();
|
||||
if (service && !group.services.includes(service)) group.services.push(service);
|
||||
group.dockerMan ||= dockerManByName.get(container.name) || null;
|
||||
group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null;
|
||||
groups.set(key, group);
|
||||
}
|
||||
for (const project of inventory.composeProjects || []) {
|
||||
const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : '';
|
||||
const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`;
|
||||
if (groups.has(key)) continue;
|
||||
const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name);
|
||||
if (existingByProject) {
|
||||
if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles;
|
||||
if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir;
|
||||
continue;
|
||||
}
|
||||
groups.set(key, {
|
||||
composeProject: project.name,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [],
|
||||
images: [],
|
||||
containers: [],
|
||||
dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null,
|
||||
composeStatus: project.status || '',
|
||||
});
|
||||
}
|
||||
for (const definition of inventory.composeDefinitions || []) {
|
||||
const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
||||
const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : ''));
|
||||
if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue;
|
||||
const projectName = String(definition.projectName || path.basename(workingDir || '')).trim();
|
||||
const existing = [...groups.values()].find((group) => {
|
||||
if (workingDir && group.workingDir && group.workingDir === workingDir) return true;
|
||||
if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true;
|
||||
return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir));
|
||||
});
|
||||
if (existing) {
|
||||
existing.composeProject ||= projectName;
|
||||
existing.workingDir ||= workingDir;
|
||||
existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])];
|
||||
existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])];
|
||||
existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])];
|
||||
existing.composeDefinitionValid = definition.valid;
|
||||
existing.composeDefinitionError = definition.error || '';
|
||||
existing.composeSource = 'server-compose-file';
|
||||
continue;
|
||||
}
|
||||
const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`;
|
||||
groups.set(key, {
|
||||
composeProject: projectName,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [...new Set(definition.services || [])],
|
||||
images: [...new Set(definition.images || [])],
|
||||
containers: [],
|
||||
dockerMan: dockerManByName.get(projectName.toLowerCase()) || null,
|
||||
composeStatus: '',
|
||||
composeDefinitionValid: definition.valid,
|
||||
composeDefinitionError: definition.error || '',
|
||||
composeSource: 'server-compose-file',
|
||||
});
|
||||
}
|
||||
const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase()));
|
||||
for (const dockerMan of inventory.dockerMan || []) {
|
||||
const normalized = String(dockerMan.name || '').toLowerCase();
|
||||
if (!normalized || containerNames.has(normalized)) continue;
|
||||
const key = `container:${dockerMan.name}`;
|
||||
if (groups.has(key)) continue;
|
||||
groups.set(key, {
|
||||
composeProject: '',
|
||||
workingDir: '',
|
||||
configFiles: [],
|
||||
services: [],
|
||||
images: dockerMan.repository ? [dockerMan.repository] : [],
|
||||
containers: [{
|
||||
id: '',
|
||||
name: dockerMan.name,
|
||||
image: dockerMan.repository || '',
|
||||
imageId: '',
|
||||
running: false,
|
||||
status: 'template-only',
|
||||
health: null,
|
||||
labels: {},
|
||||
ports: {},
|
||||
mounts: [],
|
||||
networks: dockerMan.network ? { [dockerMan.network]: {} } : {},
|
||||
restartPolicy: '',
|
||||
}],
|
||||
dockerMan,
|
||||
});
|
||||
}
|
||||
const workloads = [];
|
||||
for (const group of groups.values()) {
|
||||
const selector = workloadSelector(group);
|
||||
@@ -279,8 +446,8 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
|
||||
const primary = group.containers.find((item) => item.running) || group.containers[0];
|
||||
const ports = group.containers.flatMap(containerPorts);
|
||||
const mounts = group.containers.flatMap((container) => container.mounts || []);
|
||||
const remoteFolderCandidate = safeRelativeToBase(server.basePath, group.workingDir)
|
||||
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, mount?.Source)).find(Boolean)
|
||||
const remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir)))
|
||||
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean)
|
||||
|| '';
|
||||
const workload = {
|
||||
workloadId,
|
||||
@@ -288,7 +455,7 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
|
||||
serverName: server.name,
|
||||
kind: selector.kind,
|
||||
selector,
|
||||
displayName: group.composeProject || primary?.name || 'Unnamed workload',
|
||||
displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload',
|
||||
compose: {
|
||||
project: group.composeProject,
|
||||
workingDir: group.workingDir,
|
||||
@@ -322,10 +489,15 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
|
||||
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
|
||||
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
|
||||
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
|
||||
composeStatus: group.composeStatus || '',
|
||||
images: [...new Set(group.images || [])],
|
||||
composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''),
|
||||
composeDefinitionValid: group.composeDefinitionValid !== false,
|
||||
composeDefinitionError: group.composeDefinitionError || '',
|
||||
},
|
||||
runtime: {
|
||||
running: group.containers.some((container) => container.running === true),
|
||||
allRunning: group.containers.every((container) => container.running === true),
|
||||
allRunning: group.containers.length > 0 && group.containers.every((container) => container.running === true),
|
||||
health: group.containers.some((container) => container.health === 'unhealthy')
|
||||
? 'unhealthy'
|
||||
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
|
||||
@@ -400,4 +572,6 @@ module.exports = {
|
||||
profileMatchesWorkload,
|
||||
sanitizeLegacyContainer,
|
||||
safeRelativeToBase,
|
||||
canonicalServerAppdataPath,
|
||||
deploymentRootCandidate,
|
||||
};
|
||||
|
||||
+1160
-388
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user