This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a template has an image (and optional ports/volumes/env) for the container form.
|
||||
* No-op for type-1 container templates that already include image.
|
||||
*
|
||||
* @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()
|
||||
if (image) {
|
||||
return { ...template, image }
|
||||
}
|
||||
|
||||
if (!isStackTemplate(template) && !template.repository) {
|
||||
return template
|
||||
}
|
||||
|
||||
const urls = stackfileRawUrls(template.repository || template.Repository)
|
||||
if (!urls.length) {
|
||||
return {
|
||||
...template,
|
||||
_composeWarning:
|
||||
'Stack template has no repository URL — cannot resolve a container image.',
|
||||
}
|
||||
}
|
||||
|
||||
const fetched = await fetchFirstStackfile(urls, opts)
|
||||
if (!fetched) {
|
||||
return {
|
||||
...template,
|
||||
_composeWarning: `Could not download compose file from ${urls[0]}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { services } = parseComposeServices(fetched.text)
|
||||
const hint = String(template.name || template.title || '')
|
||||
const picked = pickPrimaryComposeService(services, hint)
|
||||
if (!picked?.service?.image) {
|
||||
return {
|
||||
...template,
|
||||
_composeWarning: 'Compose file has no service with an image field.',
|
||||
}
|
||||
}
|
||||
const merged = mergeServiceIntoTemplate(template, picked.service, picked.name)
|
||||
return {
|
||||
...merged,
|
||||
_composeSourceUrl: fetched.url,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
...template,
|
||||
_composeWarning: err?.message || String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
isStackTemplate,
|
||||
stackfileRawUrls,
|
||||
parseComposeServices,
|
||||
pickPrimaryComposeService,
|
||||
mergeServiceIntoTemplate,
|
||||
fetchFirstStackfile,
|
||||
resolveTemplateForDeploy,
|
||||
}
|
||||
Reference in New Issue
Block a user