jenkins
This commit is contained in:
parent
af1e194396
commit
43db5e5450
|
|
@ -2,6 +2,7 @@ FROM docker.m.daocloud.io/debian:stable-slim
|
||||||
|
|
||||||
# BIN_NAME 必须与 Makefile build 的 -o 产物名一致(Jenkins 会传 --build-arg)
|
# BIN_NAME 必须与 Makefile build 的 -o 产物名一致(Jenkins 会传 --build-arg)
|
||||||
ARG BIN_NAME=kratos-admin
|
ARG BIN_NAME=kratos-admin
|
||||||
|
ENV BIN_NAME=${BIN_NAME}
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|
@ -9,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& rm -rf /var/lib/apt/lists/ \
|
&& rm -rf /var/lib/apt/lists/ \
|
||||||
&& apt-get autoremove -y && apt-get autoclean -y
|
&& apt-get autoremove -y && apt-get autoclean -y
|
||||||
|
|
||||||
# Fixed UID/GID so the init container can chown the hostPath config dir.
|
# Fixed UID/GID so the init container can prepare shared storage.
|
||||||
RUN groupadd --system --gid 1000 kra && useradd --system --uid 1000 --gid kra --home-dir /app kra
|
RUN groupadd --system --gid 1000 kra && useradd --system --uid 1000 --gid kra --home-dir /app kra
|
||||||
|
|
||||||
COPY --chown=kra:kra bin /app
|
COPY --chown=kra:kra bin /app
|
||||||
|
|
@ -24,4 +25,4 @@ EXPOSE 8000
|
||||||
EXPOSE 9000
|
EXPOSE 9000
|
||||||
VOLUME /data/conf
|
VOLUME /data/conf
|
||||||
|
|
||||||
CMD ["./${BIN_NAME}", "-conf", "/data/conf"]
|
CMD ["sh", "-c", "exec ./$BIN_NAME -conf /data/conf"]
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ web/ Vue 管理端
|
||||||
|
|
||||||
## 启动后端
|
## 启动后端
|
||||||
|
|
||||||
先修改 `configs/config.yaml`。首次启动时,如果目标数据库不可用,服务会保留初始化接口;在管理端初始化页面完成建库、建表和种子数据写入后即可登录。
|
先按本地环境填写 `configs/config.yaml`,或通过 `KRA_DATA_DATABASE_*` / `KRA_ADMIN_JWT_SIGNING_KEY` 环境变量提供敏感配置。默认模板不包含数据库口令,首次启动时如果目标数据库不可用,服务会保留初始化接口;在管理端初始化页面完成建库、建表和种子数据写入后即可登录。
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
go run ./cmd -conf ./configs
|
go run ./cmd -conf ./configs
|
||||||
```
|
```
|
||||||
|
|
||||||
默认 HTTP 地址为 `http://127.0.0.1:8000`。生产环境必须替换 JWT 签名密钥和数据库凭据。
|
默认 HTTP 地址为 `http://127.0.0.1:8000`。生产环境必须通过受控配置提供 JWT 签名密钥和数据库凭据;不要把真实密钥提交到 Git。
|
||||||
|
|
||||||
配置文件发生变化时会自动刷新内存配置;管理端“重载服务”会进一步重建数据库、Redis、对象存储连接并重新载入定时任务。路由前缀和 HTTP 监听地址需要重启进程后生效。
|
配置文件发生变化时会自动刷新内存配置;管理端“重载服务”会进一步重建数据库、Redis、对象存储连接并重新载入定时任务。路由前缀和 HTTP 监听地址需要重启进程后生效。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ server:
|
||||||
data:
|
data:
|
||||||
database:
|
database:
|
||||||
driver: mysql
|
driver: mysql
|
||||||
source: root:Xu950329.@tcp(127.0.0.1:3306)/kra?charset=utf8mb4&parseTime=True&loc=Local
|
source: ""
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: "3306"
|
port: "3306"
|
||||||
user: root
|
user: root
|
||||||
password: Xu950329.
|
password: ""
|
||||||
name: kra
|
name: ""
|
||||||
config: charset=utf8mb4&parseTime=True&loc=Local
|
config: timeout=5s&readTimeout=5s&writeTimeout=5s&charset=utf8mb4&parseTime=True&loc=Local
|
||||||
path: ""
|
path: ""
|
||||||
alias_name: ""
|
alias_name: ""
|
||||||
disable: false
|
disable: false
|
||||||
|
|
@ -87,8 +87,8 @@ admin:
|
||||||
# The administration system router prefix maps to this transport prefix.
|
# The administration system router prefix maps to this transport prefix.
|
||||||
router_prefix: ""
|
router_prefix: ""
|
||||||
jwt:
|
jwt:
|
||||||
# Production deployments must override this value with a private secret.
|
# Generated during first-install, or provide KRA_ADMIN_JWT_SIGNING_KEY locally.
|
||||||
signing_key: 593e26b1-54dc-4465-9245-498ae08a957a
|
signing_key: ""
|
||||||
expires_time: 604800s
|
expires_time: 604800s
|
||||||
buffer_time: 86400s
|
buffer_time: 86400s
|
||||||
issuer: kra
|
issuer: kra
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ GitLab ──(Jenkins 集成,请求内嵌凭据)──► Jenkins job「kra」
|
||||||
(3 后端 + 3 前端 + MySQL + Redis)
|
(3 后端 + 3 前端 + MySQL + Redis)
|
||||||
```
|
```
|
||||||
|
|
||||||
分支路由的核心:GitLab 集成发送的 push payload 中带 `ref`(分支),Jenkins 的 gitlab-plugin 把它注入为环境变量 `gitlabBranch`,`deploy/Jenkinsfile` 的 `Select environment` 阶段按它派生出命名空间、环境标识、目标集群与 kubeconfig 凭据,`Checkout branch` 阶段再动态检出实际推送的分支。**单 job 同时服务两个环境,不会串**:
|
分支路由的核心:GitLab 集成发送的 push payload 中带 `ref`(分支),Jenkins 的 gitlab-plugin 把它注入为环境变量 `gitlabBranch`,并把 push 的最终提交注入为 `gitlabAfter`。`deploy/Jenkinsfile` 的 `Select environment` 阶段按分支派生出命名空间、环境标识、目标集群与 kubeconfig 凭据,随后优先按 `gitlabAfter` 的 SHA 检出构建内容。**单 job 同时服务两个环境,不会串**:
|
||||||
|
|
||||||
- 镜像 tag 含分支与 commit(如 `dev-f0657c1abc12`),按 commit 不可变;
|
- 镜像 tag 含分支与 commit(如 `dev-f0657c1abc12`),按 commit 不可变;
|
||||||
- 构建不并发(`disableConcurrentBuilds`),同时推送时排队执行;
|
- 构建不并发(`disableConcurrentBuilds`),同时推送时排队执行;
|
||||||
|
|
@ -114,7 +114,7 @@ systemctl restart rke2-server
|
||||||
| Git | SCM 检出 |
|
| Git | SCM 检出 |
|
||||||
| Credentials / Credentials Binding | 凭据管理与注入 |
|
| Credentials / Credentials Binding | 凭据管理与注入 |
|
||||||
|
|
||||||
### 5.3 凭据(6 个,ID 与 `deploy/Jenkinsfile` 中变量对应)
|
### 5.3 凭据(5 个,ID 与 `deploy/Jenkinsfile` 中变量对应)
|
||||||
|
|
||||||
| 凭据 ID | 类型 | 来源 / 获取方式 |
|
| 凭据 ID | 类型 | 来源 / 获取方式 |
|
||||||
|---------|------|----------------|
|
|---------|------|----------------|
|
||||||
|
|
@ -123,7 +123,6 @@ systemctl restart rke2-server
|
||||||
| `kra-rke2-128-kubeconfig` | Secret file | 128 节点 `/etc/rancher/rke2/rke2.yaml`,**把 server 的 127.0.0.1 改为 192.168.200.128** 后保存 |
|
| `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-rke2-129-kubeconfig` | Secret file | 129 节点同上,server 改为 192.168.200.129 |
|
||||||
| `kra-mysql-root-password` | Secret text | 自定的 MySQL root 密码(与节点上 MySQL 一致) |
|
| `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 添加路径:系统管理 → 凭据 → Add Credentials → 类型 Secret file → 上传文件 → 填 ID。
|
||||||
|
|
||||||
|
|
@ -150,22 +149,25 @@ gitlabBranch(集成/推送触发)
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| Harbor 拉镜像信任 | 见 §4 第 3 步 `registries.yaml` |
|
| 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) |
|
| 应用配置文件 | Jenkins 将 [k8s/config.example.yaml](k8s/config.example.yaml) 渲染为 `<APP_NAME>-config-template` ConfigMap,初始化容器复制到共享配置 PVC `/data/conf` |
|
||||||
| 数据持久化 | `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis`(节点本地目录,当前单节点集群适用) |
|
| 配置与上传存储 | `<APP_NAME>-config`(1Gi)和 `<APP_NAME>-uploads`(20Gi),均要求存储类支持 `ReadWriteMany` |
|
||||||
|
| 数据持久化 | `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis`(节点本地目录,当前单节点集群适用;Redis initContainer 需要允许 root 修正 hostPath 权限) |
|
||||||
| kubectl | 节点用 `/var/lib/rancher/rke2/bin/kubectl`,并 `export KUBECONFIG=/etc/rancher/rke2/rke2.yaml` |
|
| kubectl | 节点用 `/var/lib/rancher/rke2/bin/kubectl`,并 `export KUBECONFIG=/etc/rancher/rke2/rke2.yaml` |
|
||||||
|
|
||||||
**首次部署初始化流程**(kra 框架约定,勿用环境变量注入数据库 DSN):
|
**首次部署初始化流程**(kra 框架约定,勿用环境变量注入数据库 DSN):
|
||||||
|
|
||||||
1. 服务启动时数据库未就绪,首页显示"前往初始化"。
|
1. 服务启动时数据库未就绪,首页显示"前往初始化"。
|
||||||
2. 走完 `/init/initdb` 向导:建库建表 + 写入种子数据(默认账号 admin)。
|
2. 走完 `/init/initdb` 向导:建库建表 + 写入种子数据(默认账号 admin)。
|
||||||
3. 向导最后一步把数据库连接**写回** `/data/conf/config.yaml`(即节点的 hostPath 文件),配置持久化在节点上。
|
3. 向导最后一步把数据库连接**写回**共享 PVC 中的 `/data/conf/config.yaml`,配置不会随 Pod 消失。
|
||||||
4. 若需后续手工改配置:改节点文件后 `rollout restart deployment/kra-backend` 让 3 副本同步。
|
4. 初始化期间建议先把后端缩容到 1 个副本:`kubectl -n <APP_NAME>-dev scale deployment/<APP_NAME>-backend --replicas=1`。初始化完成后再恢复副本数并滚动重启:`kubectl -n <APP_NAME>-dev scale deployment/<APP_NAME>-backend --replicas=3`,然后 `kubectl -n <APP_NAME>-dev rollout status deployment/<APP_NAME>-backend`。后续手工改配置也需要执行滚动重启。
|
||||||
|
|
||||||
|
> `<APP_NAME>-config-template` ConfigMap 只负责首次创建配置文件。初始化后,运行时配置以共享 PVC 中的 `/data/conf/config.yaml` 为准;更新 ConfigMap 不会覆盖已有配置,避免覆盖初始化写入的数据库连接。
|
||||||
|
|
||||||
## 7. 首次部署清单(按顺序)
|
## 7. 首次部署清单(按顺序)
|
||||||
|
|
||||||
1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。
|
1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。
|
||||||
2. RKE2 节点:`registries.yaml` 配好并重启 rke2-server;放置 `/var/lib/kra/conf/config.yaml`。
|
2. RKE2 集群:确认 `KRA_SHARED_STORAGE_CLASS` 对应的存储类支持 `ReadWriteMany`。
|
||||||
3. Jenkins:安装插件;添加 §5.3 全部 6 个凭据;按 §5.4 建 `kra` job。
|
3. Jenkins:安装插件;添加 §5.3 所列 5 个凭据;按 §5.4 建 `kra` job。
|
||||||
4. GitLab:配置 Jenkins 集成(§3)。
|
4. GitLab:配置 Jenkins 集成(§3)。
|
||||||
5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。
|
5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。
|
||||||
6. 初始化向导(§6),完成后浏览器验证。
|
6. 初始化向导(§6),完成后浏览器验证。
|
||||||
|
|
@ -178,7 +180,7 @@ curl http://192.168.200.128:30081/health # "ok"
|
||||||
curl -I http://192.168.200.129:30080/ # 同上验证 129
|
curl -I http://192.168.200.129:30080/ # 同上验证 129
|
||||||
```
|
```
|
||||||
|
|
||||||
构建日志里 `resolved branch='xxx'` 一行可直接确认本次构建部署到了哪个环境。
|
构建日志里的 `resolved branch='xxx'` 和 `building commit <sha>` 两行可分别确认部署环境和实际构建版本。
|
||||||
|
|
||||||
## 9. 排障速查
|
## 9. 排障速查
|
||||||
|
|
||||||
|
|
@ -187,12 +189,12 @@ curl -I http://192.168.200.129:30080/ # 同上验证 129
|
||||||
| push 后没有构建 | GitLab 集成"最近事件"(hook_logs)看响应码与请求体;确认集成启用且勾选推送 |
|
| push 后没有构建 | GitLab 集成"最近事件"(hook_logs)看响应码与请求体;确认集成启用且勾选推送 |
|
||||||
| 构建失败 | Jenkins 构建日志;先看 `resolved branch` 是否符合预期 |
|
| 构建失败 | Jenkins 构建日志;先看 `resolved branch` 是否符合预期 |
|
||||||
| Pod 镜像拉取失败 | 节点 `registries.yaml`、Harbor robot 是否有效、`harbor-registry` Secret |
|
| Pod 镜像拉取失败 | 节点 `registries.yaml`、Harbor robot 是否有效、`harbor-registry` Secret |
|
||||||
| 后端 503 / 初始化异常 | 节点 `/var/lib/kra/conf/config.yaml` 是否存在且属主 1000:1000;后端日志是否连上 `<APP_NAME>-mysql:3306` |
|
| 后端 503 / 初始化异常 | 配置 PVC 是否已绑定、ConfigMap 是否存在、后端日志是否连上 `<APP_NAME>-mysql:3306` |
|
||||||
| 登录态全部失效 | 是否更换了 `kra-jwt-signing-key`(换值即失效所有 token) |
|
| 登录态全部失效 | 是否在系统设置中更换了 JWT 签名密钥(换值即失效所有 token) |
|
||||||
| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 |
|
| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 |
|
||||||
|
|
||||||
## 10. 附录:安全与维护
|
## 10. 附录:安全与维护
|
||||||
|
|
||||||
- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。
|
- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。
|
||||||
- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。
|
- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。
|
||||||
- 轮换 Harbor robot / MySQL / JWT key:更新 Jenkins 对应凭据 → 推送任意分支触发一次部署(kubeconfig 类凭据即时生效;JWT 轮换会使登录态失效)。
|
- 轮换 Harbor robot:更新 Jenkins 凭据后重新部署即可。轮换 MySQL 密码必须先在 MySQL 中执行 `ALTER USER` 并确认连接正常,再更新 Jenkins 凭据并重启相关 Pod;仅更新 Secret 不会修改已初始化 MySQL 数据目录中的 root 密码。JWT 签名密钥通过系统设置修改并保存到共享配置 PVC,随后滚动重启后端;换值会使现有登录态失效。
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ pipeline {
|
||||||
// 项目级参数 —— 基于 kra 模板的新项目只需修改本块。
|
// 项目级参数 —— 基于 kra 模板的新项目只需修改本块。
|
||||||
// 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。
|
// 这些变量自动 export 到 shell,由 envsubst 白名单渲染进 k8s 清单。
|
||||||
// ══════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════
|
||||||
APP_NAME = 'kra' // 派生:服务名/Secret名/库名/hostPath/镜像后缀
|
APP_NAME = 'kra' // 派生:服务名/Secret名/库名/存储声明/镜像后缀
|
||||||
IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径
|
IMAGE_REPO = 'library/kra' // Harbor 中镜像仓库路径
|
||||||
REGISTRY = '192.168.200.132'
|
REGISTRY = '192.168.200.132'
|
||||||
BIN_NAME = 'kratos-admin' // Makefile build 的产物名
|
BIN_NAME = 'kratos-admin' // Makefile build 的产物名
|
||||||
|
|
@ -19,24 +19,20 @@ pipeline {
|
||||||
KRA_BACKEND_NODEPORT = '30081'
|
KRA_BACKEND_NODEPORT = '30081'
|
||||||
KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4'
|
KRA_MYSQL_IMAGE = 'docker.m.daocloud.io/library/mysql:8.4'
|
||||||
KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine'
|
KRA_REDIS_IMAGE = 'docker.m.daocloud.io/library/redis:7-alpine'
|
||||||
|
KRA_SHARED_STORAGE_CLASS = 'longhorn' // 必须支持 ReadWriteMany;按目标集群实际存储类修改
|
||||||
GO_IMAGE = 'docker.m.daocloud.io/golang:1.25'
|
GO_IMAGE = 'docker.m.daocloud.io/golang:1.25'
|
||||||
NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim'
|
NODE_IMAGE = 'docker.m.daocloud.io/node:20-slim'
|
||||||
PNPM_VERSION = '10.15.1'
|
PNPM_VERSION = '10.15.1'
|
||||||
CACHE_DIR = '/var/lib/kra-ci-cache' // Jenkins 宿主机依赖缓存根目录
|
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(新项目若沿用同一套凭据可不改)
|
// Jenkins 凭据 ID(新项目若沿用同一套凭据可不改)
|
||||||
CRED_HARBOR_PUSH = 'harbor-library-push'
|
CRED_HARBOR_PUSH = 'harbor-library-push'
|
||||||
CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig'
|
CRED_KUBECONFIG_DEV = 'kra-rke2-128-kubeconfig'
|
||||||
CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig'
|
CRED_KUBECONFIG_PROD = 'kra-rke2-129-kubeconfig'
|
||||||
CRED_MYSQL_PASSWORD = 'kra-mysql-root-password'
|
CRED_MYSQL_PASSWORD = 'kra-mysql-root-password'
|
||||||
CRED_JWT_SIGNING_KEY = 'kra-jwt-signing-key'
|
|
||||||
|
|
||||||
// k8s 清单渲染白名单:只替换列出的变量,避免误伤清单内 shell 变量(如 probe 里的 $MYSQL_ROOT_PASSWORD)
|
// 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'
|
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 $KRA_SHARED_STORAGE_CLASS $BIN_NAME'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
|
|
@ -51,17 +47,21 @@ pipeline {
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main'
|
branch = env.JOB_NAME.endsWith('-dev') ? 'dev' : 'main'
|
||||||
}
|
}
|
||||||
|
branch = branch.trim().replaceFirst(/^refs\/heads\//, '')
|
||||||
|
// The backend NodePort service has the longest generated name:
|
||||||
|
// <APP_NAME>-backend-nodeport, which must stay within 63 characters.
|
||||||
|
if (!(env.APP_NAME ==~ /^[a-z0-9](?:[a-z0-9-]{0,44}[a-z0-9])?$/)) {
|
||||||
|
error("APP_NAME must be a lowercase Kubernetes name with at most 46 characters: ${env.APP_NAME}")
|
||||||
|
}
|
||||||
echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'"
|
echo "gitlabBranch='${env.gitlabBranch ?: ''}' BRANCH_NAME='${env.BRANCH_NAME ?: ''}' -> resolved branch='${branch}'"
|
||||||
env.KRA_BRANCH = branch
|
env.KRA_BRANCH = branch
|
||||||
if (branch == 'dev') {
|
if (branch == 'dev') {
|
||||||
env.KRA_NAMESPACE = "${env.APP_NAME}-dev"
|
env.KRA_NAMESPACE = "${env.APP_NAME}-dev"
|
||||||
env.KRA_ENV = 'development'
|
env.KRA_ENV = 'development'
|
||||||
env.KRA_CLUSTER_HOST = env.DEV_CLUSTER_HOST
|
|
||||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV
|
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_DEV
|
||||||
} else if (branch == 'main') {
|
} else if (branch == 'main') {
|
||||||
env.KRA_NAMESPACE = "${env.APP_NAME}-prod"
|
env.KRA_NAMESPACE = "${env.APP_NAME}-prod"
|
||||||
env.KRA_ENV = 'production'
|
env.KRA_ENV = 'production'
|
||||||
env.KRA_CLUSTER_HOST = env.PROD_CLUSTER_HOST
|
|
||||||
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD
|
env.KUBECONFIG_CREDENTIALS = env.CRED_KUBECONFIG_PROD
|
||||||
} else {
|
} else {
|
||||||
error("Unsupported branch: ${branch}. Only dev and main deploy.")
|
error("Unsupported branch: ${branch}. Only dev and main deploy.")
|
||||||
|
|
@ -70,13 +70,31 @@ pipeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Checkout branch') {
|
stage('Checkout triggered commit') {
|
||||||
steps {
|
steps {
|
||||||
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
||||||
// 保证单 job 同时服务 dev/main 两个环境。
|
// 保证单 job 同时服务 dev/main 两个环境。
|
||||||
|
script {
|
||||||
|
env.KRA_TRIGGER_COMMIT = env.gitlabAfter?.trim()
|
||||||
|
if (env.KRA_TRIGGER_COMMIT && !(env.KRA_TRIGGER_COMMIT ==~ /[0-9a-fA-F]{40}/)) {
|
||||||
|
error("Invalid GitLab commit SHA: ${env.KRA_TRIGGER_COMMIT}")
|
||||||
|
}
|
||||||
|
if (env.KRA_TRIGGER_COMMIT && env.KRA_TRIGGER_COMMIT.replace('0', '').isEmpty()) {
|
||||||
|
error('GitLab event points to a deleted branch; refusing to deploy')
|
||||||
|
}
|
||||||
checkout([$class: 'GitSCM',
|
checkout([$class: 'GitSCM',
|
||||||
branches: [[name: "*/${env.KRA_BRANCH}"]],
|
branches: [[name: "*/${env.KRA_BRANCH}"]],
|
||||||
userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]])
|
userRemoteConfigs: [[credentialsId: 'gitlab-root-http', url: 'http://192.168.200.130/root/kra.git']]])
|
||||||
|
if (env.KRA_TRIGGER_COMMIT) {
|
||||||
|
sh "git fetch --no-tags origin ${env.KRA_TRIGGER_COMMIT}"
|
||||||
|
sh "git checkout --detach ${env.KRA_TRIGGER_COMMIT}"
|
||||||
|
}
|
||||||
|
env.KRA_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||||
|
if (env.KRA_TRIGGER_COMMIT && env.KRA_COMMIT != env.KRA_TRIGGER_COMMIT) {
|
||||||
|
error("Checked out ${env.KRA_COMMIT}, expected GitLab commit ${env.KRA_TRIGGER_COMMIT}")
|
||||||
|
}
|
||||||
|
echo "building commit ${env.KRA_COMMIT}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,17 +129,20 @@ pipeline {
|
||||||
stage('Build and push images') {
|
stage('Build and push images') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.GIT_COMMIT.take(12)}"
|
env.IMAGE_TAG = "${env.KRA_BRANCH}-${env.KRA_COMMIT.take(12)}"
|
||||||
}
|
}
|
||||||
withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
withCredentials([usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" --username "$HARBOR_USER" --password-stdin
|
umask 077
|
||||||
docker build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" .
|
DOCKER_CONFIG_DIR=$(mktemp -d)
|
||||||
docker build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web
|
trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT
|
||||||
docker push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
echo "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||||
docker push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
docker --config "$DOCKER_CONFIG_DIR" build --build-arg BIN_NAME="$BIN_NAME" -t "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG" .
|
||||||
docker logout "$REGISTRY"
|
docker --config "$DOCKER_CONFIG_DIR" build -t "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG" web
|
||||||
|
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||||
|
docker --config "$DOCKER_CONFIG_DIR" push "$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||||
|
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,25 +152,46 @@ pipeline {
|
||||||
steps {
|
steps {
|
||||||
withCredentials([file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'),
|
withCredentials([file(credentialsId: "${env.KUBECONFIG_CREDENTIALS}", variable: 'KUBECONFIG_FILE'),
|
||||||
string(credentialsId: env.CRED_MYSQL_PASSWORD, variable: 'KRA_MYSQL_ROOT_PASSWORD'),
|
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')]) {
|
usernamePassword(credentialsId: env.CRED_HARBOR_PUSH, usernameVariable: 'HARBOR_USER', passwordVariable: 'HARBOR_PASSWORD')]) {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
export KUBECONFIG="$KUBECONFIG_FILE"
|
export KUBECONFIG="$KUBECONFIG_FILE"
|
||||||
|
umask 077
|
||||||
|
TMP_DIR=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
kubectl get storageclass "$KRA_SHARED_STORAGE_CLASS" >/dev/null
|
||||||
kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
kubectl create namespace "$KRA_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||||
kubectl -n "$KRA_NAMESPACE" create secret docker-registry harbor-registry \\
|
DOCKER_CONFIG_DIR="$TMP_DIR/docker"
|
||||||
--docker-server="$REGISTRY" --docker-username="$HARBOR_USER" --docker-password="$HARBOR_PASSWORD" \\
|
mkdir -p "$DOCKER_CONFIG_DIR"
|
||||||
|
printf '%s' "$HARBOR_PASSWORD" | docker --config "$DOCKER_CONFIG_DIR" login "$REGISTRY" --username "$HARBOR_USER" --password-stdin >/dev/null
|
||||||
|
kubectl -n "$KRA_NAMESPACE" create secret generic harbor-registry \\
|
||||||
|
--type=kubernetes.io/dockerconfigjson \\
|
||||||
|
--from-file=.dockerconfigjson="$DOCKER_CONFIG_DIR/config.json" \\
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||||
|
printf '%s' "$KRA_MYSQL_ROOT_PASSWORD" > "$TMP_DIR/mysql-root-password"
|
||||||
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\
|
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-runtime" \\
|
||||||
--from-literal=mysql-root-password="$KRA_MYSQL_ROOT_PASSWORD" \\
|
--from-file=mysql-root-password="$TMP_DIR/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" \\
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
--from-literal=jwt-signing-key="$KRA_JWT_SIGNING_KEY" \\
|
CONFIG_TEMPLATE="$TMP_DIR/config.yaml"
|
||||||
--from-literal=redis-addr="${APP_NAME}-redis:6379" \\
|
envsubst '$APP_NAME $KRA_ENV' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE"
|
||||||
|
kubectl -n "$KRA_NAMESPACE" create configmap "${APP_NAME}-config-template" \\
|
||||||
|
--from-file=config.yaml="$CONFIG_TEMPLATE" \\
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
export KRA_APP="$APP_NAME"
|
export KRA_APP="$APP_NAME"
|
||||||
export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
export KRA_BACKEND_IMAGE="$REGISTRY/$IMAGE_REPO-backend:$IMAGE_TAG"
|
||||||
export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
export KRA_FRONTEND_IMAGE="$REGISTRY/$IMAGE_REPO-frontend:$IMAGE_TAG"
|
||||||
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml | kubectl apply -f -
|
RENDERED_MANIFEST="$TMP_DIR/manifest.yaml"
|
||||||
|
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST"
|
||||||
|
kubectl apply --dry-run=server -f "$RENDERED_MANIFEST"
|
||||||
|
kubectl apply -f "$RENDERED_MANIFEST"
|
||||||
|
RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}')
|
||||||
|
kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\
|
||||||
|
-p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"kra.dev/runtime-secret-version\":\"$RUNTIME_SECRET_VERSION\"}}}}}"
|
||||||
|
kubectl -n "$KRA_NAMESPACE" wait --for=jsonpath='{.status.phase}'=Bound \\
|
||||||
|
"pvc/${APP_NAME}-config" "pvc/${APP_NAME}-uploads" --timeout=5m
|
||||||
|
kubectl -n "$KRA_NAMESPACE" rollout status statefulset/${APP_NAME}-mysql --timeout=10m
|
||||||
|
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-redis --timeout=10m
|
||||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m
|
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-backend --timeout=10m
|
||||||
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m
|
kubectl -n "$KRA_NAMESPACE" rollout status deployment/${APP_NAME}-frontend --timeout=10m
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的
|
||||||
|
|
||||||
- Jenkins 服务器 URL:`http://192.168.200.131:8080`,Project name:`kra`,填入 Jenkins 用户名/密码(凭据由 GitLab 保管,无需手动维护 webhook 或请求头)
|
- Jenkins 服务器 URL:`http://192.168.200.131:8080`,Project name:`kra`,填入 Jenkins 用户名/密码(凭据由 GitLab 保管,无需手动维护 webhook 或请求头)
|
||||||
- 集成会把完整 push 事件(含 `ref` 分支)POST 到 Jenkins `/project/<job>` 端点,gitlab-plugin 注入 `gitlabBranch` 环境变量
|
- 集成会把完整 push 事件(含 `ref` 分支)POST 到 Jenkins `/project/<job>` 端点,gitlab-plugin 注入 `gitlabBranch` 环境变量
|
||||||
- `deploy/Jenkinsfile` 的 `Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,`Checkout branch` 阶段动态检出实际推送的分支
|
- `deploy/Jenkinsfile` 的 `Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,构建阶段优先按 push payload 的最终提交 SHA 检出
|
||||||
- 手动构建兜底:`kra` job 手动触发时默认部署 main(不识别 tag,tag 仅作版本标记,不触发部署)
|
- 手动构建兜底:`kra` job 手动触发时默认部署 main(不识别 tag,tag 仅作版本标记,不触发部署)
|
||||||
|
|
||||||
每个环境包含 3 个后端 Pod、3 个前端 Pod、1 个 MySQL Pod 和 1 个 Redis Pod。前端通过 `<APP_NAME>-backend` ClusterIP Service 访问后端,Kubernetes Service 在 Pod 之间做负载均衡;前端通过 NodePort `30080` 暴露,后端调试入口通过 NodePort `30081` 暴露。
|
每个环境包含 3 个后端 Pod、3 个前端 Pod、1 个 MySQL Pod 和 1 个 Redis Pod。前端通过 `<APP_NAME>-backend` ClusterIP Service 访问后端,Kubernetes Service 在 Pod 之间做负载均衡;前端通过 NodePort `30080` 暴露,后端调试入口通过 NodePort `30081` 暴露。后端配置和上传文件使用支持 `ReadWriteMany` 的共享 PVC。
|
||||||
|
|
||||||
MySQL 和 Redis 使用节点本地目录 `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis` 持久化。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,不提供数据库跨节点高可用。
|
MySQL 和 Redis 使用节点本地目录 `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis` 持久化。Redis 部署会先用一次性 root initContainer 修正 hostPath 权限,主进程仍以镜像内的非 root 用户运行。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,不提供数据库跨节点高可用。
|
||||||
|
|
||||||
## Jenkins 前置条件
|
## Jenkins 前置条件
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@ Jenkins 节点需要可用的 Docker CLI/daemon、`kubectl` 和 `envsubst`(Go
|
||||||
- `kra-rke2-128-kubeconfig`: 集群 128 的 kubeconfig 文件
|
- `kra-rke2-128-kubeconfig`: 集群 128 的 kubeconfig 文件
|
||||||
- `kra-rke2-129-kubeconfig`: 集群 129 的 kubeconfig 文件
|
- `kra-rke2-129-kubeconfig`: 集群 129 的 kubeconfig 文件
|
||||||
- `kra-mysql-root-password`: 应用专用 MySQL root 密码
|
- `kra-mysql-root-password`: 应用专用 MySQL root 密码
|
||||||
- `kra-jwt-signing-key`: 生产 JWT 签名密钥
|
|
||||||
|
|
||||||
Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-registry` 镜像拉取 Secret。不要把真实密码写入 Git。
|
Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-registry` 镜像拉取 Secret。不要把真实密码写入 Git。
|
||||||
|
|
||||||
|
|
@ -42,15 +41,7 @@ Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-r
|
||||||
|
|
||||||
### 前置:放置配置文件
|
### 前置:放置配置文件
|
||||||
|
|
||||||
后端启动读取 `/data/conf/config.yaml`,该目录以 hostPath `/var/lib/<APP_NAME>/conf` 挂载到 K8s 节点。部署人员在**目标集群节点**上执行(以 kra 项目为例):
|
后端启动读取 `/data/conf/config.yaml`。流水线会把 `deploy/k8s/config.example.yaml` 渲染成 ConfigMap,并由初始化容器复制到共享配置 PVC;容器内运行用户为 UID/GID 1000,初始化向导会把数据库连接和 JWT 密钥写回该文件。
|
||||||
|
|
||||||
```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 密钥写回该文件。
|
|
||||||
|
|
||||||
### 初始化
|
### 初始化
|
||||||
|
|
||||||
|
|
@ -65,7 +56,7 @@ chown -R 1000:1000 /var/lib/kra/conf
|
||||||
| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 |
|
| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 |
|
||||||
| 库名 | `kra`(与应用名一致) |
|
| 库名 | `kra`(与应用名一致) |
|
||||||
|
|
||||||
提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回节点的 `config.yaml`,Pod 重建后配置不丢失。多副本共享同一目录,初始化完成后会自动滚动重启后端让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。
|
提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回共享 PVC 中的 `config.yaml`,Pod 重建后配置不丢失。初始化期间建议暂时保持后端为 1 个副本,完成后再扩容到 `KRA_REPLICAS`,避免多个 Pod 同时执行首次初始化。初始化完成后请执行一次后端滚动重启,让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。
|
||||||
|
|
||||||
## 基于 kra 模板接入新项目
|
## 基于 kra 模板接入新项目
|
||||||
|
|
||||||
|
|
@ -73,16 +64,16 @@ chown -R 1000:1000 /var/lib/kra/conf
|
||||||
|
|
||||||
| 变量 | 说明 | 默认值(kra 项目) |
|
| 变量 | 说明 | 默认值(kra 项目) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `APP_NAME` | 应用名,派生所有 K8s 资源名、数据库名、hostPath、镜像后缀 | `kra` |
|
| `APP_NAME` | 应用名,派生所有 K8s 资源名、数据库名、存储声明、镜像后缀 | `kra` |
|
||||||
| `IMAGE_REPO` | Harbor 镜像仓库路径 | `library/kra` |
|
| `IMAGE_REPO` | Harbor 镜像仓库路径 | `library/kra` |
|
||||||
| `REGISTRY` | Harbor 地址 | `192.168.200.132` |
|
| `REGISTRY` | Harbor 地址 | `192.168.200.132` |
|
||||||
| `BIN_NAME` | 后端二进制名,须与 `Makefile` 的 `make build` 产物一致 | `kratos-admin` |
|
| `BIN_NAME` | 后端二进制名,须与 `Makefile` 的 `make build` 产物一致 | `kratos-admin` |
|
||||||
| `REPLICAS` | 前后端副本数 | `3` |
|
| `KRA_REPLICAS` | 前后端副本数 | `3` |
|
||||||
| `FRONTEND_NODEPORT` / `BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` |
|
| `KRA_FRONTEND_NODEPORT` / `KRA_BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` |
|
||||||
| `MYSQL_IMAGE` / `REDIS_IMAGE` | 中间件镜像 | daocloud mysql:8.4 / redis:7-alpine |
|
| `KRA_MYSQL_IMAGE` / `KRA_REDIS_IMAGE` | 中间件镜像 | daocloud mysql:8.4 / redis:7-alpine |
|
||||||
|
| `KRA_SHARED_STORAGE_CLASS` | 支持 `ReadWriteMany` 的共享存储类 | `longhorn` |
|
||||||
| `GO_IMAGE` / `NODE_IMAGE` / `PNPM_VERSION` | 构建镜像与工具版本 | 见文件 |
|
| `GO_IMAGE` / `NODE_IMAGE` / `PNPM_VERSION` | 构建镜像与工具版本 | 见文件 |
|
||||||
| `CACHE_DIR` | Jenkins 宿主机依赖缓存目录(建议每项目独立) | `/var/lib/kra-ci-cache` |
|
| `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,沿用现有凭据可不改 | 见文件 |
|
| `CRED_*` | Jenkins 凭据 ID,沿用现有凭据可不改 | 见文件 |
|
||||||
|
|
||||||
### 模板渲染机制
|
### 模板渲染机制
|
||||||
|
|
@ -100,8 +91,8 @@ chown -R 1000:1000 /var/lib/kra/conf
|
||||||
|---|---|
|
|---|---|
|
||||||
| Deployment/Service | `<APP_NAME>-backend`、`<APP_NAME>-frontend`、`<APP_NAME>-redis` |
|
| Deployment/Service | `<APP_NAME>-backend`、`<APP_NAME>-frontend`、`<APP_NAME>-redis` |
|
||||||
| StatefulSet/Service | `<APP_NAME>-mysql` |
|
| StatefulSet/Service | `<APP_NAME>-mysql` |
|
||||||
| ConfigMap | `<APP_NAME>-config` |
|
| ConfigMap | `<APP_NAME>-config-template` |
|
||||||
| 运行时 Secret | `<APP_NAME>-runtime`(key:`mysql-root-password`、`database-source`、`redis-addr`、`jwt-signing-key`) |
|
| 运行时 Secret | `<APP_NAME>-runtime`(key:`mysql-root-password`) |
|
||||||
| 数据库名 | `<APP_NAME>` |
|
| 数据库名 | `<APP_NAME>` |
|
||||||
|
|
||||||
`internal/logging` 等框架代码与项目名无关,接入新项目时无需修改。
|
`internal/logging` 等框架代码与项目名无关,接入新项目时无需修改。
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# 后端配置文件模板。
|
# 后端配置文件模板。
|
||||||
# 部署人员将其放置到 K8s 节点的 /var/lib/<APP_NAME>/conf/config.yaml(默认 kra 项目为 /var/lib/kra/conf/config.yaml),
|
# Jenkins 会用 APP_NAME 渲染本文件,并通过 ConfigMap/初始化容器写入共享配置 PVC。
|
||||||
# 并执行 chown -R 1000:1000 /var/lib/<APP_NAME>/conf(容器内运行用户为 UID/GID 1000)。
|
# 容器内运行用户为 UID/GID 1000,初始化向导会把运行时配置写回本文件。
|
||||||
#
|
#
|
||||||
# 说明:
|
# 说明:
|
||||||
# - data.database 保持为空:应用启动后首页会出现"前往初始化",
|
# - data.database 保持为空:应用启动后首页会出现"前往初始化",
|
||||||
# 在初始化页面填写数据库连接后,框架会自动把连接配置写回本文件。
|
# 在初始化页面填写数据库连接后,框架会自动把连接配置写回本文件。
|
||||||
# - data.redis.addr 指向集群内 Redis Service(<APP_NAME>-redis:6379)。
|
# - data.redis.addr 指向集群内 Redis Service(<APP_NAME>-redis:6379)。
|
||||||
# - admin.app.env / admin.app.app_id 按项目实际值修改。
|
# - admin.app.env / admin.app.app_id / admin.jwt.issuer 由 Jenkins 按项目和分支渲染。
|
||||||
server:
|
server:
|
||||||
http:
|
http:
|
||||||
network: tcp
|
network: tcp
|
||||||
|
|
@ -31,7 +31,7 @@ data:
|
||||||
redis:
|
redis:
|
||||||
network: tcp
|
network: tcp
|
||||||
name: default
|
name: default
|
||||||
addr: kra-redis:6379
|
addr: ${APP_NAME}-redis:6379
|
||||||
password: ""
|
password: ""
|
||||||
db: 0
|
db: 0
|
||||||
use_cluster: false
|
use_cluster: false
|
||||||
|
|
@ -43,7 +43,7 @@ admin:
|
||||||
signing_key: ""
|
signing_key: ""
|
||||||
expires_time: 604800s
|
expires_time: 604800s
|
||||||
buffer_time: 86400s
|
buffer_time: 86400s
|
||||||
issuer: kra
|
issuer: ${APP_NAME}
|
||||||
system:
|
system:
|
||||||
use_redis: false
|
use_redis: false
|
||||||
use_multipoint: false
|
use_multipoint: false
|
||||||
|
|
@ -67,5 +67,5 @@ admin:
|
||||||
mode: whitelist
|
mode: whitelist
|
||||||
whitelist: []
|
whitelist: []
|
||||||
app:
|
app:
|
||||||
app_id: kra
|
app_id: ${APP_NAME}
|
||||||
env: production
|
env: ${KRA_ENV}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
# KRA_BACKEND_NODEPORT node port for the backend debug service
|
# KRA_BACKEND_NODEPORT node port for the backend debug service
|
||||||
# KRA_MYSQL_IMAGE mysql server image
|
# KRA_MYSQL_IMAGE mysql server image
|
||||||
# KRA_REDIS_IMAGE redis server image
|
# KRA_REDIS_IMAGE redis server image
|
||||||
|
# KRA_SHARED_STORAGE_CLASS RWX storage class for config and uploads
|
||||||
# KRA_BACKEND_IMAGE backend image built by Jenkins
|
# KRA_BACKEND_IMAGE backend image built by Jenkins
|
||||||
# KRA_FRONTEND_IMAGE frontend image built by Jenkins
|
# KRA_FRONTEND_IMAGE frontend image built by Jenkins
|
||||||
# BIN_NAME backend binary name inside the image
|
# BIN_NAME backend binary name inside the image
|
||||||
|
|
@ -84,6 +85,20 @@ spec:
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ${KRA_APP}-mysql
|
||||||
|
namespace: ${KRA_NAMESPACE}
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||||
|
ports:
|
||||||
|
- name: mysql
|
||||||
|
port: 3306
|
||||||
|
targetPort: mysql
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: ${KRA_APP}-redis
|
name: ${KRA_APP}-redis
|
||||||
namespace: ${KRA_NAMESPACE}
|
namespace: ${KRA_NAMESPACE}
|
||||||
|
|
@ -114,10 +129,25 @@ spec:
|
||||||
app.kubernetes.io/name: ${KRA_APP}-redis
|
app.kubernetes.io/name: ${KRA_APP}-redis
|
||||||
app.kubernetes.io/part-of: ${KRA_APP}
|
app.kubernetes.io/part-of: ${KRA_APP}
|
||||||
spec:
|
spec:
|
||||||
|
initContainers:
|
||||||
|
- name: prepare-redis-storage
|
||||||
|
image: ${KRA_REDIS_IMAGE}
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
runAsGroup: 0
|
||||||
|
command: ["sh", "-c"]
|
||||||
|
args:
|
||||||
|
- chown -R redis:redis /data
|
||||||
|
volumeMounts:
|
||||||
|
- name: redis-data
|
||||||
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: redis
|
- name: redis
|
||||||
image: ${KRA_REDIS_IMAGE}
|
image: ${KRA_REDIS_IMAGE}
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
ports:
|
ports:
|
||||||
- name: redis
|
- name: redis
|
||||||
containerPort: 6379
|
containerPort: 6379
|
||||||
|
|
@ -173,7 +203,33 @@ spec:
|
||||||
app.kubernetes.io/name: ${KRA_APP}-backend
|
app.kubernetes.io/name: ${KRA_APP}-backend
|
||||||
app.kubernetes.io/part-of: ${KRA_APP}
|
app.kubernetes.io/part-of: ${KRA_APP}
|
||||||
spec:
|
spec:
|
||||||
# /data/conf 由部署人员预先放置 config.yaml(见 deploy/k8s/config.example.yaml),
|
securityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
initContainers:
|
||||||
|
- name: initialize-shared-storage
|
||||||
|
image: ${KRA_BACKEND_IMAGE}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
command: ["sh", "-c"]
|
||||||
|
args:
|
||||||
|
- >-
|
||||||
|
mkdir -p /data/conf /app/uploads;
|
||||||
|
if [ ! -f /data/conf/config.yaml ]; then
|
||||||
|
cp /bootstrap/config.yaml /data/conf/config.yaml;
|
||||||
|
fi;
|
||||||
|
chmod u+rwX,g+rwX /data/conf /data/conf/config.yaml /app/uploads
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /data/conf
|
||||||
|
- name: uploads
|
||||||
|
mountPath: /app/uploads
|
||||||
|
- name: config-template
|
||||||
|
mountPath: /bootstrap
|
||||||
|
readOnly: true
|
||||||
|
# /data/conf 由初始化容器从 ConfigMap 初始化到共享 PVC,
|
||||||
# 目录必须可写:初始化向导会把数据库连接写回该文件。
|
# 目录必须可写:初始化向导会把数据库连接写回该文件。
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: harbor-registry
|
- name: harbor-registry
|
||||||
|
|
@ -198,16 +254,12 @@ spec:
|
||||||
env:
|
env:
|
||||||
# 数据库连接不在这里注入:应用启动时未配置数据库会进入 bootstrap 模式,
|
# 数据库连接不在这里注入:应用启动时未配置数据库会进入 bootstrap 模式,
|
||||||
# 首页出现"前往初始化",由 /init/initdb 建表、写入种子数据并持久化连接配置。
|
# 首页出现"前往初始化",由 /init/initdb 建表、写入种子数据并持久化连接配置。
|
||||||
# 只注入 JWT 签名密钥;Redis 地址已在下方 config.yaml 中指定。
|
# JWT 签名密钥由 /data/conf/config.yaml 唯一管理,避免环境变量覆盖管理页面的配置变更;
|
||||||
- name: KRA_ADMIN_JWT_SIGNING_KEY
|
# Redis 地址已在下方 config.yaml 中指定。
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: ${KRA_APP}-runtime
|
|
||||||
key: jwt-signing-key
|
|
||||||
- name: KRA_ADMIN_SYSTEM_USE_REDIS
|
- name: KRA_ADMIN_SYSTEM_USE_REDIS
|
||||||
value: "true"
|
value: "true"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: conf
|
- name: config
|
||||||
mountPath: /data/conf
|
mountPath: /data/conf
|
||||||
- name: uploads
|
- name: uploads
|
||||||
mountPath: /app/uploads
|
mountPath: /app/uploads
|
||||||
|
|
@ -235,12 +287,41 @@ spec:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
memory: 1Gi
|
memory: 1Gi
|
||||||
volumes:
|
volumes:
|
||||||
- name: conf
|
- name: config
|
||||||
hostPath:
|
persistentVolumeClaim:
|
||||||
path: /var/lib/${KRA_APP}/conf
|
claimName: ${KRA_APP}-config
|
||||||
type: DirectoryOrCreate
|
|
||||||
- name: uploads
|
- name: uploads
|
||||||
emptyDir: {}
|
persistentVolumeClaim:
|
||||||
|
claimName: ${KRA_APP}-uploads
|
||||||
|
- name: config-template
|
||||||
|
configMap:
|
||||||
|
name: ${KRA_APP}-config-template
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: ${KRA_APP}-config
|
||||||
|
namespace: ${KRA_NAMESPACE}
|
||||||
|
spec:
|
||||||
|
storageClassName: ${KRA_SHARED_STORAGE_CLASS}
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: ${KRA_APP}-uploads
|
||||||
|
namespace: ${KRA_NAMESPACE}
|
||||||
|
spec:
|
||||||
|
storageClassName: ${KRA_SHARED_STORAGE_CLASS}
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,3 @@ type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
# 替换为随机值;不要把真实密码提交进 Git。
|
# 替换为随机值;不要把真实密码提交进 Git。
|
||||||
mysql-root-password: "CHANGE_ME_TO_A_RANDOM_VALUE"
|
mysql-root-password: "CHANGE_ME_TO_A_RANDOM_VALUE"
|
||||||
database-source: "root:CHANGE_ME_TO_A_RANDOM_VALUE@tcp(<APP_NAME>-mysql:3306)/<APP_NAME>?charset=utf8mb4&parseTime=True&loc=Local"
|
|
||||||
redis-addr: "<APP_NAME>-redis:6379"
|
|
||||||
jwt-signing-key: "CHANGE_ME_TO_A_LONG_RANDOM_VALUE"
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ func databaseDSN(c *config.Database, name string) (string, error) {
|
||||||
}
|
}
|
||||||
query := c.Config
|
query := c.Config
|
||||||
if query == "" {
|
if query == "" {
|
||||||
query = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
query = "timeout=5s&readTimeout=5s&writeTimeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.User, c.Password, net.JoinHostPort(host, port), name, query), nil
|
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.User, c.Password, net.JoinHostPort(host, port), name, query), nil
|
||||||
case "pgsql":
|
case "pgsql":
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kra/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
|
||||||
|
dsn, err := databaseDSN(&config.Database{
|
||||||
|
Driver: "mysql",
|
||||||
|
User: "root",
|
||||||
|
Password: "secret",
|
||||||
|
Host: "mysql",
|
||||||
|
Name: "kra",
|
||||||
|
}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} {
|
||||||
|
if !strings.Contains(dsn, option) {
|
||||||
|
t.Fatalf("DSN %q does not contain %q", dsn, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -137,6 +137,14 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
releaseLock, err := acquireInitializationLock(ctx, candidate, database)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer releaseLock()
|
||||||
|
if persistedDatabaseConfigured(d.runtime) {
|
||||||
|
return errors.New("数据库已初始化,无需重复初始化")
|
||||||
|
}
|
||||||
db := candidate.WithContext(ctx)
|
db := candidate.WithContext(ctx)
|
||||||
if err := migrateAll(db, d.catalog); err != nil {
|
if err := migrateAll(db, d.catalog); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,23 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersistedDatabaseConfiguredReadsSharedRuntimeConfig(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: kra\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runtime := config.NewStore(&config.Config{Admin: &config.Admin{ConfigPath: configPath}})
|
||||||
|
if !persistedDatabaseConfigured(runtime) {
|
||||||
|
t.Fatal("persisted database configuration was not detected")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: \"\"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if persistedDatabaseConfigured(runtime) {
|
||||||
|
t.Fatal("empty persisted database configuration was treated as initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
|
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
configPath := filepath.Join(root, "config.yaml")
|
configPath := filepath.Join(root, "config.yaml")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
configpkg "kra/internal/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// acquireInitializationLock serializes first-install work across backend Pods.
|
||||||
|
// The Kubernetes deployment uses MySQL, whose named lock is connection-scoped
|
||||||
|
// and therefore remains held while the candidate database is migrated and seeded.
|
||||||
|
func acquireInitializationLock(ctx context.Context, db *gorm.DB, database *configpkg.Database) (func(), error) {
|
||||||
|
if normalizedDriver(database.Driver) != "mysql" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open initialization lock connection: %w", err)
|
||||||
|
}
|
||||||
|
conn, err := sqlDB.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reserve initialization lock connection: %w", err)
|
||||||
|
}
|
||||||
|
lockName := initializationLockName(database)
|
||||||
|
var acquired sql.NullInt64
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", lockName, 60).Scan(&acquired); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("acquire database initialization lock: %w", err)
|
||||||
|
}
|
||||||
|
if !acquired.Valid || acquired.Int64 != 1 {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("database initialization lock is busy")
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
_, _ = conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", lockName)
|
||||||
|
cancel()
|
||||||
|
_ = conn.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializationLockName(database *configpkg.Database) string {
|
||||||
|
identity := fmt.Sprintf("%s\x00%s\x00%s\x00%s", database.Driver, database.Host, database.Port, database.Name)
|
||||||
|
sum := sha256.Sum256([]byte(identity))
|
||||||
|
return "kra-init-" + hex.EncodeToString(sum[:])[:48]
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistedDatabaseConfigured(runtime *configpkg.Store) bool {
|
||||||
|
if runtime == nil || runtime.ConfigPath() == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
persisted, err := configpkg.Load(runtime.ConfigPath())
|
||||||
|
if err != nil || persisted == nil || persisted.Data == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return databaseConnectionConfigured(persisted.Data.Database)
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/")
|
prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/")
|
||||||
}
|
}
|
||||||
public := engine.Group(prefix)
|
public := engine.Group(prefix)
|
||||||
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
|
health := func(c *gin.Context) { c.JSON(http.StatusOK, "ok") }
|
||||||
|
engine.GET("/health", health)
|
||||||
|
if prefix != "" {
|
||||||
|
public.GET("/health", health)
|
||||||
|
}
|
||||||
|
|
||||||
private := engine.Group(prefix)
|
private := engine.Group(prefix)
|
||||||
// The reference administration behavior installs operation recording after JWT,
|
// The reference administration behavior installs operation recording after JWT,
|
||||||
|
|
@ -103,11 +107,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ws != nil && ws.Enabled() {
|
if ws != nil && ws.Enabled() {
|
||||||
engine.GET(ws.Path(), handleWebSocket)
|
engine.GET(websocketRoutePath(prefix, ws), handleWebSocket)
|
||||||
}
|
}
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Info("websocket endpoint initialized", "mod", "websocket",
|
logger.Info("websocket endpoint initialized", "mod", "websocket",
|
||||||
"enabled", ws != nil && ws.Enabled(), "path", websocketPath(ws))
|
"enabled", ws != nil && ws.Enabled(), "path", websocketRoutePath(prefix, ws))
|
||||||
}
|
}
|
||||||
env := ""
|
env := ""
|
||||||
if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil {
|
if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil {
|
||||||
|
|
@ -125,10 +129,6 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == websocketRoutePath(prefix, ws) {
|
|
||||||
handleWebSocket(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if staticfiles.Serve(c, runtime) {
|
if staticfiles.Serve(c, runtime) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,17 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHealthEndpointKeepsRootProbePathWithRouterPrefix(t *testing.T) {
|
||||||
|
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||||
|
for _, path := range []string{"/health", "/admin/health"} {
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if response.Code != http.StatusOK || response.Body.String() != `"ok"` {
|
||||||
|
t.Fatalf("health path %s status=%d body=%s", path, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,21 @@ func TestWebSocketRouteSupportsRouterPrefix(t *testing.T) {
|
||||||
t.Fatal("prefixed websocket handshake returned nil connection")
|
t.Fatal("prefixed websocket handshake returned nil connection")
|
||||||
}
|
}
|
||||||
_ = connection.Close()
|
_ = connection.Close()
|
||||||
|
for _, route := range engine.Routes() {
|
||||||
|
if route.Method == http.MethodGet && route.Path == "/ws" {
|
||||||
|
t.Fatal("unprefixed websocket route should not be registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, route := range engine.Routes() {
|
||||||
|
if route.Method == http.MethodGet && route.Path == "/admin/ws" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("prefixed websocket route was not registered")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ platformws.Hub = (*websocketintegration.Server)(nil)
|
var _ platformws.Hub = (*websocketintegration.Server)(nil)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
@ -10,16 +15,36 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api {
|
location = /api {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
rewrite ^/api/(.*)$ /$1 break; #重写
|
|
||||||
proxy_pass http://${KRA_APP}-backend:8000; # Kubernetes Service
|
proxy_pass http://${KRA_APP}-backend:8000; # Kubernetes Service
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/swagger/index.html {
|
location ^~ /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://${KRA_APP}-backend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/swagger/index.html {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_pass http://${KRA_APP}-backend:8000/swagger/index.html;
|
proxy_pass http://${KRA_APP}-backend:8000/swagger/index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
const isRightSize = file.size / 1024 < props.fileSize
|
const isRightSize = file.size / 1024 < props.fileSize
|
||||||
if (!isRightSize) {
|
if (!isRightSize) {
|
||||||
// 压缩
|
// 压缩
|
||||||
const compress = new ImageCompress(file, props.fileSize, props.maxWH)
|
const compress = new ImageCompress(file, props.maxWH)
|
||||||
return compress.compress()
|
return compress.compress()
|
||||||
}
|
}
|
||||||
return isRightSize
|
return isRightSize
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
export default class ImageCompress {
|
export default class ImageCompress {
|
||||||
constructor(file, fileSize, maxWH = 1920) {
|
constructor(file, maxWH = 1920) {
|
||||||
this.file = file
|
this.file = file
|
||||||
this.fileSize = fileSize
|
|
||||||
this.maxWH = maxWH // 最大长宽
|
this.maxWH = maxWH // 最大长宽
|
||||||
}
|
}
|
||||||
|
|
||||||
compress() {
|
compress() {
|
||||||
// 压缩
|
// 压缩
|
||||||
const fileType = this.file.type
|
const fileType = this.file.type
|
||||||
const fileSize = this.file.size / 1024
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.readAsDataURL(this.file)
|
reader.readAsDataURL(this.file)
|
||||||
|
|
@ -28,12 +26,6 @@ export default class ImageCompress {
|
||||||
|
|
||||||
const newImgData = canvas.toDataURL(fileType, 0.9)
|
const newImgData = canvas.toDataURL(fileType, 0.9)
|
||||||
|
|
||||||
// 压缩宽高后的图像大小
|
|
||||||
const newImgSize = this.fileSizeKB(newImgData)
|
|
||||||
|
|
||||||
if (newImgSize > this.fileSize) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = this.dataURLtoBlob(newImgData, fileType)
|
const blob = this.dataURLtoBlob(newImgData, fileType)
|
||||||
const nfile = new File([blob], this.file.name)
|
const nfile = new File([blob], this.file.name)
|
||||||
resolve(nfile)
|
resolve(nfile)
|
||||||
|
|
@ -66,12 +58,6 @@ export default class ImageCompress {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileSizeKB(dataURL) {
|
|
||||||
let sizeKB = 0
|
|
||||||
sizeKB = Math.round((dataURL.split(',')[1].length * 3) / 4 / 1024)
|
|
||||||
return sizeKB
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转为Blob
|
* 转为Blob
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ router.isReady().then(() => {
|
||||||
let query = router.currentRoute.value.query
|
let query = router.currentRoute.value.query
|
||||||
classId.value = query.id
|
classId.value = query.id
|
||||||
token.value = query.token
|
token.value = query.token
|
||||||
}).catch((err) => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadRef = ref(null)
|
const uploadRef = ref(null)
|
||||||
|
|
|
||||||
|
|
@ -326,17 +326,28 @@ const selected = computed(
|
||||||
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
||||||
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
||||||
const isWebSocket = (item) => integrationKey(item) === 'websocket/melody'
|
const isWebSocket = (item) => integrationKey(item) === 'websocket/melody'
|
||||||
|
const websocketApiBase = () => String(import.meta.env.VITE_BASE_API || '').trim()
|
||||||
|
const websocketUrlOverride = () => String(import.meta.env.VITE_WEBSOCKET_URL || '').trim()
|
||||||
|
const websocketUrlPath = (value) => {
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.origin).pathname.replace(/\/+$/, '')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
const websocketBackendIsExplicit = () =>
|
const websocketBackendIsExplicit = () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
String(import.meta.env.VITE_WEBSOCKET_URL || '').trim() ||
|
websocketUrlOverride() ||
|
||||||
String(import.meta.env.VITE_SERVER_PORT || '').trim()
|
String(import.meta.env.VITE_SERVER_PORT || '').trim() ||
|
||||||
|
/^https?:\/\//i.test(websocketApiBase())
|
||||||
)
|
)
|
||||||
const websocketOrigin = () => {
|
const websocketOrigin = () => {
|
||||||
if (websocketBackendIsExplicit()) {
|
if (websocketBackendIsExplicit()) {
|
||||||
const configuredBase = String(
|
const configuredBase = String(
|
||||||
import.meta.env.VITE_WEBSOCKET_URL ||
|
websocketUrlOverride() ||
|
||||||
import.meta.env.VITE_BASE_PATH ||
|
(/^https?:\/\//i.test(websocketApiBase())
|
||||||
window.location.origin
|
? websocketApiBase()
|
||||||
|
: import.meta.env.VITE_BASE_PATH || window.location.origin)
|
||||||
)
|
)
|
||||||
const backend = new URL(configuredBase, window.location.origin)
|
const backend = new URL(configuredBase, window.location.origin)
|
||||||
if (
|
if (
|
||||||
|
|
@ -355,9 +366,14 @@ const websocketOrigin = () => {
|
||||||
return `${protocol}//${window.location.host}`
|
return `${protocol}//${window.location.host}`
|
||||||
}
|
}
|
||||||
const websocketPathPrefix = () => {
|
const websocketPathPrefix = () => {
|
||||||
if (websocketBackendIsExplicit()) return ''
|
const override = websocketUrlOverride()
|
||||||
const base = String(import.meta.env.VITE_BASE_API || '').trim()
|
if (override) return websocketUrlPath(override)
|
||||||
if (!base || /^https?:\/\//i.test(base)) return ''
|
const base = websocketApiBase()
|
||||||
|
if (!base) return ''
|
||||||
|
if (/^https?:\/\//i.test(base)) {
|
||||||
|
return websocketUrlPath(base)
|
||||||
|
}
|
||||||
|
if (String(import.meta.env.VITE_SERVER_PORT || '').trim()) return ''
|
||||||
return `/${base.replace(/^\/+|\/+$/g, '')}`
|
return `/${base.replace(/^\/+|\/+$/g, '')}`
|
||||||
}
|
}
|
||||||
const connectionInfo = computed(() => {
|
const connectionInfo = computed(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue