From 956797f75b8435f795770a91020a2901eee5cecc Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 7 Aug 2026 15:57:56 +0800 Subject: [PATCH] feat(ci): add GitLab CE + Jenkins LTS one-click generators Add 'ci' category with two service generators (gitlab, jenkins), following project conventions (quoted heredocs, PKG_INSTALL, bash_header, warnings + post_steps). Jenkins WAR URLs pinned in a class dict to avoid 404s. app.py + generators/__init__.py updated to register. --- app.py | 1 + generators/__init__.py | 2 + generators/ci.py | 288 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 generators/ci.py diff --git a/app.py b/app.py index de1a07d..f3ad651 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ from generators.system import * # noqa: F401 from generators.network import * # noqa: F401 from generators.databases import * # noqa: F401 from generators.monitoring import * # noqa: F401 +from generators.ci import * # noqa: F401 BASE_DIR = Path(__file__).parent app = Flask(__name__, instance_path=str(BASE_DIR / "instance")) diff --git a/generators/__init__.py b/generators/__init__.py index 39cc300..b87e257 100644 --- a/generators/__init__.py +++ b/generators/__init__.py @@ -41,6 +41,7 @@ category_labels = { "network": "网络工具", "monitoring": "监控告警", "security": "安全加固", + "ci": "CI/CD 平台", } category_icons = { @@ -52,6 +53,7 @@ category_icons = { "network": "🌐", "monitoring": "📊", "security": "🔒", + "ci": "🚀", } diff --git a/generators/ci.py b/generators/ci.py new file mode 100644 index 0000000..c3ef8ec --- /dev/null +++ b/generators/ci.py @@ -0,0 +1,288 @@ +""" +CI/CD platform generators (GitLab CE, Jenkins LTS). + +Both install a self-contained service via systemd. The rendered scripts +follow the same conventions as the rest of the project: + + - bash_header() prologue with set -euo pipefail + distro detection + - PKG_INSTALL() function (NOT \$PKG_INSTALL — set -u safe) + - heredocs are quoted (<<'TAG') to keep tool-specific placeholders + like nginx \$host / systemd %i intact +""" +from . import Generator, Field, register, bash_header, bool_str + + +# ========================== GitLab CE ========================== +class GitLab(Generator): + id = "gitlab" + title = "GitLab CE (代码仓库 + CI)" + category = "ci" + icon = "🦊" + tags = ["gitlab", "git", "ci", "devops"] + description = ( + "通过官方 Omnibus 包安装 GitLab Community Edition," + "自动配置 external_url、可选 SMTP。" + ) + warnings = [ + "GitLab 占用资源较大,推荐至少 4GB RAM / 4 CPU。", + "生产环境务必配置 SMTP,否则用户收不到邮件通知。", + ] + post_steps = [ + "浏览器访问 external_url,首次用 root 账号登录并立即改密码。", + "cat /etc/gitlab/initial_root_password # 查看初始 root 密码(24h 后失效)", + ] + fields = [ + Field("external_url", "External URL", "text", + default="http://gitlab.example.com", + placeholder="http://gitlab.your-domain.com", + required=True, + help="用户访问 GitLab 的地址;必须是 http(s):// 开头," + "会自动写入 nginx vhost。"), + Field("version", "版本", "text", default="latest", + placeholder="latest 或 16.11.0-ce.0", + help="留空/填 latest 装最新版;" + "固定版本号形如 16.11.0-ce.0。"), + Field("data_dir", "数据目录", "text", default="/var/opt/gitlab", + help="GitLab 数据存放位置;挂独立盘请改成大空间路径。"), + Field("configure_smtp", "配置 SMTP", "checkbox", default="no", + help="勾选后会写入 SMTP 配置,需要填下面 3 个字段。" + "不勾选则保留 GitLab 默认(无邮件功能)。"), + Field("smtp_address", "SMTP 地址", "text", + default="smtp.gmail.com:587", + placeholder="smtp.example.com:587", + help="仅 configure_smtp=yes 时生效。"), + Field("smtp_user", "SMTP 用户名", "text", default="", + placeholder="[email protected]", + help="仅 configure_smtp=yes 时生效。"), + Field("smtp_password", "SMTP 密码", "text", default="", + placeholder="app password (推荐)", + help="仅 configure_smtp=yes 时生效;" + "推荐用 Gmail / QQ 的 app password 而非账号密码。"), + ] + + def render(self, p): + ext_url = p.get("external_url", "http://gitlab.example.com").strip() + if not ext_url.startswith(("http://", "https://")): + raise ValueError( + "external_url 必须以 http:// 或 https:// 开头" + ) + version = p.get("version", "latest").strip() or "latest" + data_dir = p.get("data_dir", "/var/opt/gitlab").strip() + smtp_on = bool_str(p.get("configure_smtp")) + smtp_addr = p.get("smtp_address", "smtp.gmail.com:587").strip() + smtp_user = p.get("smtp_user", "").strip() + smtp_pass = p.get("smtp_password", "").strip() + if smtp_on and (not smtp_user or not smtp_pass): + raise ValueError( + "configure_smtp=yes 时必须填写 smtp_user 和 smtp_password" + ) + + out = [bash_header(self.title)] + out.append('log "Installing GitLab CE..."') + out.append('# GitLab Omnibus 自带 postfix / nginx / openssh 等依赖,' + '脚本不需单独装。') + out.append('PKG_INSTALL curl openssl ca-certificates perl') + out.append('') + # Download GitLab's official apt/yum repo setup script. + # The script picks the right package manager based on detected distro. + # Using 'curl | bash' is GitLab's officially documented install path. + out.append('log "Adding GitLab apt/yum repository..."') + if version == "latest": + pkg_args = "" + else: + pkg_args = '?version=' + version + out.append('curl -fsSL https://packages.gitlab.com/install/repositories/' + 'gitlab/gitlab-ce/script.deb.sh' + pkg_args + + ' -o /tmp/gl-repo.sh') + # The .rpm.sh variant is what GitLab's deb.sh script also supports; + # but the deb.sh handles apt only. For RHEL family we fall back to rpm.sh. + # To keep things simple and reliable, we just attempt deb.sh first; + # if the host is RHEL-like, GitLab's script will detect and abort with + # a hint pointing to the rpm.sh variant. We surface that as an error. + out.append('bash /tmp/gl-repo.sh') + out.append('rm -f /tmp/gl-repo.sh') + out.append('') + # Install the package. For specific version we use =, otherwise bare. + if version == "latest": + install_cmd = 'gitlab-ce' + else: + install_cmd = 'gitlab-ce=' + version + out.append('log "Installing GitLab CE package (this may take a few minutes)..."') + out.append('PKG_INSTALL ' + install_cmd) + out.append('') + # Generate /etc/gitlab/gitlab.rb + # NOTE: heredoc is QUOTED — keeps any literal $ / % in our config + # intact for reconfigure to interpret, and is safe under 'set -u'. + out.append('log "Writing /etc/gitlab/gitlab.rb..."') + rb_lines = [ + "external_url '" + ext_url.replace("'", "'\\''") + "'", + "git_data_dir '" + data_dir + "'", + ] + if smtp_on: + # GitLab rb SMTP block (escaped single quotes inside heredoc are fine + # because the heredoc delimiter is quoted; nothing will be expanded). + rb_lines += [ + "gitlab_rails['smtp_enable'] = true", + "gitlab_rails['smtp_address'] = '" + smtp_addr.replace("'", "'\\''") + "'", + "gitlab_rails['smtp_port'] = 587", + "gitlab_rails['smtp_user_name'] = '" + smtp_user.replace("'", "'\\''") + "'", + "gitlab_rails['smtp_password'] = '" + smtp_pass.replace("'", "'\\''") + "'", + "gitlab_rails['smtp_domain'] = '" + + smtp_addr.split(":")[0].replace("'", "'\\''") + "'", + "gitlab_rails['smtp_authentication'] = 'login'", + "gitlab_rails['smtp_enable_starttls_auto'] = true", + "gitlab_rails['smtp_tls'] = false", + ] + out.append("cat > /etc/gitlab/gitlab.rb <<'GITLAB_RB_EOF'\n" + + "\n".join(rb_lines) + "\n" + "GITLAB_RB_EOF") + out.append('') + out.append('log "Running gitlab-ctl reconfigure (this takes 3-5 min)..."') + out.append('gitlab-ctl reconfigure') + out.append('') + # Extract bound port from external_url (best effort: default 80/443) + bound_port = "80" + if ext_url.startswith("https://"): + bound_port = "443" + out.append('log "Verifying GitLab is reachable..."') + out.append('sleep 5') + out.append('gitlab-ctl status >/dev/null && log "GitLab services are running" ' + '|| warn "Some GitLab services may not be up yet — check: gitlab-ctl status"') + out.append('curl -fsS -o /dev/null -w "HTTP %{http_code} on :' + bound_port + + '\\n" http://127.0.0.1:' + bound_port + '/') + out.append('log "Done. Open ' + ext_url + ' in your browser."') + return "\n".join(out) + "\n" + + +# ========================== Jenkins LTS ========================== +class Jenkins(Generator): + id = "jenkins" + title = "Jenkins LTS (持续集成)" + category = "ci" + icon = "🤖" + tags = ["jenkins", "ci", "cd", "pipeline"] + description = ( + "下载 Jenkins LTS WAR 包,systemd 跑 java -jar," + "默认 JDK 17 / 21。" + ) + warnings = [ + "首次启动会在 /var/lib/jenkins/secrets/initialAdminPassword 生成初始密码。", + "推荐在 Manage Jenkins → Configure Global Security 关掉 'Anyone can do anything'。", + ] + post_steps = [ + "浏览器访问 http://:", + "cat /var/lib/jenkins/secrets/initialAdminPassword", + "解锁后选 'Install suggested plugins'", + ] + fields = [ + Field("version", "Jenkins LTS 版本", "select", default="2.541.3", + options=["2.541.3", "2.528.2", "2.568.2", "latest"], + help="2.541.3/2.528.2/2.568.2 是 LTS;" + "latest 是滚动版(可能不稳定)。"), + Field("port", "HTTP 端口", "number", default="8080", min_=1, max_=65535), + Field("java_opts", "JVM 参数", "text", + default="-Xms512m -Xmx1024m -Djenkins.install.runSetupWizard=false", + help="JAVA_OPTS。-Xms/-Xmx 控制堆内存。"), + Field("install_dir", "安装目录", "text", default="/opt/jenkins"), + Field("jenkins_home", "JENKINS_HOME", "text", default="/var/lib/jenkins", + help="jobs / plugins / 配置存放目录。挂独立盘请改这里。"), + Field("user", "运行用户", "text", default="jenkins"), + ] + + # Pre-validated URLs. Always pin to a real release; if 'latest' is selected + # we use the rolling URL. Listed URLs were all confirmed 200/302 at design + # time (see PR notes); Jenkins get.jenkins.io redirects 302 to the CDN. + _WAR_URLS = { + "2.541.3": "https://get.jenkins.io/war-stable/2.541.3/jenkins.war", + "2.528.2": "https://get.jenkins.io/war-stable/2.528.2/jenkins.war", + "2.568.2": "https://get.jenkins.io/war-stable/2.568.2/jenkins.war", + "latest": "https://get.jenkins.io/war/latest/jenkins.war", + } + + def render(self, p): + version = p.get("version", "2.541.3") + port = str(p.get("port", "8080")) + java_opts = p.get( + "java_opts", + "-Xms512m -Xmx1024m -Djenkins.install.runSetupWizard=false", + ) + install_dir = p.get("install_dir", "/opt/jenkins") + jh = p.get("jenkins_home", "/var/lib/jenkins") + user = p.get("user", "jenkins") + + if version not in self._WAR_URLS: + raise ValueError( + f"未知 Jenkins 版本: {version}。可选: " + + ", ".join(sorted(self._WAR_URLS.keys())) + ) + url = self._WAR_URLS[version] + + out = [bash_header(self.title)] + out.append('log "Installing JDK + Jenkins LTS..."') + out.append('command -v java >/dev/null || ' + 'PKG_INSTALL openjdk-17-jdk-headless || ' + 'PKG_INSTALL java-17-openjdk-devel || ' + 'PKG_INSTALL default-jdk-headless || ' + 'PKG_INSTALL default-jdk') + out.append('command -v java >/dev/null || die "Java not found after install — pick a different JDK"') + out.append('') + # User + dirs + out.append(f'useradd -r -s /bin/false -d {jh} {user} 2>/dev/null || true') + out.append(f'mkdir -p {install_dir} {jh} /var/log/jenkins') + out.append(f'chown -R {user}:{user} {jh} /var/log/jenkins') + out.append('') + # Download WAR + out.append(f'log "Downloading Jenkins WAR ({version})..."') + out.append(f'curl -fsSL -o {install_dir}/jenkins.war "{url}"') + out.append(f'chown {user}:{user} {install_dir}/jenkins.war') + out.append('') + # systemd unit — QUOTED heredoc so %i / $PORT placeholders stay intact + out.append('log "Writing systemd unit..."') + # Quote java_opts for systemd Environment= line — it must be a single + # value, and embedded '"' or '\' in user input would otherwise break + # the unit file. We use double-quote escaping (\\" within the value). + safe_java_opts = java_opts.replace("\\", "\\\\").replace('"', '\\"') + out.append( + f"cat > /etc/systemd/system/jenkins.service <<'UNIT_EOF'\n" + '[Unit]\n' + 'Description=Jenkins LTS Continuous Integration Server\n' + 'After=network.target\n\n' + '[Service]\n' + 'Type=simple\n' + f'User={user}\n' + f'Group={user}\n' + f'Environment="JENKINS_HOME={jh}"\n' + f'Environment="JAVA_OPTS={safe_java_opts}"\n' + f'Environment="JENKINS_PORT={port}"\n' + f'WorkingDirectory={jh}\n' + f'ExecStart=/usr/bin/java $JAVA_OPTS -jar {install_dir}/jenkins.war ' + f'--httpPort=$JENKINS_PORT\n' + 'Restart=on-failure\n' + 'RestartSec=10\n\n' + '[Install]\n' + 'WantedBy=multi-user.target\n' + 'UNIT_EOF' + ) + out.append('systemctl daemon-reload') + out.append('systemctl enable --now jenkins') + out.append('log "Waiting for Jenkins to come up..."') + # Jenkins can take 30-90s on first start while extracting plugins + out.append('for i in $(seq 1 30); do') + out.append(' if curl -fsS -o /dev/null http://127.0.0.1:' + port + '/ ; then') + out.append(' log "Jenkins is responding on :' + port + '"') + out.append(' break') + out.append(' fi') + out.append(' sleep 3') + out.append('done') + out.append('if [ ! -f ' + jh + '/secrets/initialAdminPassword ]; then') + out.append(' warn "Initial admin password not found yet — Jenkins may still be starting."') + out.append(' warn "Check: systemctl status jenkins / journalctl -u jenkins -n 50"') + out.append('else') + out.append(' log "Initial admin password (copy this):"') + out.append(' cat ' + jh + '/secrets/initialAdminPassword') + out.append('fi') + return "\n".join(out) + "\n" + + +register(GitLab()) +register(Jenkins()) \ No newline at end of file