cicdkit 工程实践:一个用纯 Go 标准库打造的本地 CI/CD 单二进制平台

作者:

🇬🇧 English

一台 MacBook,桌面干净,没有 Jenkins 的托盘图标,没有 GitHub Actions 的公网 runner 客户端,也没有 PostgreSQL 或 MySQL 的守护进程。打开浏览器,点一个按钮——镜像构建、推送、k3s 发布、服务探测——一条链路在几分钟内跑完。所有状态存在本地一个 JSON 文件里,所有代码编译进一个二进制。

这就是 cicdkit 的场景:纯 Go 标准库零三方依赖的后端 + 嵌入的 React 前端 = 一个二进制搞定从配置管理到流水线执行的全部流程(GitHub: erishen/cicdkit)。

本文将沿着一次真实发布的数据流——从浏览器点击到镜像落地 k3s——逐层拆解 cicdkit 的内部结构,重点看它在「能跑起来、跑得安全」上做了哪些工程取舍。

速览(TL;DR)

  • 一个二进制、零外部依赖:纯 Go 标准库后端 + 嵌入的 React/Vite 前端(go:embed),编译成单文件;所有状态存本地 store.json,无 PostgreSQL/MySQL 等外部数据库。
  • 一条本地优先的流水线:在同一个 Web UI 里完成配置管理 → Docker 构建 → 镜像推送 → k3s 发布 → SSH 直发裸机 → 服务探测,不依赖公网 CI runner。
  • 安全模型是硬约束:非本机绑定必须显式 API_TOKEN,否则拒绝启动;宿主机命令执行受控;密钥在列表接口经 Redacted()/MergeSecrets() 脱敏,落盘文件权限 0600。
  • 8 个多语言示例(Go/Rust/Python/Ruby/PHP/Java/.NET/Node)一键跑通构建与发布。
  • 可选增强:LLM 故障诊断 + 知识库,以及 gitleaks pre-commit 钩子防密钥泄露。

入口与嵌入:从 main.go 到一个单二进制

启动流程按严格的顺序执行:

命令行参数解析 → .env 加载 → 配置加载 → store 初始化(NewJsonStore) → runner 创建(pipeline.New) → 前端 embed(编译期 go:embed,运行期 fs.Sub 抽出 web/dist) → AUTO_TOKEN 处理 → IsLoopback 安全闸门 → HTTP 服务器启动(优雅关闭内含 Flush)

配置加载阶段还负责处理 .env.env.local 文件:

if err := config.LoadDotEnv(); err != nil {
    log.Printf("加载 .env 失败 (忽略): %v", err)
}
cfg, err := config.Load(*configPath)

这让 SSH 连接密钥等敏感信息可以驻留在环境变量文件中,而非直接写在项目 JSON 或 UI 表单里。真实的环境变量优先于文件。

在配置之后、服务器启动之前,有一个关键的安全检查——IsLoopback()。如果监听地址不是本机绑定,则必须设置显式的 API_TOKEN;否则 log.Fatalf 直接拒绝启动。

前端怎么进来:静态资源与 SPA 回退

对于已知路径的请求,直接由 http.FileServer 提供静态文件;对于所有未匹配的前端路由,回退到 index.html,让 React Router 接管——这是 SPA 的经典模式,但在 Go 单二进制中实现得格外简洁。

鉴权走的是 withAuth 中间件。/api/health/api/version 两个端点对所有请求开放,其余 /api/* 路径均要求携带 API Token(通过 BearerX-API-Token header 传递)。

当后端通过 window.__CICD_API_TOKEN__ 注入一次性令牌时,前端自动将其写入 localStorage

if (window.__CICD_API_TOKEN__) {
    writeToken(window.__CICD_API_TOKEN__)
}

normalizeToken 函数处理了 .env 文件中常见的引号包裹问题——用户复制 token 时经常连同双引号一起粘贴,导致前后端 token 长度不一致、始终 401。这里在读写两侧都做了归一化处理:

function normalizeToken(t) {
    if (!t) return ''
    const s = String(t).trim()
    return s.replace(/^["']|["']$/g, '')
}

多个并发请求同时遇到 401 的情况由 tokenPrompting 标志符保护:同一时刻只允许一个 prompt 在途,避免首页多个请求连环弹出「请输入 Token」框。

配置与安全:本机绑定是硬约束

由于后端会执行 dockerkubectl 等宿主机命令,一个暴露在公网且无鉴权的 cicdkit 实例等同于开放了任意命令执行。因此非本机绑定时,如果只有 AUTO_TOKEN 而没有显式 API_TOKEN,程序直接退出:

if !cfg.Server.IsLoopback() {
    switch {
    case cfg.Server.APIToken == "":
        log.Fatalf("安全错误: 监听地址 %s 不限于本机,但未设置 API_TOKEN。本平台会在宿主机执行 docker/kubectl,等同于把命令执行开放给全网。请设置 API_TOKEN 环境变量,或把地址改为 127.0.0.1。", cfg.Server.Addr)
    case autoTokenUsed:
        log.Fatalf("安全错误: 监听地址 %s 不限于本机,但使用的是 AUTO_TOKEN(令牌已写入前端页面,对外网无效)。请改用显式 API_TOKEN 环境变量后再启动。", cfg.Server.Addr)
    }
}

SSH 字段通过 Redacted()MergeSecrets() 实现密钥脱敏,确保列表响应中不会泄露明文凭证。

API 路由与项目生命周期

路由通过路径前缀匹配分发:

action := parts[1]
switch action {
case "build":
    s.trigger(w, r, id, "build")
case "pipeline":
    s.trigger(w, r, id, "pipeline")
case "deploy":
    s.triggerDeploy(w, r, id)
case "validate":
    s.handleValidateProject(w, r, id)
case "probe":
    s.handleProbeProject(w, r, id)
case "generate":
    // GET 预览脚手架文件,POST 写盘落地
    if r.Method == http.MethodPost {
        s.handleGenerateApply(w, r, id)
    } else {
        s.handleGeneratePlan(w, r, id)
    }
}

服务器端路径模式更安全——用户通过 /api/fs/roots/api/fs/list 接口浏览允许范围内的目录。

文件系统浏览与路径安全

隐藏条目(以 . 开头的)被跳过,目录和文件分别排序后合并返回。

流水线执行与持久化

每个项目触发 buildpipelinedeploy 动作时,runner 根据项目配置决定构建流程(Dockerfile 构建 → 镜像推送 → 部署到 k3s),并写运行记录到 JSON store。

HTTP 服务器的超时配置体现了对「长任务」的容忍:

httpSrv := &http.Server{
    Addr:              cfg.Server.Addr,
    Handler:           srv.Handler(),
    ReadHeaderTimeout: 10 * time.Second,
    ReadTimeout:       30 * time.Second,
}

构建戳:确认你连的是最新版本

前端 footer 展示这个时间,配合前端 UI_BUILD 的时间戳,用户可以一眼确认浏览器是否连上了最新编译的二进制——排查「改了代码却没生效」时非常实用。格式选择「空格分隔、无时区」的本地时间,与前端 UI_BUILD 的样式统一。


源码导航

项目地址

常见问题

cicdkit 为什么要求非本机绑定时必须设置显式 API_TOKEN,而不能使用 AUTO_TOKEN?

AUTO_TOKEN 是一次性随机令牌,启动时自动生成并注入前端页面的 JavaScript 变量中。由于令牌已写入前端源码,任何能访问该页面的用户都能看到它——这意味着 AUTO_TOKEN 无法防止跨站访问。非本机绑定时服务暴露在网络上,必须使用显式 API_TOKEN,确保只有持有正确令牌的外部请求才能访问。

cicdkit 如何避免多个并发请求同时触发 401 弹框?

前端 api.js 中使用 tokenPrompting 布尔标志符。当第一个 401 请求触发 prompt 时将其设为 true,后续并发的 401 请求检测到该标志后直接抛错,不再弹框。用户输入 token 后,原请求重试,其他失败请求由各自的重试逻辑处理。

cicdkit 的 JSON 文件存储如何保证写入的安全性?

store 层并没有 OS 级文件锁,而是用进程内读写锁(sync.RWMutex)保护内存中的数据集——所有读走读锁、写走写锁。每次写入并不直接落盘,而是调用 persist() 启动一个 200ms 的合并窗口(persistDelay),把一次构建过程中每个 stage 触发的 SaveRun 折叠成一次磁盘写入。落地时 flushNow() 先取读锁做内存快照,写入 store.json.tmp 再以 os.Rename 原子替换,读者永远不会看到半截文件;文件权限设 0600,因为 store 可能含 registry 密码 / ssh key 路径等密钥。Graceful shutdown 在收到 SIGINT/SIGTERM 后,让 HTTP 服务在 10s 内等完当前请求,再调 Flush() 停止待定定时器并强制把最后一个合并窗口写入磁盘,避免进程退出丢失最近操作。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

首页 简历 商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号