Files
peardock/client/templateResolve.js
T

633 lines
20 KiB
JavaScript

/**
* 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<object|string>, env?: Array<object|string>, 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<string, object> }}
*/
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<string, object>} */
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<string, object>} 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<TemplateLike>}
*/
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,
}