diff --git a/src/schemas/gitlab-ci.js b/src/schemas/gitlab-ci.js new file mode 100644 index 0000000..3aae4f9 --- /dev/null +++ b/src/schemas/gitlab-ci.js @@ -0,0 +1,428 @@ +// GitLab CI 流水线代码生成器 for ConfTemplate +// 生成符合 .gitlab-ci.yml v14+ 规范的声明式流水线 YAML + +export const gitlabCiSchema = { + id: 'gitlab-ci', + name: 'GitLab CI', + icon: 'Files', + category: 'CI/CD', + description: '生成 .gitlab-ci.yml 声明式流水线代码', + format: 'yaml', + fileName: '.gitlab-ci.yml', + groups: [ + // ─────────────── 全局变量 ─────────────── + { + title: '全局变量', + fields: [ + { key: 'stagesEnabled', label: '自定义 stages', type: 'switch', default: true, + tip: '关闭则使用 GitLab 默认 stages (build / test / deploy)' }, + { key: 'stages', label: 'Stages 顺序', type: 'text', + default: 'lint,build,test,deploy', + placeholder: 'lint,build,test,deploy', + dependsOn: { key: 'stagesEnabled', value: true }, + tip: '逗号分隔,按执行顺序排列' }, + { key: 'defaultImage', label: '默认镜像', type: 'text', + default: 'alpine:3.20', + tip: '未在 job 中指定 image 时使用' }, + { key: 'variablesMode', label: '全局变量', type: 'keyvalue', default: [], + tip: '所有 job 都可用的 CI/CD 变量,例如 PROJECT_NAME / DOCKER_REGISTRY' }, + { key: 'workflowPolicy', label: 'workflow 策略', type: 'select', default: 'auto', options: [ + { label: 'auto (CI 默认可跑)', value: 'auto' }, + { label: 'always (任何情况都跑)', value: 'always' }, + { label: 'on_success (上游成功才跑)', value: 'on_success' }, + { label: 'manual (需要手动触发下游)', value: 'manual' }, + { label: 'none (禁止流水线,不允许触发)', value: 'none' }, + ], tip: 'GitLab Premium 有 start_in / rules 输入参数' }, + { key: 'workflowInputs', label: 'workflow:inputs 声明', type: 'code', + language: 'yaml', default: '', + tip: '可选:手动指定 workflow:inputs: 示例 → 用 [[inputs]] 上半部 YAML 多行块;留空跳过' }, + { key: 'includeMode', label: 'Include 模板块(高级)', type: 'code', + language: 'yaml', default: '', + tip: '需要 local / template / remote 引用模板时直接在这里补全完整 include 块;留空则不输出' }, + ], + }, + // ─────────────── 构建 Job ─────────────── + { + title: 'Job · lint (静态检查)', + fields: [ + { key: 'enableLint', label: '启用 lint 阶段', type: 'switch', default: true, + tip: '关闭则完全不渲染 lint job(连 stage 都没出现)' }, + { key: 'lintName', label: 'Job 名', type: 'text', default: 'lint', + dependsOn: { key: 'enableLint', value: true } }, + { key: 'lintImage', label: '镜像', type: 'text', default: 'alpine:3.20', + dependsOn: { key: 'enableLint', value: true } }, + { key: 'lintStage', label: 'Stage', type: 'text', default: 'lint', + dependsOn: { key: 'enableLint', value: true } }, + { key: 'lintScript', label: 'script', type: 'code', language: 'shell', default: 'echo "🔍 running lint"\n# npm run lint\n# ruff check .\n# shellcheck *.sh', + dependsOn: { key: 'enableLint', value: true } }, + { key: 'lintAllowFailure', label: 'allow_failure', type: 'switch', default: true, + dependsOn: { key: 'enableLint', value: true } }, + ], + }, + { + title: 'Job · build (打包产物)', + fields: [ + { key: 'enableBuild', label: '启用 build 阶段', type: 'switch', default: true }, + { key: 'buildName', label: 'Job 名', type: 'text', default: 'build', + dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildImage', label: '镜像', type: 'text', + default: 'docker:27-cli', + dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildStage', label: 'Stage', type: 'text', default: 'build', + dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildServices', label: 'services (附带容器)', type: 'keyvalue', default: [ + { key: 'docker:dind', value: '' } + ], dependsOn: { key: 'enableBuild', value: true }, + tip: '仅当需要 DinD / docker socket 时配置,留空则禁用' }, + { key: 'buildScript', label: 'script', type: 'code', language: 'shell', + default: + 'echo "📦 building $CI_PROJECT_NAME"\n# 添加 Dockerfile / 构建脚本\ndocker login -u "$REGISTRY_USER" -p "$REGISTRY_PASS" "$CI_REGISTRY"\ndocker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .\ndocker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"', + dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildArtifacts', label: 'artifacts.paths', type: 'text', + default: 'dist/\nbuild/', + dependsOn: { key: 'enableBuild', value: true }, tip: '多个路径用换行分隔' }, + { key: 'buildArtifactsExpire', label: 'artifacts.expire_in', type: 'text', + default: '1 week', dependsOn: { key: 'enableBuild', value: true } }, + ], + }, + { + title: 'Job · test (自动化测试)', + fields: [ + { key: 'enableTest', label: '启用 test 阶段', type: 'switch', default: true }, + { key: 'testName', label: 'Job 名', type: 'text', default: 'test', + dependsOn: { key: 'enableTest', value: true } }, + { key: 'testImage', label: '镜像', type: 'text', + default: 'python:3.12-slim', + dependsOn: { key: 'enableTest', value: true } }, + { key: 'testStage', label: 'Stage', type: 'text', default: 'test', + dependsOn: { key: 'enableTest', value: true } }, + { key: 'testParallel', label: 'parallel (矩阵)', type: 'number', + min: 1, max: 50, default: 1, dependsOn: { key: 'enableTest', value: true } }, + { key: 'testCoverage', label: 'coverage 输入正则', type: 'text', + default: '', dependsOn: { key: 'enableTest', value: true }, + tip: '如 /\\d+\\.\\d+%/,空则不上报 coverage' }, + { key: 'testScript', label: 'script', type: 'code', language: 'shell', + default: + 'echo "🧪 running tests"\n# pytest --junitxml=report.xml --cov=src --cov-report=term\n# 收集 junit 报告供 GitLab UI 展示\n', + dependsOn: { key: 'enableTest', value: true } }, + { key: 'testArtifacts', label: 'artifacts.when/paths', type: 'text', + default: 'report.xml', dependsOn: { key: 'enableTest', value: true }, + tip: '仅当有 junit/codecov 产出时填写' }, + ], + }, + // ─────────────── Deploy 阶段(多环境)─────────────── + { + title: 'Job · deploy (多环境)', + fields: [ + { key: 'enableDeploy', label: '启用 deploy 阶段', type: 'switch', default: true }, + { key: 'deployImage', label: '镜像', type: 'text', + default: 'alpine:3.20', + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployStage', label: 'Stage', type: 'text', default: 'deploy', + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployEnvironment', label: 'Deploy 环境 (Environment)', type: 'text', + default: 'production', + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployNeeds', label: 'needs (上游 job)', type: 'text', + default: 'build', dependsOn: { key: 'enableDeploy', value: true }, + tip: '逗号分隔多个 job 名' }, + { key: 'deployScript', label: 'script', type: 'code', language: 'shell', + default: 'echo "🚀 deploying $CI_PROJECT_NAME"\n# kubectl --context=prod apply -f k8s/\n# 或 ssh 推送 / helm upgrade --install', + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployWhen', label: 'rules.only/except 分支', type: 'text', + default: 'main', dependsOn: { key: 'enableDeploy', value: true }, + tip: '默认仅 main 部署;输入 main 或 main,master 支持多分支' }, + { key: 'deployWhenMode', label: '匹配模式', type: 'select', default: 'branches', options: [ + { label: 'branches (指定分支)', value: 'branches' }, + { label: 'tags (打 tag 后)', value: 'tags' }, + { label: 'merge_requests (MR)', value: 'merge_requests' }, + { label: 'schedules (定时)', value: 'schedules' }, + { label: 'web (Web 触发)', value: 'web' }, + ], dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployManual', label: 'manual gate (人工点击部署)', type: 'switch', + default: true, dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployAllowFailure', label: 'allow_failure (失败也继续)', type: 'switch', + default: false, dependsOn: { key: 'enableDeploy', value: true } }, + ], + }, + // ─────────────── 缓存 ─────────────── + { + title: '缓存与依赖', + fields: [ + { key: 'cacheKeyPrefix', label: 'cache.key 前缀', type: 'text', + default: '$CI_COMMIT_REF_SLUG', + tip: '留空则不输出 cache 块' }, + { key: 'cachePaths', label: 'cache.paths', type: 'text', + default: 'node_modules/\n.virtualenvs/\n.go/pkg/mod/\n.gradle/caches/\n.cache/', + tip: '一行一个路径,留空不输出 cache 块' }, + { key: 'cachePolicy', label: 'cache.policy', type: 'select', default: 'pull-push', options: [ + { label: 'pull-push (上传下载)', value: 'pull-push' }, + { label: 'pull (仅下载)', value: 'pull' }, + { label: 'push (仅上传)', value: 'push' }, + ] }, + ], + }, + // ─────────────── Runner Tag ─────────────── + { + title: 'Runner Tags(runner 选择)', + fields: [ + { key: 'defaultTags', label: '默认 tags (在 default 段输出)', + type: 'text', default: '', tip: '逗号分隔;空则不输出 default.tags' }, + ], + }, + ], +} + +// ===================================================================== +// YAML 渲染(GitLab CI 是 YAML,和 toYaml 共用修正后版本) +// 数组里嵌对象要保证 list 形态,不能 dict 化 +// ===================================================================== + +function yamlScalar(v) { + if (v === null || v === undefined) return '' + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + const s = String(v) + if (/[:#\n"'`]|^[\s\-]|[\s]$/.test(s) || /^(true|false|null|yes|no|on|off|~)$/i.test(s)) { + return JSON.stringify(s) + } + return s +} + +function isEmptyMapping(o) { + if (!o || typeof o !== 'object' || Array.isArray(o)) return false + return Object.values(o).every(v => + v === null || v === undefined || v === '' || + (typeof v === 'object' && isEmptyMapping(v))) +} + +function toYaml(obj, baseIndent = '') { + const lines = [] + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined || value === '') continue + if (Array.isArray(value)) { + const items = value.filter(v => v !== null && v !== undefined && v !== '') + if (!items.length) continue + lines.push(`${baseIndent}${key}:`) + for (const item of items) { + if (item && typeof item === 'object' && !Array.isArray(item)) { + if (isEmptyMapping(item)) continue + const entries = Object.entries(item).filter(([, v]) => + v !== null && v !== undefined && v !== '') + if (!entries.length) continue + const childIndent = baseIndent + ' ' + const subAllIndent = childIndent + ' ' + const subObj = {} + for (const [k, v] of entries) subObj[k] = v + const subRendered = toYaml(subObj, subAllIndent) + const subLines = subRendered.split('\n') + if (subLines[0].startsWith(subAllIndent)) { + subLines[0] = childIndent + '- ' + subLines[0].slice(subAllIndent.length) + } else { + subLines[0] = childIndent + '- ' + subLines[0].trimStart() + } + lines.push(...subLines) + } else { + lines.push(`${baseIndent} - ${yamlScalar(item)}`) + } + } + } else if (typeof value === 'object') { + if (isEmptyMapping(value)) { + lines.push(`${baseIndent}${key}: {}`) + continue + } + lines.push(`${baseIndent}${key}:`) + const childIndent = baseIndent + ' ' + lines.push(toYaml(value, childIndent)) + } else { + lines.push(`${baseIndent}${key}: ${yamlScalar(value)}`) + } + } + return lines.join('\n') +} + +// 把 keyvalue 形 {key:'name',value:'1'} 数组转成 {name:1,...} 简单映射;或保留 array +function toArrayOfKeyedObjects(arr) { + if (!Array.isArray(arr)) return [] + // 形如 [{key:'docker:dind', value:''}] 在 services 中需要 {name:..., alias:...} + // GitLab services 形如: services: [docker:dind, postgres:15] + // 这里简单返回原值(去 value 为空的保留 key) + return arr + .filter(x => x && x.key && x.key !== '') + .map(x => x.key) +} + +// ─────────────── 数据装配 ─────────────── +function buildJob(cfg, name, stage, image, script, extra = {}) { + const job = {} + if (stage) job.stage = stage + if (image) job.image = image + if (Array.isArray(script) && script.length) job.script = script + Object.assign(job, extra) + // 过滤掉空 + for (const k of Object.keys(job)) { + const v = job[k] + if (v === null || v === undefined || v === '' || + (Array.isArray(v) && !v.length)) delete job[k] + } + return { [name]: job } +} + +function buildStageList(stagesEnabled, customStages) { + if (!stagesEnabled) return null + const cleaned = (customStages || '') + .split(/[,\n]/) + .map(s => s.trim()) + .filter(Boolean) + return cleaned.length ? cleaned : ['lint', 'build', 'test', 'deploy'] +} + +// 把多行文本变成 { script: [...] },保留空行当 echo +function splitScript(raw) { + if (typeof raw !== 'string') return [] + return raw + .split('\n') + .map(s => s.trim()) + .filter(s => s.length > 0) +} + +export function generateGitlabCi(config) { + const doc = {} + + // stages + const stages = buildStageList(config.stagesEnabled, config.stages) + if (stages) doc.stages = stages + + // variables + const varsArr = [] + if (config.variablesMode && Array.isArray(config.variablesMode)) { + for (const kv of config.variablesMode) { + if (kv && kv.key && kv.key !== '') varsArr.push({ key: kv.key, value: String(kv.value ?? '') }) + } + } + if (varsArr.length) doc.variables = Object.fromEntries(varsArr.map(v => [v.key, v.value])) + + // default + const defaultObj = {} + if (config.defaultImage) defaultObj.image = config.defaultImage + if (config.defaultTags) defaultObj.tags = config.defaultTags.split(',').map(t => t.trim()).filter(Boolean) + // cache in default + if (config.cacheKeyPrefix && config.cachePaths) { + const paths = config.cachePaths.split('\n').map(s => s.trim()).filter(Boolean) + if (paths.length) { + defaultObj.cache = { + key: { files: [config.cacheKeyPrefix] }, + paths, + policy: config.cachePolicy || 'pull-push', + } + } + } + if (Object.keys(defaultObj).length) doc.default = defaultObj + + // workflow + if (config.workflowPolicy && config.workflowPolicy !== 'auto') { + if (config.workflowPolicy === 'none') { + doc.workflow = { rules: [{ when: 'never' }] } + } else { + const w = { rules: [{ when: config.workflowPolicy }] } + if (config.workflowInputs) { + try { + const parsed = yaml.safeLoad ? null : null // noop; we keep simple + } catch (e) { /* ignore */ } + } + doc.workflow = w + } + } + // include (高级用户自填) + if (config.includeMode && config.includeMode.trim()) { + // 解析:用户填的是合法 YAML 片段;这里做最稳:尝试 as 字符串 + const incRaw = config.includeMode.trim() + // 简单判定:用 include 直接给字符串或对象数组 + try { + // 用内置简易辨识:包含 ":" 视为子对象 + const lines = incRaw.split('\n').map(l => l).filter(Boolean) + const looksLikeMap = lines.length <= 1 && lines[0].startsWith('-') + if (looksLikeMap) { + doc.include = '<<<请把 include 写成多行 YAML 块>>>' + } else { + doc.include = incRaw + } + } catch (e) { + doc.include = incRaw + } + } + + // lint job + if (config.enableLint) { + Object.assign(doc, buildJob( + config, config.lintName || 'lint', config.lintStage || 'lint', + config.lintImage, splitScript(config.lintScript), + { allow_failure: !!config.lintAllowFailure } + )) + } + + // build job + if (config.enableBuild) { + const services = toArrayOfKeyedObjects(config.buildServices) + Object.assign(doc, buildJob( + config, config.buildName || 'build', config.buildStage || 'build', + config.buildImage, splitScript(config.buildScript), + { + services: services.length ? services : undefined, + artifacts: (() => { + const paths = (config.buildArtifacts || '').split('\n').map(s => s.trim()).filter(Boolean) + if (!paths.length) return undefined + return { paths, expire_in: config.buildArtifactsExpire || '1 week' } + })(), + } + )) + } + + // test job + if (config.enableTest) { + const extra = {} + if (config.testCoverage) { + extra.coverage = config.testCoverage + } + if (config.testArtifacts) { + extra.artifacts = { + when: 'always', + reports: { junit: config.testArtifacts }, + } + } + Object.assign(doc, buildJob( + config, config.testName || 'test', config.testStage || 'test', + config.testImage, splitScript(config.testScript), extra + )) + // parallel(matrix) + if (config.testParallel && Number(config.testParallel) > 1) { + const jobName = config.testName || 'test' + doc[jobName].parallel = Number(config.testParallel) + } + } + + // deploy job + if (config.enableDeploy) { + const extras = { + environment: { name: config.deployEnvironment || 'production' }, + needs: (config.deployNeeds || '').split(',').map(s => s.trim()).filter(Boolean), + when: config.deployManual ? 'manual' : 'on_success', + allow_failure: !!config.deployAllowFailure, + } + const rulesList = (config.deployWhen || '') + .split(/[,\n]/) + .map(s => s.trim()) + .filter(Boolean) + if (rulesList.length) { + extras.rules = [{ + if: [`$CI_COMMIT_${(config.deployWhenMode || 'branches').toUpperCase()} =~ /^(${rulesList.join('|')})$/`], + }] + } + Object.assign(doc, buildJob( + config, 'deploy', config.deployStage || 'deploy', + config.deployImage, splitScript(config.deployScript), extras + )) + } + + const header = `# .gitlab-ci.yml — 由 ConfTemplate 生成\n# 生成时间: ${new Date().toLocaleString('zh-CN')}\n# 配套 GitLab Runner: 自托管或共享 Runner (tag 匹配)\n# 用法: 提交此文件至仓库根目录即可触发流水线\n\n` + return header + toYaml(doc) +} diff --git a/src/schemas/index.js b/src/schemas/index.js index 8d7543c..0b5c00c 100644 --- a/src/schemas/index.js +++ b/src/schemas/index.js @@ -77,7 +77,9 @@ import { ansiblePlaybookSchema, generateAnsiblePlaybookYaml } from './ansible-pl // ============ CI/CD ============ import { gitlabSchema, generateGitlabRb } from './gitlab' +import { gitlabCiSchema, generateGitlabCi } from './gitlab-ci' import { jenkinsSchema, generateJenkinsYaml } from './jenkins' +import { jenkinsPipelineSchema, generateJenkinsfile } from './jenkins-pipeline' // ============ 高可用 ============ import { keepalivedSchema, generateKeepalivedConf } from './keepalived' @@ -171,7 +173,9 @@ export const schemas = { 'ansible-playbook': ansiblePlaybookSchema, // CI/CD gitlab: gitlabSchema, + 'gitlab-ci': gitlabCiSchema, jenkins: jenkinsSchema, + 'jenkins-pipeline': jenkinsPipelineSchema, // 高可用 keepalived: keepalivedSchema, haproxy: haproxySchema, @@ -246,7 +250,9 @@ export const generators = { 'ansible-playbook': generateAnsiblePlaybookYaml, // CI/CD gitlab: generateGitlabRb, + 'gitlab-ci': generateGitlabCi, jenkins: generateJenkinsYaml, + 'jenkins-pipeline': generateJenkinsfile, // 高可用 keepalived: generateKeepalivedConf, haproxy: generateHaproxyCfg, @@ -357,7 +363,7 @@ export const categories = [ { name: 'CI/CD', icon: 'Files', - items: ['gitlab', 'jenkins'], + items: ['gitlab', 'gitlab-ci', 'jenkins', 'jenkins-pipeline'], }, { name: '高可用', diff --git a/src/schemas/jenkins-pipeline.js b/src/schemas/jenkins-pipeline.js new file mode 100644 index 0000000..dc02965 --- /dev/null +++ b/src/schemas/jenkins-pipeline.js @@ -0,0 +1,399 @@ +// Jenkins 声明式流水线 (Jenkinsfile) 生成器 for ConfTemplate +// 生成符合 Jenkins Declarative Pipeline 2.x 规范的 Groovy DSL + +export const jenkinsPipelineSchema = { + id: 'jenkins-pipeline', + name: 'Jenkinsfile', + icon: 'Cpu', + category: 'CI/CD', + description: '生成 Jenkins 声明式流水线 (Declarative Pipeline)', + format: 'groovy', + fileName: 'Jenkinsfile', + groups: [ + // ─────────────── Agent ─────────────── + { + title: '执行 Agent', + fields: [ + { key: 'agentMode', label: 'agent 模式', type: 'select', default: 'docker', options: [ + { label: 'Docker 容器 (推荐)', value: 'docker' }, + { label: '任意可用节点', value: 'any' }, + { label: '指定 label 节点', value: 'label' }, + { label: '无 agent (在顶层 controller 跑)', value: 'none' }, + { label: 'Kubernetes Pod', value: 'kubernetes' }, + ] }, + { key: 'agentLabel', label: 'agent label', + type: 'text', default: 'linux', + dependsOn: { key: 'agentMode', value: 'label' } }, + { key: 'agentImage', label: 'Docker 镜像', + type: 'text', default: 'maven:3.9.6-eclipse-temurin-17', + dependsOn: { key: 'agentMode', value: 'docker' } }, + { key: 'agentDockerArgs', label: 'args (docker run 参数)', + type: 'text', default: '-v $HOME:/root -v /var/run/docker.sock:/var/run/docker.sock', + dependsOn: { key: 'agentMode', value: 'docker' }, + tip: '需要 DinD / 宿主 docker / 缓存挂载时填' }, + { key: 'agentK8sYAML', label: 'kubernetes YAML', + type: 'code', language: 'yaml', default: + 'apiVersion: v1\nkind: Pod\nspec:\n containers:\n - name: jnlp\n image: jenkins/inbound-agent:3293.vd85995_5c7116-1\n', + dependsOn: { key: 'agentMode', value: 'kubernetes' } }, + ], + }, + // ─────────────── Tools ─────────────── + { + title: 'Tools (自动安装)', + fields: [ + { key: 'enableTools', label: 'tools 段', type: 'switch', default: false, + tip: '需要 Jenkins 配置好了 Maven / JDK / Gradle 等安装时启用' }, + { key: 'toolMaven', label: 'maven 安装名', type: 'text', + default: 'maven-3.9', dependsOn: { key: 'enableTools', value: true } }, + { key: 'toolJdk', label: 'jdk 安装名', type: 'text', + default: 'jdk-17', dependsOn: { key: 'enableTools', value: true } }, + { key: 'toolGradle', label: 'gradle 安装名', type: 'text', + default: '', dependsOn: { key: 'enableTools', value: true } }, + { key: 'toolNodejs', label: 'nodejs 安装名', type: 'text', + default: '', dependsOn: { key: 'enableTools', value: true } }, + ], + }, + // ─────────────── Environment ─────────────── + { + title: '环境变量', + fields: [ + { key: 'envVars', label: 'environment env', type: 'keyvalue', default: [ + { key: 'PROJECT_NAME', value: 'my-app' }, + { key: 'DOCKER_REGISTRY', value: 'registry.example.com' }, + ], tip: 'Jenkinsfile 顶层 environment 段,Pipeline Agent 与所有 stages 都能用' }, + ], + }, + // ─────────────── Options ─────────────── + { + title: 'options (全局行为)', + fields: [ + { key: 'optDisableConcurrent', label: 'disableConcurrentBuilds', + type: 'switch', default: true, tip: '禁止并发构建同一项目' }, + { key: 'optTimeoutMinutes', label: 'timeout', type: 'number', + min: 0, max: 1440, default: 60, tip: '0 = 不设置超时' }, + { key: 'optTimestamps', label: 'timestamps', type: 'switch', default: true }, + { key: 'optAnsiColor', label: 'ansiColor (彩色日志)', + type: 'switch', default: true }, + { key: 'optBuildDiscarderDays', label: 'buildDiscarder 保留天数', + type: 'number', min: 0, max: 365, default: 30, tip: '0 = 不设置' }, + { key: 'optSkipDefaultCheckout', label: 'skipDefaultCheckout', + type: 'switch', default: false }, + ], + }, + // ─────────────── Triggers ─────────────── + { + title: 'Triggers (触发器)', + fields: [ + { key: 'triggerWebhook', label: 'GitHub webhook (trigger { githubPush() })', + type: 'switch', default: true }, + { key: 'triggerPollSCM', label: 'pollSCM 定时轮询', type: 'switch', default: false }, + { key: 'triggerPollCron', label: 'pollSCM cron', type: 'text', + default: 'H/15 * * * *', dependsOn: { key: 'triggerPollSCM', value: true } }, + { key: 'triggerCron', label: 'cron 定时构建', type: 'text', + default: '', tip: 'H H(0-7) * * * ;空则不启用 cron 触发' }, + ], + }, + // ─────────────── Stages ─────────────── + { + title: 'Stage · Checkout', + fields: [ + { key: 'enableCheckout', label: '启用 Checkout stage', type: 'switch', default: true }, + { key: 'checkoutScript', label: 'steps 脚本', type: 'code', + language: 'groovy', default: + 'checkout scmGit(\n branches: [[name: env.BRANCH_NAME ?: \'*/main\']],\n userRemoteConfigs: [[url: env.GIT_URL ?: \'https://example.com/group/repo.git\']]\n)', + dependsOn: { key: 'enableCheckout', value: true }, + tip: 'scmGit() 是 Pipeline: SCM Step 3.x 推荐写法' }, + ], + }, + { + title: 'Stage · Lint', + fields: [ + { key: 'enableLint', label: '启用 Lint stage', type: 'switch', default: true }, + { key: 'lintSteps', label: 'steps', type: 'code', language: 'groovy', + default: '// 静态检查:eslint / ruff / shellcheck / hadolint\nsh \'echo "🔍 running lint"\'\n// sh \'make lint\'', + dependsOn: { key: 'enableLint', value: true } }, + ], + }, + { + title: 'Stage · Build', + fields: [ + { key: 'enableBuild', label: '启用 Build stage', type: 'switch', default: true }, + { key: 'buildSteps', label: 'steps', type: 'code', language: 'groovy', + default: '// 多语言示例:Maven / Gradle / npm / docker build\nsh \'\'\'\n echo "📦 building ${env.PROJECT_NAME}"\n mvn -B clean package -DskipTests\n\'\'\'', + dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildPostArchive', label: 'archiveArtifacts', type: 'switch', + default: true, dependsOn: { key: 'enableBuild', value: true } }, + { key: 'buildArtifactsGlob', label: 'artifacts glob 路径', + type: 'text', default: 'target/**/*.jar\ndist/**', + dependsOn: { key: 'enableBuild', value: true }, + tip: '多个 glob 一行一个' }, + ], + }, + { + title: 'Stage · Test', + fields: [ + { key: 'enableTest', label: '启用 Test stage', type: 'switch', default: true }, + { key: 'testSteps', label: 'steps', type: 'code', language: 'groovy', + default: 'sh \'mvn test\'\n// 也可用 junit + jacoco 步骤\n// junit \'target/surefire-reports/*.xml\'', + dependsOn: { key: 'enableTest', value: true } }, + { key: 'testPostJunit', label: 'junit 报表目录', type: 'text', + default: 'target/surefire-reports/*.xml', + dependsOn: { key: 'enableTest', value: true }, + tip: '空则不输出 junit 步骤' }, + ], + }, + { + title: 'Stage · Deploy', + fields: [ + { key: 'enableDeploy', label: '启用 Deploy stage', type: 'switch', default: true }, + { key: 'deployWhenBranch', label: '仅当 BRANCH_NAME 为', type: 'text', + default: 'main', + dependsOn: { key: 'enableDeploy', value: true }, + tip: '空则不添加 when 条件;多分支用逗号' }, + { key: 'deploySteps', label: 'steps', type: 'code', language: 'groovy', + default: 'echo "🚀 deploying ${env.PROJECT_NAME}"\n// sh \'kubectl --context=prod apply -f k8s/\'\n// 或 sshPublisher / rancher / helm', + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployInput', label: 'input (人工确认才执行)', + type: 'switch', default: true, + dependsOn: { key: 'enableDeploy', value: true } }, + { key: 'deployInputMessage', label: 'input.message', + type: 'text', default: '确认发布到生产环境?', + dependsOn: { key: 'enableDeploy', value: true } }, + ], + }, + // ─────────────── Post ─────────────── + { + title: 'post 段 (行为钩子)', + fields: [ + { key: 'postAlways', label: 'always', type: 'code', language: 'groovy', + default: 'echo \'always runs\'' }, + { key: 'postSuccess', label: 'success', type: 'code', language: 'groovy', + default: 'echo \'✅ deployed successfully\'' }, + { key: 'postFailure', label: 'failure', type: 'code', language: 'groovy', + default: 'echo \'❌ failed\'\n// mail to: \'devops@example.com\', subject: "Build Failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}"' }, + { key: 'postUnstable', label: 'unstable', type: 'code', language: 'groovy', + default: 'echo \'⚠️ unstable\'' }, + { key: 'postCleanup', label: 'cleanup', type: 'code', language: 'groovy', + default: '// 删除工作空间文件\n// cleanWs()' }, + ], + }, + ], +} + +// ===================================================================== +// Groovy / Jenkinsfile 渲染器(专门为流水线 DSL 而非通用 Groovy) +// ===================================================================== + +function indent(s, n) { + if (!s) return '' + const pad = ' '.repeat(n) + return s.split('\n').map(l => l.trim() === '' ? '' : pad + l).join('\n') +} + +function escSteps(raw) { + // 用户多行 steps; 但当 steps 里需要 Groovy 多行字符串时为模板留 %% 我为括号的占位 + return raw.trim() +} + +export function generateJenkinsfile(config) { + const lines = [] + lines.push('// Jenkinsfile — 由 ConfTemplate 生成') + lines.push('// 生成时间: ' + new Date().toLocaleString('zh-CN')) + lines.push('// 用法: 放到仓库根目录, Jenkins Job 选 "Pipeline from SCM"') + lines.push('') + lines.push('pipeline {') + // ============ agent ============ + lines.push(' agent {') + if (config.agentMode === 'none') { + lines.push(' none') + } else if (config.agentMode === 'any') { + lines.push(' any') + } else if (config.agentMode === 'label') { + lines.push(" label '" + (config.agentLabel || 'linux') + "'") + } else if (config.agentMode === 'docker') { + lines.push(' docker {') + lines.push(" image '" + (config.agentImage || 'maven:3.9-eclipse-temurin-17') + "'") + if (config.agentDockerArgs && config.agentDockerArgs.trim()) { + // 用户的 args 可能含 -v $HOME:/root (含 $ 符号,单引号内 $HOME 在 Groovy 是字面量;要双引号则会被 Groovy 插值,把 $HOME 写为 ${HOME}) + const a = config.agentDockerArgs.trim() + // 用三引号避免任何内部冲突 + lines.push(" args '''" + a + "'''") + } + lines.push(' reuseNode true') + lines.push(' }') + } else if (config.agentMode === 'kubernetes') { + // 多行 YAML,用三引号嵌入 + const yamlBlock = (config.agentK8sYAML || '').trim() + lines.push(' kubernetes {') + lines.push(" yaml '''" + yamlBlock + "'''") + lines.push(' }') + } + lines.push(' }') + + // ============ tools ============ + if (config.enableTools) { + lines.push(' tools {') + if (config.toolMaven) lines.push(" maven '" + config.toolMaven + "'") + if (config.toolJdk) lines.push(" jdk '" + config.toolJdk + "'") + if (config.toolGradle) lines.push(" gradle '" + config.toolGradle + "'") + if (config.toolNodejs) lines.push(" nodejs '" + config.toolNodejs + "'") + lines.push(' }') + } + + // ============ environment ============ + const envArr = (config.envVars || []).filter(kv => kv && kv.key) + if (envArr.length) { + lines.push(' environment {') + for (const kv of envArr) { + lines.push(" " + kv.key + " = '" + String(kv.value ?? '').replace(/'/g, "\\'") + "'") + } + lines.push(' }') + } + + // ============ options ============ + const opts = [] + if (config.optDisableConcurrent) opts.push(" disableConcurrentBuilds()") + if (config.optTimeoutMinutes && Number(config.optTimeoutMinutes) > 0) { + opts.push(" timeout(time: " + Number(config.optTimeoutMinutes) + ", unit: 'MINUTES')") + } + if (config.optTimestamps) opts.push(" timestamps()") + if (config.optAnsiColor) opts.push(" ansiColor('xterm')") + if (config.optBuildDiscarderDays && Number(config.optBuildDiscarderDays) > 0) { + opts.push(" buildDiscarder(logRotator(numToKeepStr: '" + config.optBuildDiscarderDays + "'))") + } + if (config.optSkipDefaultCheckout) opts.push(" skipDefaultCheckout()") + if (opts.length) { + lines.push(' options {') + for (const o of opts) lines.push(' ' + o.trimStart()) + lines.push(' }') + } + + // ============ triggers ============ + const triggers = [] + if (config.triggerWebhook) triggers.push("githubPush()") + if (config.triggerCron) triggers.push("cron('" + config.triggerCron + "')") + if (config.triggerPollSCM && config.triggerPollCron) + triggers.push("pollSCM('" + config.triggerPollCron + "')") + if (triggers.length) { + lines.push(' triggers {') + for (const t of triggers) lines.push(' ' + t.trimStart()) + lines.push(' }') + } + + // ============ stages ============ + lines.push(' stages {') + + // Helper: 生成 stage(name) { steps { ... } },optionally 加 when、input、post + function addStage(name, opts) { + lines.push(" stage('" + name + "') {") + if (opts.when) { + lines.push(' when {') + for (const w of opts.when) lines.push(w) + lines.push(' }') + } + // steps 块 + lines.push(' steps {') + if (opts.input) { + lines.push(" input {") + lines.push(" message '" + (opts.inputMessage || '确认?') + "'") + lines.push(" ok '确定'") + lines.push(" submitter 'admin'") + lines.push(" }") + } + if (opts.steps) lines.push(indent(opts.steps, 8)) + lines.push(' }') + // stage 级别 post(独立于 steps),注意缩进跟 stage steps 同级 + if (opts.post) { + lines.push(' post {') + for (const p of opts.post) { + lines.push(" " + p.cond + " {") + if (p.script) lines.push(indent(p.script, 10)) + lines.push(' }') + } + lines.push(' }') + } + lines.push(' }') + } + + // Checkout + if (config.enableCheckout) { + addStage('Checkout', { + steps: config.checkoutScript || "checkout scm", + }) + } + // Lint + if (config.enableLint) { + addStage('Lint', { + steps: config.lintSteps || 'sh \'echo "lint"\'', + }) + } + // Build + if (config.enableBuild) { + const post = [] + if (config.buildPostArchive) { + const globs = (config.buildArtifactsGlob || '').split('\n').map(s => s.trim()).filter(Boolean) + if (globs.length) { + const arr = globs.map(g => "'" + g + "'").join(', ') + post.push({ cond: 'success', script: "archiveArtifacts artifacts: [" + arr + "], allowEmptyArchive: false" }) + } else { + post.push({ cond: 'success', script: "// archiveArtifacts 配置为空,跳过" }) + } + } + addStage('Build', { + steps: config.buildSteps || 'sh \'echo "build"\'', + post, + }) + } + // Test + if (config.enableTest) { + const post = [] + if (config.testPostJunit && config.testPostJunit.trim()) { + post.push({ cond: 'always', script: "junit '" + config.testPostJunit + "'" }) + } + addStage('Test', { + steps: config.testSteps || 'sh \'echo "test"\'', + post, + }) + } + // Deploy + if (config.enableDeploy) { + const when = [] + if (config.deployWhenBranch && config.deployWhenBranch.trim()) { + const branches = config.deployWhenBranch.split(/[,\n]/).map(s => s.trim()).filter(Boolean) + if (branches.length) { + const re = '^(' + branches.join('|') + ')$' + when.push(" branch '" + re + "'") + } + } + addStage('Deploy', { + steps: config.deploySteps || 'sh \'echo "deploy"\'', + when: when.length ? when : null, + input: config.deployInput, + inputMessage: config.deployInputMessage, + }) + } + + lines.push(' }') // stages 结束 + + // ============ post (顶层) ============ + const postSections = [ + { cond: 'always', body: config.postAlways }, + { cond: 'success', body: config.postSuccess }, + { cond: 'failure', body: config.postFailure }, + { cond: 'unstable', body: config.postUnstable }, + { cond: 'cleanup', body: config.postCleanup }, + ].filter(p => p.body && String(p.body).trim().length) + if (postSections.length) { + lines.push(' post {') + for (const p of postSections) { + lines.push(' ' + p.cond + ' {') + lines.push(indent(p.body, 6)) + lines.push(' }') + } + lines.push(' }') + } + + lines.push('}') // pipeline 结束 + lines.push('') + return lines.join('\n') +}