# Go 应用：从 Mac 上的 CI/CD 开始

这次先跑通一台 Mac 上的完整流程：**准备源码 → 测试 → 构建镜像 → 部署容器 → 验证版本**。构建和部署用同一台 Mac、同一个 Docker 引擎，后续再拆成两台机器。

## 机器如何分工

```text
Mac
├── Docker 中的 Jenkins 控制器（helloworld 的 Compose，8080）
│     └── 分配任务给 mac-local Agent
├── Mac 原生 Java 进程：Jenkins Agent（执行 make / docker）
│     ├── Docker 构建阶段：Go 测试、编译、打包
│     └── Docker Compose：启动应用、等待健康检查
└── Docker 中的 Go 应用（Mac 的 8081 → 容器的 8080）
```

Docker Desktop 实际在 Linux 虚拟机里运行 Linux 容器。因此镜像里的 Go 程序是 Linux 二进制，Mac 只是构建和部署操作所在的主机。无需在 Jenkins 控制器内安装 Docker 或挂载 Docker socket。

## 第一步：先不依赖 Jenkins，理解部署命令

前提：Docker 已启动，有 `docker compose` v2（支持 `up --wait`）、`make`、`curl`。Mac 无需安装 Go，Docker 构建阶段自带 Go 1.26。

在本案例目录执行：

```bash
cd /Users/luca/ZhongQiuProject/jenkins/goapp
make ci IMAGE_TAG=v1
curl http://localhost:8081/
```

预期返回：

```json
{"message":"Hello from Go and Jenkins!","version":"v1"}
```

`make ci` 依次执行下面的步骤，你也可以逐个运行：

```bash
make test                      # Docker 内运行 go test 和 go vet
make build IMAGE_TAG=v1         # 多阶段构建，最终镜像仅包含应用
make deploy IMAGE_TAG=v1        # 使用现有本地镜像，等到容器健康
make verify IMAGE_TAG=v1        # 检查 HTTP 状态和实际运行版本
```

每次练习使用新标签，例如 `v2`。`Dockerfile` 把标签写入二进制，验证步骤不仅检查接口可访问，还检查是否部署了预期版本。构建镜像时也会执行测试；同一源码可复用 Docker 测试层缓存。

应用提供 `/`（问候和版本）与 `/healthz`（健康状态），未知路径返回 404。默认 8081 端口与 Jenkins 的 8080 不冲突。如果被占用，在所有命令中使用相同的 `APP_PORT=18081`；使用 Jenkins 时也要修改 Jenkinsfile 的 `APP_PORT`。

## 第二步：启动 Jenkins 并连接 Mac Agent

### 2.1 启动控制器

按 [Hello World 教程](../helloworld/README.md) 完成 Jenkins 首次安装。沿用同一个控制器即可，不需要再开一个 Jenkins。

### 2.2 准备 Mac 的 Java

Agent 需要 Java，控制器容器里的 Java 无法代替 Mac 的 Java。请在 Mac 安装 JDK 21，并在将要启动 Agent 的终端检查：

```bash
java -version
docker info
docker compose version
make --version
```

若已安装多个 JDK，可以在该终端选择 JDK 21：

```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
```

Docker 命令必须能由启动 Agent 的这个 Mac 用户正常执行。Agent 继承启动终端的环境和 Docker context。

### 2.3 创建节点

在 **Manage Jenkins → Nodes → New Node** 中创建：

| 设置 | 填写内容 |
| --- | --- |
| Node name | `mac-local` |
| Type | Permanent Agent |
| Number of executors | `1`，避免本实验并行争用同一个应用 |
| Remote root directory | `/Users/luca/jenkins-agent`，请按实际用户名调整 |
| Labels | `mac-local` |
| Usage | Only build jobs with label expressions matching this node |
| Launch method | Launch agent by connecting it to the controller |
| WebSocket | 启用 |

工作目录必须独立于源码目录，不要填写 `ZhongQiuProject` 或 `jenkins/goapp`：流水线会清理自己的 Jenkins 工作区。Hello World 仍可以用控制器内置节点；本案例通过 label 指定 Mac 节点。

保存后打开节点页面，按页面提供的命令下载 `agent.jar` 并启动。以下只是格式示例，secret 请使用页面生成的真实值：

```bash
mkdir -p "$HOME/jenkins-agent"
cd "$HOME/jenkins-agent"
curl -fsSLo agent.jar http://localhost:8080/jnlpJars/agent.jar
java -jar agent.jar \
  -url http://localhost:8080/ \
  -secret '<节点页面生成的 secret>' \
  -name mac-local \
  -webSocket \
  -workDir "$HOME/jenkins-agent"
```

保持终端运行，等待 Jenkins 显示节点在线。若控制器用了其他端口，对应修改 URL。WebSocket 走 8080，无需给 Compose 增加 50000 端口。节点 secret 不要放进源码或提交到 Git。

## 第三步：运行完整 Jenkins Pipeline

1. 新建 **Pipeline** 任务，名称使用 `goapp-local`。
2. 选择 **Pipeline script**，粘贴本目录 [Jenkinsfile](Jenkinsfile) 全部内容，保留 Groovy Sandbox。
3. 检查 `SOURCE_DIR` 默认值是本机真实源码路径，然后保存。
4. 点击 **Build Now**。首次会使用 `LOCAL` 默认值；之后可用 **Build with Parameters** 修改参数。
5. 查看控制台，依次观察 `Prepare source`、`Test`、`Build image`、`Deploy to Mac`、`Verify deployment`。
6. 打开 http://localhost:8081，响应中的版本应为 `build-1`（以实际构建编号为准）。

LOCAL 模式会把需要的文件复制到 Jenkins 独立工作区，因此不需要先搭建 Git 服务。修改 `main.go` 后再点构建，即可看到新的镜像和部署结果。如果修改问候语，请同步更新对应测试。

**这里的自动化是：触发一次流水线后，测试、构建、部署和验证全部自动执行。LOCAL 模式不会监控 Mac 文件变化。** 修改本地 Jenkinsfile 后还要重新粘贴到 Jenkins；下一步改为 Git 管理，便可以自动获取流水线和源码的修改。

仅创建一个部署该应用的 Pipeline 任务。它使用固定的 Compose 项目 `jenkins-goapp`，每次更新同一个应用；`disableConcurrentBuilds()` 只防止该任务自身重叠，不能防止其他任务或终端同时部署。运行 Jenkins 部署时不要同时执行手动部署。

## 第四步：接入 Git，实现代码变化自动部署

熟悉前三步后再做：

1. 将整个 `ZhongQiuProject` 目录作为 Git 仓库提交到自己可访问的 Git 服务，保留 `jenkins/goapp` 目录结构。不要提交 `.env`、凭据或 Agent secret。
2. 确认 Jenkins 的 **Git** 插件已安装，Mac Agent 上可以使用 `git`。
3. 将同一个任务的 Definition 改为 **Pipeline script from SCM**，SCM 选 Git，填写实际仓库 URL、分支和需要的 Jenkins 凭据。
4. Script Path 填 `jenkins/goapp/Jenkinsfile`。
5. 首次点击 **Build with Parameters**，将 `SOURCE_MODE` 选为 `SCM`，成功运行一次，注册 SCM 轮询记录。
6. 将仓库中 Jenkinsfile 的参数选项改为 `choices: ['SCM', 'LOCAL']`，提交并推送，再手动以 `SCM` 运行一次以更新参数默认值。后续定时触发就会默认使用 SCM。
7. 修改应用及对应测试、提交并推送。流水线的 `pollSCM('H/2 * * * *')` 每约两分钟检查一次，有提交变化才构建、部署。

SCM 轮询需要 Jenkins 能连接仓库，但不需要外网访问本机 Jenkins，适合第一阶段学习。之后可以替换成 Git 服务的 webhook；那时再处理可访问的回调地址。当前示例不会替你创建或推送远端仓库。

## 查看、回退和停止

以下命令仍在源码的 `jenkins/goapp` 目录执行：

```bash
make status
make logs                      # Ctrl+C 退出日志
docker image ls jenkins-goapp   # 查看保留的镜像标签

# 示例：回退到以前确实成功构建过的标签
make deploy IMAGE_TAG=build-1
make verify IMAGE_TAG=build-1

make down                      # 只停止 Go 应用，不影响 Jenkins
```

源码目录与 Jenkins 工作区使用相同 Compose 项目名和同一 Docker 引擎，因此都可以管理这个应用。查看状态、日志、停止无需指定原镜像标签；部署、验证必须指定目标标签。旧镜像会保留以便回退，反复练习后可自行清理不再需要的标签。

测试或镜像构建失败时不会执行部署。部署失败可能已经替换旧容器；本案例会报错，不包含自动回滚或无停机发布，需要根据日志手动回退。

## 后续逐步迁移到云服务器

本次实现到单 Mac 的可运行流程，云端部分先按以下顺序推进：

| 阶段 | 构建位置 | 部署位置 | 下一步增加的能力 |
| --- | --- | --- | --- |
| 当前 | Mac Agent | 同一台 Mac 的 Docker | 本地镜像直接部署 |
| 下一步 | Mac Agent | 云 Linux 服务器 | 镜像仓库、push/pull、SSH 部署、云端健康验证 |
| 再下一步 | 独立 Linux Agent | 云 Linux 服务器 | 独立构建/部署凭据、节点标签和环境配置 |

迁移时复用 Go 应用与 Dockerfile；发布流程需要新增 push，以及在目标服务器上执行部署。当前 Compose 的 `pull_policy: never` 专供本地镜像练习，云端要调整拉取策略并指定仓库地址。Mac 的 `localhost` 验证也要改成对目标服务器的验证。

Apple Silicon 默认构建 `linux/arm64` 镜像；如果云服务器是 x86，需要用 buildx 构建 `linux/amd64` 或多架构镜像并推送，不能直接复用当前本地镜像。到云端阶段再一起加入部署配置、认证、访问入口与回滚流程。

## 常见问题

- **等待 `mac-local` 节点**：检查 Agent 是否在线、label 是否相同，Mac 是否休眠。
- **找不到 docker / 无法连接 daemon**：检查 Agent 启动终端的 PATH、Docker Desktop 和 Docker context；修改环境后重新启动 Agent。
- **找不到源码**：LOCAL 路径是 Mac 上的绝对路径，不是 Jenkins 容器内部路径。
- **SCM 报 `checkout scm` 不可用**：必须先把任务改成 Pipeline script from SCM。
- **镜像下载失败**：检查 Docker 的网络或代理配置，首次需要拉取 Go 镜像。
- **部署不健康**：运行 `make status` 和 `make logs`，检查端口占用和应用日志。
- **重启 Mac 后任务不运行**：重新启动 Docker 和 Agent；当前教程使用前台 Agent 进程。

参考：[Jenkins Agent](https://www.jenkins.io/doc/book/using/using-agents/)、[Jenkins WebSocket](https://www.jenkins.io/doc/book/security/services/)、[Docker 多阶段构建](https://docs.docker.com/build/building/multi-stage/)。
