/** * Resolve Portainer-style app templates into a form-ready shape. * * Lissy93 / Portainer catalogs mix: * type 1 — container (has `image`) * type 2 — swarm stack (repository + compose, rarely has image) * type 3 — compose stack (repository + stackfile, almost never has image) * * Stacks only reference a compose file in a Git repo. Without resolving that * file, the deploy form opens with an empty Image field. */ import yaml from 'js-yaml' /** @typedef {{ image?: string, ports?: string[], volumes?: Array, env?: Array, restart_policy?: string, command?: string|string[], entrypoint?: string|string[], hostname?: string, network?: string, privileged?: boolean, labels?: object|Array, name?: string, title?: string, type?: number, repository?: object, _composeResolved?: boolean, _composeService?: string, _composeWarning?: string }} TemplateLike */ const DB_SERVICE_RE = /^(postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|memcached|rabbitmq|elasticsearch|opensearch|meilisearch|minio|db|database|mariadb|clickhouse|cassandra|couchdb|influxdb|neo4j)([-_]|$)/i /** * @param {unknown} template * @returns {boolean} */ export function isStackTemplate(template) { if (!template || typeof template !== 'object') return false const t = /** @type {TemplateLike} */ (template) const typeNum = Number(t.type) if (typeNum === 2 || typeNum === 3) return true const image = String(t.image || '').trim() const repo = t.repository const repoUrl = (repo && typeof repo === 'object' && (repo.url || repo.URL)) || (typeof repo === 'string' ? repo : '') || t.repo || '' return !image && Boolean(String(repoUrl || '').trim()) } /** * Build candidate raw content URLs for a Portainer repository descriptor. * @param {object|string|null|undefined} repository * @returns {string[]} */ export function stackfileRawUrls(repository) { if (!repository) return [] let url = '' let stackfile = 'docker-compose.yml' if (typeof repository === 'string') { url = repository.trim() } else if (typeof repository === 'object') { url = String(repository.url || repository.URL || repository.repo || '').trim() stackfile = String( repository.stackfile || repository.Stackfile || repository.composeFile || repository.composefile || 'docker-compose.yml' ).trim() || 'docker-compose.yml' } if (!url) return [] // Already a raw file URL if (/raw\.githubusercontent\.com\//i.test(url) || /cdn\.jsdelivr\.net\//i.test(url)) { return [url] } // github.com/owner/repo[/tree|/blob]/branch]/path] const gh = url.match( /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?\/?$/i ) if (gh) { const owner = gh[1] const repo = gh[2] const branchFromUrl = gh[3] const pathFromUrl = gh[4] ? gh[4].replace(/\/$/, '') : '' const filePath = pathFromUrl ? `${pathFromUrl}/${stackfile}`.replace(/\/+/g, '/') : stackfile.replace(/^\//, '') const branches = branchFromUrl ? [branchFromUrl] : ['master', 'main'] const out = [] for (const branch of branches) { out.push( `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}` ) out.push( `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${filePath}` ) } return out } // gitlab.com/group/project/-/raw/branch/file const gl = url.match(/^https?:\/\/gitlab\.com\/(.+?)(?:\.git)?\/?$/i) if (gl) { const project = gl[1].replace(/\/$/, '') const filePath = stackfile.replace(/^\//, '') return [ `https://gitlab.com/${project}/-/raw/master/${filePath}`, `https://gitlab.com/${project}/-/raw/main/${filePath}`, ] } // Fallback: join URL + stackfile const base = url.replace(/\/$/, '') return [`${base}/${stackfile.replace(/^\//, '')}`] } /** * @param {string} composeText * @returns {{ version: string|null, services: Record }} */ export function parseComposeServices(composeText) { if (!composeText || typeof composeText !== 'string') { throw new Error('Compose content is empty') } let raw try { raw = yaml.load(composeText, { schema: yaml.DEFAULT_SCHEMA }) } catch (err) { throw new Error(`Invalid compose YAML: ${err?.message || err}`) } if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Compose file must be a YAML mapping') } const servicesIn = raw.services if (!servicesIn || typeof servicesIn !== 'object' || Array.isArray(servicesIn)) { throw new Error('Compose file must define a "services" mapping') } /** @type {Record} */ const services = {} for (const [name, def] of Object.entries(servicesIn)) { if (!def || typeof def !== 'object') continue services[name] = normalizeComposeService(name, def) } return { version: raw.version != null ? String(raw.version) : null, services, } } /** * @param {string} name * @param {object} def */ function normalizeComposeService(name, def) { return { name, image: def.image != null ? String(def.image).trim() : '', container_name: def.container_name != null ? String(def.container_name).trim() : def.containerName != null ? String(def.containerName).trim() : '', ports: normalizeComposePorts(def.ports), volumes: normalizeComposeVolumes(def.volumes), environment: normalizeComposeEnv(def.environment), restart: def.restart != null ? String(def.restart) : '', command: def.command ?? null, entrypoint: def.entrypoint ?? null, hostname: def.hostname != null ? String(def.hostname) : '', privileged: def.privileged === true, network_mode: def.network_mode || def.networkMode || null, labels: def.labels ?? null, working_dir: def.working_dir || def.workingDir || null, } } function normalizeComposePorts(ports) { if (!ports) return [] if (!Array.isArray(ports)) return [String(ports)] return ports .map((p) => { if (p == null) return null if (typeof p === 'number') return `${p}/tcp` if (typeof p === 'string') return p.trim() || null if (typeof p === 'object') { const target = p.target ?? p.container ?? p.containerPort const published = p.published ?? p.host ?? p.hostPort ?? p.public const proto = String(p.protocol || 'tcp').toLowerCase() if (target == null) return null return published != null && published !== '' ? `${published}:${target}/${proto}` : `${target}/${proto}` } return String(p) }) .filter(Boolean) } function normalizeComposeVolumes(volumes) { if (!volumes) return [] if (!Array.isArray(volumes)) return [] return volumes .map((v) => { if (v == null) return null if (typeof v === 'string') { // host:container[:mode] const parts = v.split(':') if (parts.length >= 2) { return { bind: parts[0], container: parts[1], mode: parts[2] || 'rw', } } return { container: parts[0], bind: '' } } if (typeof v === 'object') { const container = v.target || v.container || v.Destination || '' const bind = v.source || v.bind || v.host || v.Source || '' const readonly = v.read_only === true || v.readonly === true if (!container) return null return { container, bind, mode: readonly ? 'ro' : 'rw' } } return null }) .filter(Boolean) } function normalizeComposeEnv(env) { if (!env) return [] if (Array.isArray(env)) { return env .map((e) => { if (e == null) return null if (typeof e === 'string') { const i = e.indexOf('=') const name = (i >= 0 ? e.slice(0, i) : e).trim() const value = i >= 0 ? e.slice(i + 1) : '' if (!name) return null return { name, label: name, default: value } } if (typeof e === 'object' && (e.name || e.Name)) { return { name: e.name || e.Name, label: e.label || e.Label || e.name || e.Name, default: e.default ?? e.value ?? e.set ?? '', } } return null }) .filter(Boolean) } if (typeof env === 'object') { return Object.entries(env).map(([name, value]) => ({ name, label: name, default: value == null ? '' : String(value), })) } return [] } /** * Pick the "app" service from a multi-service compose (skip DBs when possible). * @param {Record} services * @param {string} [nameHint] * @returns {{ name: string, service: object }|null} */ export function pickPrimaryComposeService(services, nameHint = '') { const entries = Object.entries(services || {}).filter( ([, s]) => s && String(s.image || '').trim() ) if (!entries.length) return null const slug = String(nameHint || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '') if (slug) { const byName = entries.find(([n, s]) => { const ns = n.toLowerCase().replace(/[^a-z0-9]+/g, '') const cn = String(s.container_name || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '') return ns === slug || cn === slug || ns.includes(slug) || slug.includes(ns) }) if (byName) return { name: byName[0], service: byName[1] } } const nonDb = entries.filter(([n]) => !DB_SERVICE_RE.test(n)) const withPorts = nonDb.filter(([, s]) => Array.isArray(s.ports) && s.ports.length > 0) if (withPorts.length) return { name: withPorts[0][0], service: withPorts[0][1] } if (nonDb.length) return { name: nonDb[0][0], service: nonDb[0][1] } return { name: entries[0][0], service: entries[0][1] } } /** * Overlay compose primary service onto a Portainer template object. * @param {TemplateLike} template * @param {object} service * @param {string} serviceName * @returns {TemplateLike} */ export function mergeServiceIntoTemplate(template, service, serviceName) { if (!service || !service.image) return { ...template } const labels = service.labels let labelsOut = template.labels if (labels && typeof labels === 'object' && !Array.isArray(labels)) { labelsOut = Object.entries(labels).map(([name, value]) => ({ name, value: value == null ? '' : String(value), })) } // Template env wins for defaults the catalog authored; compose fills gaps const templateEnv = Array.isArray(template.env) ? template.env : [] const composeEnv = Array.isArray(service.environment) ? service.environment : [] const envByName = new Map() for (const e of composeEnv) { if (e?.name) envByName.set(String(e.name), e) } for (const e of templateEnv) { const name = e?.name || e?.Name if (!name) continue const prev = envByName.get(String(name)) || {} envByName.set(String(name), { ...prev, ...e, name: String(name), label: e.label || e.Label || prev.label || name, default: e.default !== undefined ? e.default : e.set !== undefined ? e.set : e.value !== undefined ? e.value : prev.default ?? '', }) } const volumes = Array.isArray(template.volumes) && template.volumes.length ? template.volumes : service.volumes || [] const ports = Array.isArray(template.ports) && template.ports.length ? template.ports : service.ports || [] return { ...template, image: service.image, name: service.container_name || template.name || template.title || serviceName, ports, volumes, env: [...envByName.values()], restart_policy: template.restart_policy || service.restart || template.restartPolicy || 'unless-stopped', command: template.command ?? service.command ?? undefined, entrypoint: template.entrypoint ?? service.entrypoint ?? undefined, hostname: template.hostname || service.hostname || undefined, network: template.network || service.network_mode || undefined, privileged: template.privileged === true || service.privileged === true ? true : template.privileged, labels: labelsOut, workingDir: template.workingDir || service.working_dir || undefined, _composeResolved: true, _composeService: serviceName, } } /** * Fetch first successful raw stackfile URL. * @param {string[]} urls * @param {{ signal?: AbortSignal, fetchImpl?: typeof fetch }} [opts] * @returns {Promise<{ url: string, text: string }|null>} */ export async function fetchFirstStackfile(urls, opts = {}) { const fetchImpl = opts.fetchImpl || globalThis.fetch if (typeof fetchImpl !== 'function') return null for (const url of urls || []) { try { const res = await fetchImpl(url, { signal: opts.signal, headers: { Accept: 'text/plain, application/x-yaml, text/yaml, */*' }, }) if (!res.ok) continue const text = await res.text() if (text && /services\s*:/i.test(text)) { return { url, text } } } catch { // try next } } return null } /** * Build a compose `--env-file` body from Portainer template env entries. * @param {unknown} envRaw * @returns {string} */ export function templateEnvToEnvFile(envRaw) { if (envRaw == null) return '' const lines = [] const list = Array.isArray(envRaw) ? envRaw : [envRaw] for (const e of list) { if (e == null) continue if (typeof e === 'string') { const s = e.trim() if (s) lines.push(s.includes('=') ? s : `${s}=`) continue } if (typeof e === 'object') { const name = String(e.name || e.Name || '').trim() if (!name) continue let value = e.default !== undefined && e.default !== null ? e.default : e.set !== undefined && e.set !== null ? e.set : e.value !== undefined && e.value !== null ? e.value : '' // Portainer select: pick option marked default:true when no scalar default if ((value === '' || value == null) && Array.isArray(e.select)) { const defOpt = e.select.find( (o) => o && typeof o === 'object' && (o.default === true || o.default === 'true') ) if (defOpt) value = defOpt.value ?? '' } lines.push(`${name}=${value == null ? '' : String(value)}`) } } return lines.join('\n') } /** * Git metadata for stack deploy / GitOps fields. * @param {TemplateLike} template * @returns {{ repoUrl: string, stackfile: string, ref: string }} */ export function templateGitMeta(template) { const repo = template?.repository || template?.Repository || {} let repoUrl = '' let stackfile = 'docker-compose.yml' if (typeof repo === 'string') { repoUrl = repo.trim() } else if (repo && typeof repo === 'object') { repoUrl = String(repo.url || repo.URL || repo.repo || '').trim() stackfile = String( repo.stackfile || repo.Stackfile || repo.composeFile || 'docker-compose.yml' ).trim() || 'docker-compose.yml' } // Prefer .git URL for clone when github https without .git let cloneUrl = repoUrl if (/^https?:\/\/github\.com\/[^/]+\/[^/]+\/?$/i.test(repoUrl)) { cloneUrl = repoUrl.replace(/\/?$/, '') + '.git' } return { repoUrl: cloneUrl || repoUrl, stackfile, ref: 'main' } } /** * Sanitize stack / container name from template title. * @param {TemplateLike} template * @returns {string} */ export function templateStackName(template) { const raw = String(template?.name || template?.title || 'stack') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 63) return raw || 'stack' } /** * Ensure a template has an image (and optional ports/volumes/env) for the container form. * Also attaches full compose text for stack deploy when repository is present. * * @param {TemplateLike} template * @param {{ signal?: AbortSignal, fetchImpl?: typeof fetch }} [opts] * @returns {Promise} */ export async function resolveTemplateForDeploy(template, opts = {}) { if (!template || typeof template !== 'object') return template const image = String(template.image || template.Image || '').trim() const wantsStackFetch = isStackTemplate(template) || Boolean(template.repository || template.Repository) || !image if (image && !wantsStackFetch) { return { ...template, image } } // Type-1 with image: still return as-is (no compose fetch) if (image && Number(template.type) === 1) { return { ...template, image } } if (!template.repository && !template.Repository && !isStackTemplate(template)) { return image ? { ...template, image } : template } const urls = stackfileRawUrls(template.repository || template.Repository) if (!urls.length) { return { ...template, image: image || undefined, _composeWarning: 'Stack template has no repository URL — cannot resolve compose or image.', } } const fetched = await fetchFirstStackfile(urls, opts) if (!fetched) { return { ...template, image: image || undefined, _composeWarning: `Could not download compose file from ${urls[0]}`, } } try { const { services } = parseComposeServices(fetched.text) const serviceNames = Object.keys(services) const hint = String(template.name || template.title || '') const picked = pickPrimaryComposeService(services, hint) const base = { ...template, _composeText: fetched.text, _composeSourceUrl: fetched.url, _serviceCount: serviceNames.length, _serviceNames: serviceNames, } if (!picked?.service?.image) { return { ...base, image: image || undefined, _composeWarning: image ? undefined : 'Compose file has no service with an image field.', } } // Prefer catalog image if present; otherwise primary service image for container path if (image) { return { ...base, image, _composeService: picked.name } } const merged = mergeServiceIntoTemplate(base, picked.service, picked.name) return { ...merged, _composeText: fetched.text, _composeSourceUrl: fetched.url, _serviceCount: serviceNames.length, _serviceNames: serviceNames, } } catch (err) { return { ...template, image: image || undefined, _composeWarning: err?.message || String(err), } } } /** * Payload for the Stacks deploy modal from a Portainer stack template. * @param {TemplateLike} template — preferably after resolveTemplateForDeploy * @returns {{ stackName: string, composeContent: string, envFileContent: string, repoUrl: string, ref: string, composePath: string, note: string, serviceCount: number, ok: boolean, error?: string }} */ export function buildStackDeployPayload(template) { const stackName = templateStackName(template) const git = templateGitMeta(template) const composeContent = String(template._composeText || '').trim() const envFileContent = templateEnvToEnvFile(template.env || template.Env) const note = String(template.note || template.Note || '').trim() if (!composeContent) { return { stackName, composeContent: '', envFileContent, repoUrl: git.repoUrl, ref: git.ref, composePath: git.stackfile, note, serviceCount: Number(template._serviceCount) || 0, ok: false, error: template._composeWarning || 'No compose content available — fetch the stackfile first.', } } return { stackName, composeContent, envFileContent, repoUrl: git.repoUrl, ref: git.ref, composePath: git.stackfile, note, serviceCount: Number(template._serviceCount) || 0, ok: true, } } export default { isStackTemplate, stackfileRawUrls, parseComposeServices, pickPrimaryComposeService, mergeServiceIntoTemplate, fetchFirstStackfile, resolveTemplateForDeploy, templateEnvToEnvFile, templateGitMeta, templateStackName, buildStackDeployPayload, }