diff --git a/.dockerignore b/.dockerignore index 92f55f8..86043d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,12 +6,11 @@ .codegraph gva web/node_modules -web/dist node_modules -dist -bin coverage *.log *.tmp configs/*.local.yaml uploads +docs +deploy diff --git a/Dockerfile b/Dockerfile index aec9e35..3fbe3c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,7 @@ -FROM golang:1.25 AS builder +FROM docker.m.daocloud.io/debian:stable-slim -COPY . /src -WORKDIR /src - -RUN GOPROXY=https://goproxy.cn make build - -FROM debian:stable-slim +# BIN_NAME 必须与 Makefile build 的 -o 产物名一致(Jenkins 会传 --build-arg) +ARG BIN_NAME=kratos-admin RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ @@ -13,9 +9,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/ \ && apt-get autoremove -y && apt-get autoclean -y -RUN groupadd --system kra && useradd --system --gid kra --home-dir /app kra +# Fixed UID/GID so the init container can chown the hostPath config dir. +RUN groupadd --system --gid 1000 kra && useradd --system --uid 1000 --gid kra --home-dir /app kra -COPY --from=builder --chown=kra:kra /src/bin /app +COPY --chown=kra:kra bin /app RUN mkdir -p /data/conf /app/uploads/file && chown -R kra:kra /data/conf /app/uploads @@ -27,4 +24,4 @@ EXPOSE 8000 EXPOSE 9000 VOLUME /data/conf -CMD ["./kratos-admin", "-conf", "/data/conf"] +CMD ["./${BIN_NAME}", "-conf", "/data/conf"] \ No newline at end of file diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md new file mode 100644 index 0000000..82ed709 --- /dev/null +++ b/deploy/DEPLOYMENT.md @@ -0,0 +1,198 @@ +# KRA 部署与配置文档 + +本文档完整描述 kra 框架项目从代码推送到双集群部署的全链路:各组件如何串联、GitLab / Jenkins / Harbor 三块各自需要配置什么、首次部署步骤与排障方法。 + +> 快速接入新项目只看 [README.md](README.md);本文是完整运维文档。 +> 文中账号密码遵循内网环境约定,不写入仓库,凭据统一存放在 Jenkins Credentials 中。 + +## 1. 环境拓扑 + +| 节点 | 角色 | 关键端口 / 路径 | +|------|------|----------------| +| 192.168.200.128 | Rancher + RKE2 集群1(dev) | 前端 30080 / 后端 30081,命名空间 `-dev` | +| 192.168.200.129 | RKE2 集群2(prod) | 前端 30080 / 后端 30081,命名空间 `-prod` | +| 192.168.200.130 | GitLab | 仓库 `http://192.168.200.130/root/kra`,分支 `main` / `dev` | +| 192.168.200.131 | Jenkins | 8080(容器挂载宿主机 docker.sock) | +| 192.168.200.132 | Harbor | HTTPS(自签名证书),项目 `library` | + +所有节点 SSH 使用 root;各系统账号密码见内网环境约定。 + +## 2. 总体流程 + +``` +开发者 + │ git push (dev 或 main) + ▼ +GitLab ──(Jenkins 集成,请求内嵌凭据)──► Jenkins job「kra」 + │ gitlab-plugin 解析 push payload + │ 注入环境变量 gitlabBranch + ┌─────────────────────────────┤ + ▼ Test/Build(宿主机 Docker) ▼ Build & push + Go 编译 / pnpm 构建 ────────► Harbor(镜像 tag: {分支}-{commit前12位}) + │ + ▼ Deploy(kubectl + 对应集群 kubeconfig) + ┌─────────┴─────────┐ + ▼ ▼ +128 / kra-dev 129 / kra-prod +(3 后端 + 3 前端 + MySQL + Redis) +``` + +分支路由的核心:GitLab 集成发送的 push payload 中带 `ref`(分支),Jenkins 的 gitlab-plugin 把它注入为环境变量 `gitlabBranch`,`deploy/Jenkinsfile` 的 `Select environment` 阶段按它派生出命名空间、环境标识、目标集群与 kubeconfig 凭据,`Checkout branch` 阶段再动态检出实际推送的分支。**单 job 同时服务两个环境,不会串**: + +- 镜像 tag 含分支与 commit(如 `dev-f0657c1abc12`),按 commit 不可变; +- 构建不并发(`disableConcurrentBuilds`),同时推送时排队执行; +- job 的 SCM 分支规格 `*/main` 只影响 Jenkinsfile 脚本本身的加载(两分支脚本内容同步),不影响实际构建内容。 + +> tag 推送不触发部署(GitLab 集成未启用标签事件,Jenkinsfile 也只认 dev/main),tag 仅作 GitLab 版本标记。 + +## 3. GitLab 侧配置 + +**只需配置一项:Jenkins 集成**(不需要手工建 webhook)。 + +路径:项目 → 设置 → 集成 → Jenkins + +| 字段 | 值 | +|------|-----| +| Jenkins 服务器 URL | `http://192.168.200.131:8080` | +| Project name | `kra`(与 Jenkins job 名一致) | +| Username / Password | Jenkins 的 admin 账号 | +| 触发事件 | 仅勾选"推送" | + +**原理**:GitLab 把 Jenkins 用户名/密码内嵌进 URL,POST 到 `http://:@131:8080/project/kra`,请求体为完整 push 事件 JSON(含分支 ref)。带凭据的请求天然通过 Jenkins 权限检查,无需匿名授权、无需维护自定义请求头。 + +- 交付记录:集成编辑页底部的"最近事件"(hook_logs),可看每次请求体与响应码,是排障第一入口。 +- 修改 Jenkins 密码后:只需回此表单改一次密码,所有分支自动生效。 +- 注意:GitLab 的 Jenkins 集成每个项目只能配一个(所以采用单 job 分支路由模型)。 + +## 4. Harbor 侧配置 + +1. **项目**:`library`(镜像最终路径 `library/kra-backend`、`library/kra-frontend`)。 +2. **机器人账号**:项目管理 → library → 机器人账户 → 创建 `kra-ci`(push/pull 权限),凭据存入 Jenkins 凭据 `harbor-library-push`。 +3. **自签名证书信任**(Harbor 用自签 HTTPS,docker/kubelet 需要信任): + +```bash +# ① Jenkins 宿主机(131)——docker login/push 用 +mkdir -p /etc/docker/certs.d/192.168.200.132 +cp harbor-ca.crt /etc/docker/certs.d/192.168.200.132/ca.crt +# docker daemon 自动读取,无需重启 + +# ② 两台 RKE2 节点(128/129)——kubelet 拉镜像用 +cat >/etc/rancher/rke2/registries.yaml <<'EOF' +mirrors: + "192.168.200.132": + endpoint: + - "https://192.168.200.132" + rewrite: + "^rancher/(.*)": "library/$1" +configs: + "192.168.200.132": + tls: + insecure_skip_verify: true + auth: + username: kra-ci + password: "" +EOF +systemctl restart rke2-server +``` + +> 集群内另有流水线自动创建/更新的 `harbor-registry` imagePullSecret(每次部署时同步 robot 凭据),Pod 拉镜像走它;`registries.yaml` 是节点级兜底。 + +## 5. Jenkins 侧配置 + +### 5.1 部署形态与系统依赖 + +- Jenkins 以容器运行在 131,容器内挂载宿主机 `/var/run/docker.sock`:流水线里的 `docker build/push` 实际跑在宿主机 daemon 上(注意容器内 docker CLI 无 buildx,Dockerfile 不能用 BuildKit 语法)。 +- 宿主机依赖缓存:`/var/lib/kra-ci-cache/{go-mod,go-build,pnpm-store,corepack}` 挂进构建容器,避免重复下载。 +- 容器内需有 `kubectl`、`envsubst`、`git`。 + +### 5.2 插件清单(关键) + +| 插件 | 作用 | +|------|------| +| gitlab-plugin | `/project/` 接收端点、触发构建、注入 `gitlabBranch` | +| Pipeline | 流水线核心(Pipeline script from SCM) | +| Git | SCM 检出 | +| Credentials / Credentials Binding | 凭据管理与注入 | + +### 5.3 凭据(6 个,ID 与 `deploy/Jenkinsfile` 中变量对应) + +| 凭据 ID | 类型 | 来源 / 获取方式 | +|---------|------|----------------| +| `gitlab-root-http` | 用户名/密码 | GitLab 账号(代码检出用) | +| `harbor-library-push` | 用户名/密码 | Harbor robot 账号 `kra-ci` | +| `kra-rke2-128-kubeconfig` | Secret file | 128 节点 `/etc/rancher/rke2/rke2.yaml`,**把 server 的 127.0.0.1 改为 192.168.200.128** 后保存 | +| `kra-rke2-129-kubeconfig` | Secret file | 129 节点同上,server 改为 192.168.200.129 | +| `kra-mysql-root-password` | Secret text | 自定的 MySQL root 密码(与节点上 MySQL 一致) | +| `kra-jwt-signing-key` | Secret text | 自定的随机长字符串(JWT 签名密钥)。模板默认占位值在 `configs/config.yaml`,生产必须覆盖;**换值会导致已登录 token 全部失效**,需重新登录 | + +kubeconfig 添加路径:系统管理 → 凭据 → Add Credentials → 类型 Secret file → 上传文件 → 填 ID。 + +> kubeconfig 可用性验证:`kubectl --kubeconfig <文件> -n kra-dev get pods`。若报证书错误,在节点 `/etc/rancher/rke2/config.yaml` 追加 `tls-san: <节点IP>` 并 `systemctl restart rke2-server`。 + +### 5.4 Job 配置(唯一 job「kra」) + +1. 新建 Item → Pipeline,命名 `kra`。 +2. 勾选"不允许并发构建"。 +3. 触发器:勾选 **Build when a change is pushed to GitLab**(gitlab-plugin 的 `/project/` 端点依赖该触发器存在)。 +4. 流水线:定义 = **Pipeline script from SCM**;SCM = Git;仓库 `http://192.168.200.130/root/kra.git`;凭据 `gitlab-root-http`;分支规格 `*/main`;脚本路径 `deploy/Jenkinsfile`;勾选轻量级检出。 +5. Jenkins 安全策略保持"登录用户可做任何事"即可——集成请求自带凭据,天然有构建权限。 + +### 5.5 分支解析优先级(Jenkinsfile) + +``` +gitlabBranch(集成/推送触发) + > BRANCH_NAME(多分支流水线场景) + > JOB_NAME(手动触发兜底:以 -dev 结尾按 dev,否则默认 main) +``` + +## 6. Kubernetes 节点配置 + +| 项 | 说明 | +|----|------| +| Harbor 拉镜像信任 | 见 §4 第 3 步 `registries.yaml` | +| 应用配置文件 | 节点放置 `/var/lib/kra/conf/config.yaml`(模板 [k8s/config.example.yaml](k8s/config.example.yaml)),hostPath 挂载为容器 `/data/conf`(可写),属主 `chown -R 1000:1000`(镜像固定 UID/GID=1000) | +| 数据持久化 | `/var/lib//mysql`、`/var/lib//redis`(节点本地目录,当前单节点集群适用) | +| kubectl | 节点用 `/var/lib/rancher/rke2/bin/kubectl`,并 `export KUBECONFIG=/etc/rancher/rke2/rke2.yaml` | + +**首次部署初始化流程**(kra 框架约定,勿用环境变量注入数据库 DSN): + +1. 服务启动时数据库未就绪,首页显示"前往初始化"。 +2. 走完 `/init/initdb` 向导:建库建表 + 写入种子数据(默认账号 admin)。 +3. 向导最后一步把数据库连接**写回** `/data/conf/config.yaml`(即节点的 hostPath 文件),配置持久化在节点上。 +4. 若需后续手工改配置:改节点文件后 `rollout restart deployment/kra-backend` 让 3 副本同步。 + +## 7. 首次部署清单(按顺序) + +1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。 +2. RKE2 节点:`registries.yaml` 配好并重启 rke2-server;放置 `/var/lib/kra/conf/config.yaml`。 +3. Jenkins:安装插件;添加 §5.3 全部 6 个凭据;按 §5.4 建 `kra` job。 +4. GitLab:配置 Jenkins 集成(§3)。 +5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。 +6. 初始化向导(§6),完成后浏览器验证。 + +## 8. 部署验证 + +```bash +curl -I http://192.168.200.128:30080/ # 前端 200 +curl http://192.168.200.128:30081/health # "ok" +curl -I http://192.168.200.129:30080/ # 同上验证 129 +``` + +构建日志里 `resolved branch='xxx'` 一行可直接确认本次构建部署到了哪个环境。 + +## 9. 排障速查 + +| 现象 | 排查点 | +|------|--------| +| push 后没有构建 | GitLab 集成"最近事件"(hook_logs)看响应码与请求体;确认集成启用且勾选推送 | +| 构建失败 | Jenkins 构建日志;先看 `resolved branch` 是否符合预期 | +| Pod 镜像拉取失败 | 节点 `registries.yaml`、Harbor robot 是否有效、`harbor-registry` Secret | +| 后端 503 / 初始化异常 | 节点 `/var/lib/kra/conf/config.yaml` 是否存在且属主 1000:1000;后端日志是否连上 `-mysql:3306` | +| 登录态全部失效 | 是否更换了 `kra-jwt-signing-key`(换值即失效所有 token) | +| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 | + +## 10. 附录:安全与维护 + +- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。 +- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。 +- 轮换 Harbor robot / MySQL / JWT key:更新 Jenkins 对应凭据 → 推送任意分支触发一次部署(kubeconfig 类凭据即时生效;JWT 轮换会使登录态失效)。 diff --git a/deploy/Jenkinsfile b/deploy/Jenkinsfile new file mode 100644 index 0000000..38c204c --- /dev/null +++ b/deploy/Jenkinsfile @@ -0,0 +1,160 @@ +pipeline { + agent any + + options { + disableConcurrentBuilds() + } + + environment { + // ══════════════════════════════════════════════════════════════════ + // 项目级参数 —— 基于 kra 模板的新项目只需修改本块。 + // 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。 + // ══════════════════════════════════════════════════════════════════ + APP_NAME = 'kra' // 派生:服务名/Secret名/库名/hostPath/镜像后缀 + IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径 + REGISTRY = '192.168.200.132' + BIN_NAME = 'kratos-admin' // Makefile build 的产物名 + KRA_REPLICAS = '3' + KRA_FRONTEND_NODEPORT = '30080' + KRA_BACKEND_NODEPORT = '30081' + KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4' + KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine' + GO_IMAGE = 'docker.m.daocloud.io/golang:1.25' + NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim' + PNPM_VERSION = '10.15.1' + CACHE_DIR = '/var/lib/kra-ci-cache' // Jenkins 宿主机依赖缓存根目录 + + // 集群映射(dev -> 测试集群, main -> 生产集群) + DEV_CLUSTER_HOST = '192.168.200.128' + PROD_CLUSTER_HOST = '192.168.200.129' + + // Jenkins 凭据 ID(新项目若沿用同一套凭据可不改) + CRED_HARBOR_PUSH = 'harbor-library-push' + CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig' + CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig' + CRED_MYSQL_PASSWORD = 'kra-mysql-root-password' + CRED_JWT_SIGNING_KEY = 'kra-jwt-signing-key' + + // k8s 清单渲染白名单:只替换列出的变量,避免误伤清单内 shell 变量(如 probe 里的 $MYSQL_ROOT_PASSWORD) + K8S_RENDER_VARS = '$KRA_APP $KRA_NAMESPACE $KRA_ENV $KRA_BACKEND_IMAGE $KRA_FRONTEND_IMAGE $KRA_REPLICAS $KRA_FRONTEND_NODEPORT $KRA_BACKEND_NODEPORT $KRA_MYSQL_IMAGE $KRA_REDIS_IMAGE $BIN_NAME' + } + + stages { + stage('Select environment') { + steps { + script { + // 分支来源优先级: + // 1) env.gitlabBranch —— GitLab Jenkins 集成/webhook 触发时由 gitlab-plugin 注入(push payload 的 ref) + // 2) env.BRANCH_NAME —— 多分支流水线场景 + // 3) JOB_NAME —— 手动触发兜底:名字以 -dev 结尾按 dev 部署,否则默认 main + def branch = env.gitlabBranch ?: env.BRANCH_NAME + if (!branch) { + branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main' + } + echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'" + env.KRA_BRANCH = branch + if (branch == 'dev') { + env.KRA_NAMESPACE = "${env.APP_NAME}-dev" + env.KRA_ENV = 'development' + env.KRA_CLUSTER_HOST = env.DEV_CLUSTER_HOST + env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV + } else if (branch == 'main') { + env.KRA_NAMESPACE = "${env.APP_NAME}-prod" + env.KRA_ENV = 'production' + env.KRA_CLUSTER_HOST = env.PROD_CLUSTER_HOST + env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD + } else { + error("Unsupported branch: ${branch}. Only dev and main deploy.") + } + } + } + } + + stage('Checkout branch') { + steps { + // job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出, + // 保证单 job 同时服务 dev/main 两个环境。 + checkout([$class: 'GitSCM', + branches: [[name: "*/${env.KRA_BRANCH}"]], + userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]]) + } + } + + stage('Test') { + steps { + sh ''' + set -eu + HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#') + docker run --rm -e GOPROXY=https://goproxy.cn,direct \\ + -v "$HOST_WORKSPACE:/src" \\ + -v "$CACHE_DIR/go-mod:/go/pkg/mod" \\ + -v "$CACHE_DIR/go-build:/root/.cache/go-build" \\ + -w /src "$GO_IMAGE" sh -c "git config --global --add safe.directory /src && go test ./... && make build" + ''' + sh ''' + set -eu + HOST_WORKSPACE=$(printf '%s' "$WORKSPACE" | sed 's#/var/jenkins_home#/opt/jenkins#') + docker run --rm \\ + -e COREPACK_NPM_REGISTRY=https://registry.npmmirror.com \\ + -v "$HOST_WORKSPACE/web:/app" \\ + -v "$CACHE_DIR/pnpm-store:/root/.local/share/pnpm/store" \\ + -v "$CACHE_DIR/corepack:/root/.cache/node/corepack" \\ + -w /app "$NODE_IMAGE" bash -lc "corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate && pnpm install --frozen-lockfile && pnpm build" + # 前端 Nginx 配置模板渲染(proxy_pass 指向 ${APP_NAME}-backend 服务)。 + # KRA_APP 必须在此显式导出:envsubst 会把未设置的变量替换为空串。 + export KRA_APP="$APP_NAME" + envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf + ''' + } + } + + stage('Build and push images') { + steps { + script { + env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.GIT_COMMIT.take(12)}" + } + withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) { + sh ''' + set -eu + echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin + docker build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" . + docker build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web + docker push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" + docker push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" + docker logout "$REGISTRY" + ''' + } + } + } + + stage('Deploy to Rancher cluster') { + steps { + withCredentials([file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'), + string(credentialsId: env.CRED_MYSQL_PASSWORD, variable: 'KRA_MYSQL_ROOT_PASSWORD'), + string(credentialsId: env.CRED_JWT_SIGNING_KEY, variable: 'KRA_JWT_SIGNING_KEY'), + usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) { + sh ''' + set -eu + export KUBECONFIG="$KUBECONFIG_FILE" + kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + kubectl -n "$KRA_NAMESPACE" create secret docker-registry harbor-registry \\ + --docker-server="$REGISTRY" --docker-username="$HARBOR_USER" --docker-password="$HARBOR_PASSWORD" \\ + --dry-run=client -o yaml | kubectl apply -f - + kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\ + --from-literal=mysql-root-password="$KRA_MYSQL_ROOT_PASSWORD" \\ + --from-literal=database-source="root:${KRA_MYSQL_ROOT_PASSWORD}@tcp(${APP_NAME}-mysql:3306)/${APP_NAME}?charset=utf8mb4&parseTime=True&loc=Local" \\ + --from-literal=jwt-signing-key="$KRA_JWT_SIGNING_KEY" \\ + --from-literal=redis-addr="${APP_NAME}-redis:6379" \\ + --dry-run=client -o yaml | kubectl apply -f - + export KRA_APP="$APP_NAME" + export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" + export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" + envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml | kubectl apply -f - + kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m + kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m + ''' + } + } + } + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..592b24f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,107 @@ +# KRA CI/CD 部署 + +deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的新项目可以整体复制后只改一个参数块。 + +> 完整的环境配置、凭据来源与排障说明见 [DEPLOYMENT.md](DEPLOYMENT.md)。 + +分支和集群的对应关系: + +- `dev` -> Rancher 集群 `128` -> 命名空间 `-dev` +- `main` -> Rancher 集群 `129` -> 命名空间 `-prod` + +代码仓库:`http://192.168.200.130/root/kra`。自动触发使用 **GitLab Jenkins 集成**(项目 设置 → 集成 → Jenkins): + +- Jenkins 服务器 URL:`http://192.168.200.131:8080`,Project name:`kra`,填入 Jenkins 用户名/密码(凭据由 GitLab 保管,无需手动维护 webhook 或请求头) +- 集成会把完整 push 事件(含 `ref` 分支)POST 到 Jenkins `/project/` 端点,gitlab-plugin 注入 `gitlabBranch` 环境变量 +- `deploy/Jenkinsfile` 的 `Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,`Checkout branch` 阶段动态检出实际推送的分支 +- 手动构建兜底:`kra` job 手动触发时默认部署 main(不识别 tag,tag 仅作版本标记,不触发部署) + +每个环境包含 3 个后端 Pod、3 个前端 Pod、1 个 MySQL Pod 和 1 个 Redis Pod。前端通过 `-backend` ClusterIP Service 访问后端,Kubernetes Service 在 Pod 之间做负载均衡;前端通过 NodePort `30080` 暴露,后端调试入口通过 NodePort `30081` 暴露。 + +MySQL 和 Redis 使用节点本地目录 `/var/lib//mysql`、`/var/lib//redis` 持久化。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,不提供数据库跨节点高可用。 + +## Jenkins 前置条件 + +Jenkins 节点需要可用的 Docker CLI/daemon、`kubectl` 和 `envsubst`(Go 编译和前端构建在临时容器内完成)。Jenkins 凭据 ID 需要与 `deploy/Jenkinsfile` 中的 `CRED_*` 变量一致: + +- `harbor-library-push`: Harbor `192.168.200.132` 的推送账号 +- `kra-rke2-128-kubeconfig`: 集群 128 的 kubeconfig 文件 +- `kra-rke2-129-kubeconfig`: 集群 129 的 kubeconfig 文件 +- `kra-mysql-root-password`: 应用专用 MySQL root 密码 +- `kra-jwt-signing-key`: 生产 JWT 签名密钥 + +Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-registry` 镜像拉取 Secret。不要把真实密码写入 Git。 + +## 访问地址 + +- 集群 128: `http://192.168.200.128:30080` +- 集群 129: `http://192.168.200.129:30080` +- 后端调试: 对应节点的 `30081` + +## 首次部署初始化 + +### 前置:放置配置文件 + +后端启动读取 `/data/conf/config.yaml`,该目录以 hostPath `/var/lib//conf` 挂载到 K8s 节点。部署人员在**目标集群节点**上执行(以 kra 项目为例): + +```sh +mkdir -p /var/lib/kra/conf +# 把 deploy/k8s/config.example.yaml 复制为 /var/lib/kra/conf/config.yaml,按需修改 +chown -R 1000:1000 /var/lib/kra/conf +``` + +容器内运行用户为 UID/GID 1000,目录必须可写:初始化向导会把数据库连接和 JWT 密钥写回该文件。 + +### 初始化 + +部署后数据库未配置,应用以 bootstrap 模式启动,首页会出现**前往初始化**。在初始化页面填写数据库连接(以 kra 项目为例): + +| 字段 | 值 | +|---|---| +| 数据库类型 | mysql | +| 主机 | `kra-mysql`(集群内 Service 名) | +| 端口 | `3306` | +| 用户名 | `root` | +| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 | +| 库名 | `kra`(与应用名一致) | + +提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回节点的 `config.yaml`,Pod 重建后配置不丢失。多副本共享同一目录,初始化完成后会自动滚动重启后端让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。 + +## 基于 kra 模板接入新项目 + +复制 `deploy/` 目录到新项目,然后只需修改 `deploy/Jenkinsfile` 顶部的 **项目级参数块**: + +| 变量 | 说明 | 默认值(kra 项目) | +|---|---|---| +| `APP_NAME` | 应用名,派生所有 K8s 资源名、数据库名、hostPath、镜像后缀 | `kra` | +| `IMAGE_REPO` | Harbor 镜像仓库路径 | `library/kra` | +| `REGISTRY` | Harbor 地址 | `192.168.200.132` | +| `BIN_NAME` | 后端二进制名,须与 `Makefile` 的 `make build` 产物一致 | `kratos-admin` | +| `REPLICAS` | 前后端副本数 | `3` | +| `FRONTEND_NODEPORT` / `BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` | +| `MYSQL_IMAGE` / `REDIS_IMAGE` | 中间件镜像 | daocloud mysql:8.4 / redis:7-alpine | +| `GO_IMAGE` / `NODE_IMAGE` / `PNPM_VERSION` | 构建镜像与工具版本 | 见文件 | +| `CACHE_DIR` | Jenkins 宿主机依赖缓存目录(建议每项目独立) | `/var/lib/kra-ci-cache` | +| `DEV_CLUSTER_HOST` / `PROD_CLUSTER_HOST` | dev/main 分支部署的目标集群 | `192.168.200.128` / `192.168.200.129` | +| `CRED_*` | Jenkins 凭据 ID,沿用现有凭据可不改 | 见文件 | + +### 模板渲染机制 + +- `deploy/k8s/kra.yaml` 是 K8s 清单模板,由 Jenkins 用 `envsubst "$K8S_RENDER_VARS"` 白名单渲染。所有资源名都以 `${KRA_APP}` 为前缀(由 `APP_NAME` 注入),不要把新的 `$变量` 混入清单(白名单外的 `$...` 会原样保留,例如 MySQL probe 里的 `$MYSQL_ROOT_PASSWORD`)。 +- 前端 Nginx 配置 `web/.docker-compose/nginx/conf.d/my.conf` 中的后端服务名写作 `${KRA_APP}-backend`,Jenkins 构建前端镜像前渲染为 `web/nginx.k8s.conf`(已 gitignore)。本地手动构建 web 镜像前需先执行: + + ```sh + envsubst '$KRA_APP' < web/.docker-compose/nginx/conf.d/my.conf > web/nginx.k8s.conf + ``` + +### 命名约定 + +| 资源 | 名称 | +|---|---| +| Deployment/Service | `-backend`、`-frontend`、`-redis` | +| StatefulSet/Service | `-mysql` | +| ConfigMap | `-config` | +| 运行时 Secret | `-runtime`(key:`mysql-root-password`、`database-source`、`redis-addr`、`jwt-signing-key`) | +| 数据库名 | `` | + +`internal/logging` 等框架代码与项目名无关,接入新项目时无需修改。 diff --git a/deploy/k8s/config.example.yaml b/deploy/k8s/config.example.yaml new file mode 100644 index 0000000..e58ac27 --- /dev/null +++ b/deploy/k8s/config.example.yaml @@ -0,0 +1,71 @@ +# 后端配置文件模板。 +# 部署人员将其放置到 K8s 节点的 /var/lib//conf/config.yaml(默认 kra 项目为 /var/lib/kra/conf/config.yaml), +# 并执行 chown -R 1000:1000 /var/lib//conf(容器内运行用户为 UID/GID 1000)。 +# +# 说明: +# - data.database 保持为空:应用启动后首页会出现"前往初始化", +# 在初始化页面填写数据库连接后,框架会自动把连接配置写回本文件。 +# - data.redis.addr 指向集群内 Redis Service(-redis:6379)。 +# - admin.app.env / admin.app.app_id 按项目实际值修改。 +server: + http: + network: tcp + addr: 0.0.0.0:8000 + timeout: 600s +data: + database: + driver: mysql + source: "" + host: "" + port: "" + user: "" + password: "" + name: "" + config: "" + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 3600 + engine: InnoDB + log_mode: info + singular: false + redis: + network: tcp + name: default + addr: kra-redis:6379 + password: "" + db: 0 + use_cluster: false + read_timeout: 200ms + write_timeout: 200ms +admin: + router_prefix: "" + jwt: + signing_key: "" + expires_time: 604800s + buffer_time: 86400s + issuer: kra + system: + use_redis: false + use_multipoint: false + use_strict_auth: false + disable_auto_migrate: false + use_mongo: false + addr: 8000 + local: + store_path: uploads/file + path_prefix: uploads/file + media: + session_ttl: 24 + max_file_size: "0" + chunk_dir: uploads/chunks + zap: + level: info + format: json + director: logs + log_in_console: true + cors: + mode: whitelist + whitelist: [] + app: + app_id: kra + env: production diff --git a/deploy/k8s/kra.yaml b/deploy/k8s/kra.yaml new file mode 100644 index 0000000..b34974b --- /dev/null +++ b/deploy/k8s/kra.yaml @@ -0,0 +1,352 @@ +# Template variables (rendered by Jenkins `envsubst "$K8S_RENDER_VARS"`): +# KRA_APP application name, derives every resource name +# KRA_NAMESPACE target namespace +# KRA_ENV development | production +# KRA_REPLICAS replicas per deployment +# KRA_FRONTEND_NODEPORT node port for the frontend service +# KRA_BACKEND_NODEPORT node port for the backend debug service +# KRA_MYSQL_IMAGE mysql server image +# KRA_REDIS_IMAGE redis server image +# KRA_BACKEND_IMAGE backend image built by Jenkins +# KRA_FRONTEND_IMAGE frontend image built by Jenkins +# BIN_NAME backend binary name inside the image +apiVersion: v1 +kind: Namespace +metadata: + name: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/part-of: ${KRA_APP} + app.kubernetes.io/managed-by: jenkins +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: ${KRA_APP}-mysql + namespace: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/name: ${KRA_APP}-mysql + app.kubernetes.io/part-of: ${KRA_APP} +spec: + serviceName: ${KRA_APP}-mysql + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-mysql + template: + metadata: + labels: + app.kubernetes.io/name: ${KRA_APP}-mysql + app.kubernetes.io/part-of: ${KRA_APP} + spec: + containers: + - name: mysql + image: ${KRA_MYSQL_IMAGE} + imagePullPolicy: IfNotPresent + ports: + - name: mysql + containerPort: 3306 + env: + - name: MYSQL_DATABASE + value: ${KRA_APP} + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: ${KRA_APP}-runtime + key: mysql-root-password + volumeMounts: + - name: mysql-data + mountPath: /var/lib/mysql + readinessProbe: + exec: + command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$MYSQL_ROOT_PASSWORD\" --silent"] + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 12 + livenessProbe: + exec: + command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$MYSQL_ROOT_PASSWORD\" --silent"] + initialDelaySeconds: 60 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1 + memory: 2Gi + volumes: + - name: mysql-data + hostPath: + path: /var/lib/${KRA_APP}/mysql + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: Service +metadata: + name: ${KRA_APP}-redis + namespace: ${KRA_NAMESPACE} +spec: + selector: + app.kubernetes.io/name: ${KRA_APP}-redis + ports: + - name: redis + port: 6379 + targetPort: redis +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${KRA_APP}-redis + namespace: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/name: ${KRA_APP}-redis + app.kubernetes.io/part-of: ${KRA_APP} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-redis + template: + metadata: + labels: + app.kubernetes.io/name: ${KRA_APP}-redis + app.kubernetes.io/part-of: ${KRA_APP} + spec: + containers: + - name: redis + image: ${KRA_REDIS_IMAGE} + imagePullPolicy: IfNotPresent + ports: + - name: redis + containerPort: 6379 + command: ["redis-server", "--appendonly", "yes"] + volumeMounts: + - name: redis-data + mountPath: /data + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 512Mi + volumes: + - name: redis-data + hostPath: + path: /var/lib/${KRA_APP}/redis + type: DirectoryOrCreate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${KRA_APP}-backend + namespace: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/name: ${KRA_APP}-backend + app.kubernetes.io/part-of: ${KRA_APP} +spec: + replicas: ${KRA_REPLICAS} + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-backend + template: + metadata: + labels: + app.kubernetes.io/name: ${KRA_APP}-backend + app.kubernetes.io/part-of: ${KRA_APP} + spec: + # /data/conf 由部署人员预先放置 config.yaml(见 deploy/k8s/config.example.yaml), + # 目录必须可写:初始化向导会把数据库连接写回该文件。 + imagePullSecrets: + - name: harbor-registry + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-backend + containers: + - name: backend + image: ${KRA_BACKEND_IMAGE} + imagePullPolicy: Always + command: ["./${BIN_NAME}"] + args: ["-conf", "/data/conf"] + ports: + - name: http + containerPort: 8000 + env: + # 数据库连接不在这里注入:应用启动时未配置数据库会进入 bootstrap 模式, + # 首页出现"前往初始化",由 /init/initdb 建表、写入种子数据并持久化连接配置。 + # 只注入 JWT 签名密钥;Redis 地址已在下方 config.yaml 中指定。 + - name: KRA_ADMIN_JWT_SIGNING_KEY + valueFrom: + secretKeyRef: + name: ${KRA_APP}-runtime + key: jwt-signing-key + - name: KRA_ADMIN_SYSTEM_USE_REDIS + value: "true" + volumeMounts: + - name: conf + mountPath: /data/conf + - name: uploads + mountPath: /app/uploads + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + volumes: + - name: conf + hostPath: + path: /var/lib/${KRA_APP}/conf + type: DirectoryOrCreate + - name: uploads + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: ${KRA_APP}-backend + namespace: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/name: ${KRA_APP}-backend +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: ${KRA_APP}-backend + ports: + - name: http + port: 8000 + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: ${KRA_APP}-backend-nodeport + namespace: ${KRA_NAMESPACE} +spec: + type: NodePort + selector: + app.kubernetes.io/name: ${KRA_APP}-backend + ports: + - name: http + port: 8000 + targetPort: http + nodePort: ${KRA_BACKEND_NODEPORT} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${KRA_APP}-frontend + namespace: ${KRA_NAMESPACE} + labels: + app.kubernetes.io/name: ${KRA_APP}-frontend + app.kubernetes.io/part-of: ${KRA_APP} +spec: + replicas: ${KRA_REPLICAS} + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-frontend + template: + metadata: + labels: + app.kubernetes.io/name: ${KRA_APP}-frontend + app.kubernetes.io/part-of: ${KRA_APP} + spec: + imagePullSecrets: + - name: harbor-registry + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: ${KRA_APP}-frontend + containers: + - name: frontend + image: ${KRA_FRONTEND_IMAGE} + imagePullPolicy: Always + ports: + - name: http + containerPort: 80 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: ${KRA_APP}-frontend + namespace: ${KRA_NAMESPACE} +spec: + type: NodePort + selector: + app.kubernetes.io/name: ${KRA_APP}-frontend + ports: + - name: http + port: 80 + targetPort: http + nodePort: ${KRA_FRONTEND_NODEPORT} diff --git a/deploy/k8s/runtime-secret.example.yaml b/deploy/k8s/runtime-secret.example.yaml new file mode 100644 index 0000000..8215930 --- /dev/null +++ b/deploy/k8s/runtime-secret.example.yaml @@ -0,0 +1,19 @@ +# 运行时 Secret 示例。 +# 真实环境由 Jenkinsfile 的 Deploy 阶段自动创建(kubectl create secret generic ${APP_NAME}-runtime), +# 本文件仅用于手工初始化环境的场景。尖括号为模板变量,使用前先替换成实际值。 +# +# 模板变量: +# APP_NAME 应用名(Jenkinsfile 中 APP_NAME,kra 项目即为 kra) +# KRA_NAMESPACE 目标命名空间(如 kra-dev / kra-prod) +apiVersion: v1 +kind: Secret +metadata: + name: -runtime + namespace: +type: Opaque +stringData: + # 替换为随机值;不要把真实密码提交进 Git。 + mysql-root-password: "CHANGE_ME_TO_A_RANDOM_VALUE" + database-source: "root:CHANGE_ME_TO_A_RANDOM_VALUE@tcp(-mysql:3306)/?charset=utf8mb4&parseTime=True&loc=Local" + redis-addr: "-redis:6379" + jwt-signing-key: "CHANGE_ME_TO_A_LONG_RANDOM_VALUE" diff --git a/internal/logging/zap.go b/internal/logging/zap.go index fa312ed..49cb89f 100644 --- a/internal/logging/zap.go +++ b/internal/logging/zap.go @@ -209,7 +209,8 @@ func formatConsoleEntry(entry zapcore.Entry, module string, fields []zapcore.Fie caller := "" if entry.Caller.Defined { - caller = filepath.Base(entry.Caller.File) + fmt.Sprintf(":%d", entry.Caller.Line) + callerPath := strings.ReplaceAll(entry.Caller.File, "\\", "/") + caller = filepath.Base(callerPath) + fmt.Sprintf(":%d", entry.Caller.Line) } if module == "" { module = "app" diff --git a/internal/server/gin.go b/internal/server/gin.go index 95decd1..63af3d4 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -65,10 +65,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access handleWebSocket := func(c *gin.Context) { wsLogger := logger hasToken := middleware.RequestToken(c, true) != "" - if ws == nil || !ws.Enabled() || c.Request.URL.Path != ws.Path() { + expectedPath := websocketRoutePath(prefix, ws) + if ws == nil || !ws.Enabled() || c.Request.URL.Path != expectedPath { if wsLogger != nil { wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: endpoint unavailable", - "mod", "websocket", "path", c.Request.URL.Path, "expected_path", websocketPath(ws), + "mod", "websocket", "path", c.Request.URL.Path, "expected_path", expectedPath, "enabled", ws != nil && ws.Enabled(), "has_token", hasToken) } c.Status(http.StatusNotFound) @@ -124,7 +125,7 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access } engine.NoRoute(func(c *gin.Context) { - if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() { + if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == websocketRoutePath(prefix, ws) { handleWebSocket(c) return } @@ -163,6 +164,17 @@ func websocketPath(ws *websocket.Server) string { return ws.Path() } +func websocketRoutePath(prefix string, ws *websocket.Server) string { + path := websocketPath(ws) + if path == "" { + return "" + } + if prefix == "" { + return path + } + return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/") +} + func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server { network, address := "tcp", ":8000" if c != nil && c.HTTP != nil { diff --git a/internal/server/websocket_auth_test.go b/internal/server/websocket_auth_test.go index 8a1c215..ea4680b 100644 --- a/internal/server/websocket_auth_test.go +++ b/internal/server/websocket_auth_test.go @@ -71,4 +71,37 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) { } } +func TestWebSocketRouteSupportsRouterPrefix(t *testing.T) { + store := runtimeconfig.NewStore() + raw, err := json.Marshal(map[string]any{"path": "/ws"}) + if err != nil { + t.Fatal(err) + } + store.Set(runtimeconfig.Config{Kind: "websocket", Provider: websocketintegration.ProviderMelody, Enabled: true, Values: raw}) + ws, cleanup, err := websocketintegration.New(store) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}} + runtime := config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}) + engine := NewGinEngineWithRuntime(runtime, nil, auth, nil, nil, nil, "test", nil, ws) + httpServer := httptest.NewServer(engine) + defer httpServer.Close() + + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/admin/ws?token=login-token" + connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil) + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + if err != nil { + t.Fatalf("prefixed websocket handshake failed: %v", err) + } + if connection == nil { + t.Fatal("prefixed websocket handshake returned nil connection") + } + _ = connection.Close() +} + var _ platformws.Hub = (*websocketintegration.Server)(nil) diff --git a/web/.docker-compose/nginx/conf.d/my.conf b/web/.docker-compose/nginx/conf.d/my.conf index 9a1685d..05c0de3 100644 --- a/web/.docker-compose/nginx/conf.d/my.conf +++ b/web/.docker-compose/nginx/conf.d/my.conf @@ -1,10 +1,9 @@ server { - listen 8080; + listen 80; server_name localhost; - #charset koi8-r; - #access_log logs/host.access.log main; - + # K8s 部署:proxy_pass 的服务名由 Jenkins envsubst '$KRA_APP' 渲染为 -backend, + # 产物写至 web/nginx.k8s.conf(不入库)。本地手动构建 web 镜像前需先执行渲染。 location / { root /usr/share/nginx/html; add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; @@ -17,10 +16,10 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; rewrite ^/api/(.*)$ /$1 break; #重写 - proxy_pass http://177.7.0.12:8888; # 设置代理服务器的协议和地址 + proxy_pass http://${KRA_APP}-backend:8000; # Kubernetes Service } location /api/swagger/index.html { - proxy_pass http://127.0.0.1:8888/swagger/index.html; + proxy_pass http://${KRA_APP}-backend:8000/swagger/index.html; } - } \ No newline at end of file + } diff --git a/web/.env.production b/web/.env.production index 87f863c..1dbca05 100644 --- a/web/.env.production +++ b/web/.env.production @@ -3,3 +3,6 @@ ENV = 'production' #下方为上线需要用到的程序代理前缀,一般用于nginx代理转发 VITE_BASE_API = /api VITE_FILE_API = /api + +# 后端与前端端口不一致时配置,例如 VITE_SERVER_PORT = 30081。 +# 也可以使用 VITE_WEBSOCKET_URL = http://192.168.200.128:30081 指定后端地址。 diff --git a/web/.gitignore b/web/.gitignore index a79a8b2..a2163d9 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -4,3 +4,5 @@ package-lock.json yarn.lock bun.lockb config.yaml +# 渲染产物:Jenkins 从 .docker-compose/nginx/conf.d/my.conf 生成 +/nginx.k8s.conf diff --git a/web/Dockerfile b/web/Dockerfile index 21a1d59..51179be 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,24 +1,5 @@ -# 如果需要用 cicd ,请设置环境变量: -# variables: -# DOCKER_BUILDKIT: 1 - -FROM node:20-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -COPY . /app -WORKDIR /app - - -FROM base AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod - -FROM base AS build -COPY --from=prod-deps /app/node_modules /app/node_modules -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install && pnpm run build - - -FROM nginx:alpine -COPY --from=base /app/.docker-compose/nginx/conf.d/my.conf /etc/nginx/conf.d/my.conf -COPY --from=build /app/dist /usr/share/nginx/html -RUN ls -al /usr/share/nginx/html +FROM docker.m.daocloud.io/nginx:alpine +# nginx.k8s.conf is rendered by Jenkins from +# .docker-compose/nginx/conf.d/my.conf (envsubst '$KRA_APP'). +COPY nginx.k8s.conf /etc/nginx/conf.d/default.conf +COPY dist /usr/share/nginx/html diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 587b3ce..f478de1 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,7 +1,7 @@ allowBuilds: - '@parcel/watcher': set this to true or false - '@vue-office/docx': set this to true or false - '@vue-office/excel': set this to true or false - '@vue-office/pdf': set this to true or false - es5-ext: set this to true or false - vue-demi: set this to true or false + '@parcel/watcher': true + '@vue-office/docx': true + '@vue-office/excel': true + '@vue-office/pdf': true + es5-ext: true + vue-demi: true diff --git a/web/src/view/systemTools/integration/config.vue b/web/src/view/systemTools/integration/config.vue index 3a75ffa..fb61604 100644 --- a/web/src/view/systemTools/integration/config.vue +++ b/web/src/view/systemTools/integration/config.vue @@ -170,7 +170,7 @@ 连接信息 @@ -326,9 +326,18 @@ const selected = computed( const integrationKey = (item) => `${item.kind}/${item.provider}` const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]] const isWebSocket = (item) => integrationKey(item) === 'websocket/melody' +const websocketBackendIsExplicit = () => + Boolean( + String(import.meta.env.VITE_WEBSOCKET_URL || '').trim() || + String(import.meta.env.VITE_SERVER_PORT || '').trim() + ) const websocketOrigin = () => { - if (import.meta.env.DEV && import.meta.env.VITE_SERVER_PORT) { - const configuredBase = String(import.meta.env.VITE_BASE_PATH || window.location.origin) + if (websocketBackendIsExplicit()) { + const configuredBase = String( + import.meta.env.VITE_WEBSOCKET_URL || + import.meta.env.VITE_BASE_PATH || + window.location.origin + ) const backend = new URL(configuredBase, window.location.origin) if ( ['127.0.0.1', 'localhost'].includes(backend.hostname) && @@ -336,18 +345,27 @@ const websocketOrigin = () => { ) { backend.hostname = window.location.hostname } - backend.port = String(import.meta.env.VITE_SERVER_PORT) + if (import.meta.env.VITE_SERVER_PORT) { + backend.port = String(import.meta.env.VITE_SERVER_PORT) + } backend.protocol = backend.protocol === 'https:' ? 'wss:' : 'ws:' return `${backend.protocol}//${backend.host}` } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${protocol}//${window.location.host}` } +const websocketPathPrefix = () => { + if (websocketBackendIsExplicit()) return '' + const base = String(import.meta.env.VITE_BASE_API || '').trim() + if (!base || /^https?:\/\//i.test(base)) return '' + return `/${base.replace(/^\/+|\/+$/g, '')}` +} const connectionInfo = computed(() => { const path = String(selected.value?.config?.path || '/ws').trim() || '/ws' const normalizedPath = path.startsWith('/') ? path : `/${path}` + const routePath = `${websocketPathPrefix()}${normalizedPath}` const token = String(userStore.token || '').trim() - const url = `${websocketOrigin()}${normalizedPath}${token ? `?token=${encodeURIComponent(token)}` : ''}` + const url = `${websocketOrigin()}${routePath}${token ? `?token=${encodeURIComponent(token)}` : ''}` return { url, example: `const socket = new WebSocket(${JSON.stringify(url)})\n\nsocket.onopen = () => console.log('connected')\nsocket.onmessage = (event) => console.log(event.data)` @@ -357,7 +375,7 @@ const connectionTestMessage = computed(() => { if (connectionTestStatus.value === 'testing') return '等待服务器握手包...' if (connectionTestStatus.value === 'success') return '握手成功,WebSocket 已连接。' if (connectionTestStatus.value === 'error') return '握手失败,请查看 websocket/websocket.log。' - return '点击“测试此地址”验证浏览器到服务器的真实握手。' + return '“测试连接”仅校验当前参数;保存并启用后可测试正式地址。' }) const connectionTestAlertType = computed(() => { if (connectionTestStatus.value === 'success') return 'success' @@ -469,6 +487,8 @@ const isBusy = (item) => Boolean(pending[operationKey(item)]) const isSaving = (item) => pending[operationKey(item)] === 'save' const isToggling = (item) => pending[operationKey(item)] === 'toggle' const isTesting = (item) => pending[operationKey(item)] === 'test' +const canTestConnectionInfo = (item) => + Boolean(item?.configured && item.enabled && !isDirty(item)) const numberConstraint = (fieldKey) => NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined } @@ -601,7 +621,7 @@ const testSelected = async () => { if (res.code !== 0) { return } - ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`) + ElMessage.success(`${item.name || providerMeta(item).name} 配置测试成功`) } catch { // The request layer owns transport and server error presentation. } finally {