This commit is contained in:
+165
-8
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user