forked from snxraven/peardock
633 lines
20 KiB
JavaScript
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,
|
|
}
|