Release ForgeFlow 0.8.1
Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
@@ -97,6 +97,75 @@ function xmlEscape(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function decodeBase64Json(value, fallback) {
|
||||
try { return value ? JSON.parse(Buffer.from(value, 'base64').toString('utf8')) : fallback; }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
function parseDockerManXml(xml) {
|
||||
const text = String(xml || '');
|
||||
const tag = (name) => {
|
||||
const match = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'));
|
||||
return match ? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim() : '';
|
||||
};
|
||||
return { name: tag('Name'), webUiUrl: tag('WebUI'), iconUrl: tag('Icon'), shell: tag('Shell') };
|
||||
}
|
||||
|
||||
function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload }) {
|
||||
const compose = payload.compose || {};
|
||||
const services = compose.services && typeof compose.services === 'object' ? compose.services : {};
|
||||
const inspections = Array.isArray(payload.containers) ? payload.containers : [];
|
||||
const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null;
|
||||
const labels = primaryContainer?.Config?.Labels || {};
|
||||
const serviceName = labels['com.docker.compose.service'] || Object.keys(services)[0] || remoteFolder;
|
||||
const service = services[serviceName] || {};
|
||||
const containerName = String(primaryContainer?.Name || service.container_name || serviceName).replace(/^\//, '');
|
||||
const ports = [];
|
||||
for (const [containerKey, bindings] of Object.entries(primaryContainer?.NetworkSettings?.Ports || {})) {
|
||||
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
|
||||
const containerPort = Number(containerPortText) || null;
|
||||
if (Array.isArray(bindings) && bindings.length) {
|
||||
for (const binding of bindings) ports.push({ hostIp: binding.HostIp || '', hostPort: Number(binding.HostPort) || null, containerPort, protocol });
|
||||
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
|
||||
}
|
||||
const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {};
|
||||
const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false }));
|
||||
const networks = Object.keys(primaryContainer?.NetworkSettings?.Networks || {});
|
||||
const envNames = (primaryContainer?.Config?.Env || []).map((item) => String(item).split('=')[0]).filter(Boolean);
|
||||
const dockerMan = parseDockerManXml(payload.dockerManXml || '');
|
||||
const webUiUrl = dockerMan.webUiUrl || labels['net.unraid.docker.webui'] || '';
|
||||
const iconUrl = dockerMan.iconUrl || labels['net.unraid.docker.icon'] || '';
|
||||
const shell = dockerMan.shell || labels['net.unraid.docker.shell'] || '/bin/sh';
|
||||
const preservePaths = [...new Set([
|
||||
'.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml',
|
||||
...mounts.filter((item) => String(item.source || '').startsWith(`${remotePath}/`)).map((item) => String(item.source).slice(remotePath.length + 1).split('/')[0]).filter(Boolean)
|
||||
])];
|
||||
const source = (value, origin, confidence = 'confirmed') => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false });
|
||||
const composeFiles = payload.composeFiles || [];
|
||||
const composeFile = composeFiles[0] || labels['com.docker.compose.project.config_files']?.split(',')[0]?.replace(`${remotePath}/`, '') || 'docker-compose.yml';
|
||||
return {
|
||||
profile: {
|
||||
name: 'Production', environment: 'production', provider: 'ssh-unraid', branch: payload.branch || repository.defaultBranch || 'main',
|
||||
serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || '', alignRemote: false,
|
||||
generatedCompose: false, composeFile, composeService: serviceName, containerName,
|
||||
hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null,
|
||||
webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? 'url' : 'none', iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : '', serverIconReference: iconUrl, iconFilePath: '', dockerShell: ['/bin/bash','/bin/sh'].includes(shell) ? shell : '/bin/sh',
|
||||
healthcheckUrl: '', preservePaths, confirmationRequired: true,
|
||||
adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(),
|
||||
detectedMetadata: { head: payload.head || null, composeProject: labels['com.docker.compose.project'] || '', composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || '', healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || '', dockerMan }
|
||||
},
|
||||
provenance: {
|
||||
remoteFolder: source(remoteFolder, 'server-path'), cloneUrl: source(payload.remote || '', 'git-origin'), branch: source(payload.branch || '', 'git'),
|
||||
composeFile: source(composeFile, 'docker-compose'), composeService: source(serviceName, 'docker-labels'), containerName: source(containerName, 'docker-inspect'),
|
||||
hostPort: source(primaryPort.hostPort || null, 'docker-inspect'), containerPort: source(primaryPort.containerPort || null, 'docker-inspect'),
|
||||
webUiUrl: source(webUiUrl, dockerMan.webUiUrl ? 'unraid-dockerman' : 'docker-labels'), iconUrl: source(iconUrl, dockerMan.iconUrl ? 'unraid-dockerman' : 'docker-labels'), dockerShell: source(shell, dockerMan.shell ? 'unraid-dockerman' : 'docker-labels')
|
||||
},
|
||||
runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames }
|
||||
};
|
||||
}
|
||||
|
||||
function iconReferenceLocalPath(iconReference) {
|
||||
const value = String(iconReference || '').trim();
|
||||
if (value.startsWith('file:///')) return `/${value.slice('file:///'.length)}`;
|
||||
@@ -131,6 +200,64 @@ class UnraidDeploymentService {
|
||||
return { profile, 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);
|
||||
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; }
|
||||
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)
|
||||
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' \\) -printf '%P\\n' 2>/dev/null | sort)
|
||||
compose_file=$(printf '%s\\n' "$compose_files" | head -n1)
|
||||
compose_json='{}'
|
||||
container_json='[]'
|
||||
if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then
|
||||
compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}')
|
||||
ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true)
|
||||
[ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]')
|
||||
fi
|
||||
container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1)
|
||||
dockerman_xml=''
|
||||
if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then
|
||||
template=$(grep -ril "<Name>${container_name}</Name>" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true)
|
||||
[ -n "$template" ] && dockerman_xml=$(cat "$template")
|
||||
fi
|
||||
printf '__FORGEFLOW_DISCOVERY__\\n'
|
||||
printf 'head=%s\\n' "$head"
|
||||
printf 'branch=%s\\n' "$branch"
|
||||
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
|
||||
printf 'composeFiles=%s\\n' "$(printf '%s\\n' "$compose_files" | base64 | tr -d '\\r\\n')"
|
||||
printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n')"
|
||||
printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')"
|
||||
printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 90_000, maxOutput: 8 * 1024 * 1024 });
|
||||
const marker = '__FORGEFLOW_DISCOVERY__';
|
||||
const index = result.stdout.lastIndexOf(marker);
|
||||
if (index < 0) throw new Error('The server did not return deployment discovery data.');
|
||||
const fields = {};
|
||||
for (const line of result.stdout.slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
const split = line.indexOf('='); if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1);
|
||||
}
|
||||
const payload = {
|
||||
head: fields.head || null, branch: fields.branch || null,
|
||||
remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : '',
|
||||
composeFiles: fields.composeFiles ? Buffer.from(fields.composeFiles, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : [],
|
||||
compose: decodeBase64Json(fields.compose, {}), containers: decodeBase64Json(fields.containers, []),
|
||||
dockerManXml: fields.dockerManXml ? Buffer.from(fields.dockerManXml, 'base64').toString('utf8') : ''
|
||||
};
|
||||
const discovery = deriveDetectedProfile({ repository, server, remoteFolder: folder, remotePath, payload });
|
||||
await this.diagnostics?.info('unraid.existing-discovered', { repository: repository.fullName, serverId, remotePath, containers: discovery.runtime.containers, services: discovery.runtime.services });
|
||||
return discovery;
|
||||
}
|
||||
|
||||
async inspect({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
|
||||
@@ -906,5 +1033,8 @@ module.exports = {
|
||||
checksSummary,
|
||||
xmlEscape,
|
||||
iconReferenceLocalPath,
|
||||
decodeBase64Json,
|
||||
parseDockerManXml,
|
||||
deriveDetectedProfile,
|
||||
bash
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user