Proper Template Support for Stacks
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 04:09:50 -04:00
parent fb8f1c4fa4
commit 4bbbd45f42
4 changed files with 430 additions and 69 deletions
+165 -8
View File
@@ -404,9 +404,88 @@ export async function fetchFirstStackfile(urls, opts = {}) {
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.
* No-op for type-1 container templates that already include image.
* Also attaches full compose text for stack deploy when repository is present.
*
* @param {TemplateLike} template
* @param {{ signal?: AbortSignal, fetchImpl?: typeof fetch }} [opts]
@@ -416,20 +495,31 @@ export async function resolveTemplateForDeploy(template, opts = {}) {
if (!template || typeof template !== 'object') return template
const image = String(template.image || template.Image || '').trim()
if (image) {
const wantsStackFetch =
isStackTemplate(template) ||
Boolean(template.repository || template.Repository) ||
!image
if (image && !wantsStackFetch) {
return { ...template, image }
}
if (!isStackTemplate(template) && !template.repository) {
return template
// 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 a container image.',
'Stack template has no repository URL — cannot resolve compose or image.',
}
}
@@ -437,33 +527,96 @@ export async function resolveTemplateForDeploy(template, 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 {
...template,
_composeWarning: 'Compose file has no service with an image field.',
...base,
image: image || undefined,
_composeWarning: image
? undefined
: 'Compose file has no service with an image field.',
}
}
const merged = mergeServiceIntoTemplate(template, picked.service, picked.name)
// 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,
@@ -472,4 +625,8 @@ export default {
mergeServiceIntoTemplate,
fetchFirstStackfile,
resolveTemplateForDeploy,
templateEnvToEnvFile,
templateGitMeta,
templateStackName,
buildStackDeployPayload,
}