This commit is contained in:
Yvan 2026-09-04 10:51:45 +08:00
parent d94e2ed225
commit af1e194396
17 changed files with 1015 additions and 61 deletions

View File

@ -6,12 +6,11 @@
.codegraph
gva
web/node_modules
web/dist
node_modules
dist
bin
coverage
*.log
*.tmp
configs/*.local.yaml
uploads
docs
deploy

View File

@ -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"]

198
deploy/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,198 @@
# KRA 部署与配置文档
本文档完整描述 kra 框架项目从代码推送到双集群部署的全链路各组件如何串联、GitLab / Jenkins / Harbor 三块各自需要配置什么、首次部署步骤与排障方法。
> 快速接入新项目只看 [README.md](README.md);本文是完整运维文档。
> 文中账号密码遵循内网环境约定,不写入仓库,凭据统一存放在 Jenkins Credentials 中。
## 1. 环境拓扑
| 节点 | 角色 | 关键端口 / 路径 |
|------|------|----------------|
| 192.168.200.128 | Rancher + RKE2 集群1dev | 前端 30080 / 后端 30081命名空间 `<APP_NAME>-dev` |
| 192.168.200.129 | RKE2 集群2prod | 前端 30080 / 后端 30081命名空间 `<APP_NAME>-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位}
▼ Deploykubectl + 对应集群 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/maintag 仅作 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 用户名/密码内嵌进 URLPOST 到 `http://<user>:<pass>@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 用自签 HTTPSdocker/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: "<robot 密码>"
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 无 buildxDockerfile 不能用 BuildKit 语法)。
- 宿主机依赖缓存:`/var/lib/kra-ci-cache/{go-mod,go-build,pnpm-store,corepack}` 挂进构建容器,避免重复下载。
- 容器内需有 `kubectl`、`envsubst`、`git`。
### 5.2 插件清单(关键)
| 插件 | 作用 |
|------|------|
| gitlab-plugin | `/project/<job>` 接收端点、触发构建、注入 `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/<job>` 端点依赖该触发器存在)。
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/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/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后端日志是否连上 `<APP_NAME>-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 轮换会使登录态失效)。

160
deploy/Jenkinsfile vendored Normal file
View File

@ -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
'''
}
}
}
}
}

107
deploy/README.md Normal file
View File

@ -0,0 +1,107 @@
# KRA CI/CD 部署
deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的新项目可以整体复制后只改一个参数块。
> 完整的环境配置、凭据来源与排障说明见 [DEPLOYMENT.md](DEPLOYMENT.md)。
分支和集群的对应关系:
- `dev` -> Rancher 集群 `128` -> 命名空间 `<APP_NAME>-dev`
- `main` -> Rancher 集群 `129` -> 命名空间 `<APP_NAME>-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/<job>` 端点gitlab-plugin 注入 `gitlabBranch` 环境变量
- `deploy/Jenkinsfile``Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,`Checkout branch` 阶段动态检出实际推送的分支
- 手动构建兜底:`kra` job 手动触发时默认部署 main不识别 tagtag 仅作版本标记,不触发部署)
每个环境包含 3 个后端 Pod、3 个前端 Pod、1 个 MySQL Pod 和 1 个 Redis Pod。前端通过 `<APP_NAME>-backend` ClusterIP Service 访问后端Kubernetes Service 在 Pod 之间做负载均衡;前端通过 NodePort `30080` 暴露,后端调试入口通过 NodePort `30081` 暴露。
MySQL 和 Redis 使用节点本地目录 `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/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/<APP_NAME>/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 | `<APP_NAME>-backend`、`<APP_NAME>-frontend`、`<APP_NAME>-redis` |
| StatefulSet/Service | `<APP_NAME>-mysql` |
| ConfigMap | `<APP_NAME>-config` |
| 运行时 Secret | `<APP_NAME>-runtime`key`mysql-root-password`、`database-source`、`redis-addr`、`jwt-signing-key` |
| 数据库名 | `<APP_NAME>` |
`internal/logging` 等框架代码与项目名无关,接入新项目时无需修改。

View File

@ -0,0 +1,71 @@
# 后端配置文件模板。
# 部署人员将其放置到 K8s 节点的 /var/lib/<APP_NAME>/conf/config.yaml默认 kra 项目为 /var/lib/kra/conf/config.yaml
# 并执行 chown -R 1000:1000 /var/lib/<APP_NAME>/conf容器内运行用户为 UID/GID 1000
#
# 说明:
# - data.database 保持为空:应用启动后首页会出现"前往初始化"
# 在初始化页面填写数据库连接后,框架会自动把连接配置写回本文件。
# - data.redis.addr 指向集群内 Redis Service<APP_NAME>-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

352
deploy/k8s/kra.yaml Normal file
View File

@ -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}

View File

@ -0,0 +1,19 @@
# 运行时 Secret 示例。
# 真实环境由 Jenkinsfile 的 Deploy 阶段自动创建kubectl create secret generic ${APP_NAME}-runtime
# 本文件仅用于手工初始化环境的场景。尖括号为模板变量,使用前先替换成实际值。
#
# 模板变量:
# APP_NAME 应用名Jenkinsfile 中 APP_NAMEkra 项目即为 kra
# KRA_NAMESPACE 目标命名空间(如 kra-dev / kra-prod
apiVersion: v1
kind: Secret
metadata:
name: <APP_NAME>-runtime
namespace: <KRA_NAMESPACE>
type: Opaque
stringData:
# 替换为随机值;不要把真实密码提交进 Git。
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"

View File

@ -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"

View File

@ -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 {

View File

@ -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)

View File

@ -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' 渲染为 <APP_NAME>-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;
}
}
}

View File

@ -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 指定后端地址。

2
web/.gitignore vendored
View File

@ -4,3 +4,5 @@ package-lock.json
yarn.lock
bun.lockb
config.yaml
# 渲染产物Jenkins 从 .docker-compose/nginx/conf.d/my.conf 生成
/nginx.k8s.conf

View File

@ -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

View File

@ -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

View File

@ -170,7 +170,7 @@
<el-button
v-if="isWebSocket(selected)"
:icon="DocumentCopy"
:disabled="isBusy(selected)"
:disabled="isBusy(selected) || !canTestConnectionInfo(selected)"
@click="openConnectionInfo"
>
连接信息
@ -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 {