jenkins
This commit is contained in:
parent
43db5e5450
commit
372e119e7c
|
|
@ -114,7 +114,7 @@ systemctl restart rke2-server
|
||||||
| Git | SCM 检出 |
|
| Git | SCM 检出 |
|
||||||
| Credentials / Credentials Binding | 凭据管理与注入 |
|
| Credentials / Credentials Binding | 凭据管理与注入 |
|
||||||
|
|
||||||
### 5.3 凭据(5 个,ID 与 `deploy/Jenkinsfile` 中变量对应)
|
### 5.3 凭据(6 个,ID 与 `deploy/Jenkinsfile` 中变量对应)
|
||||||
|
|
||||||
| 凭据 ID | 类型 | 来源 / 获取方式 |
|
| 凭据 ID | 类型 | 来源 / 获取方式 |
|
||||||
|---------|------|----------------|
|
|---------|------|----------------|
|
||||||
|
|
@ -123,6 +123,7 @@ 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 签名密钥;必须非空且为单行文本 |
|
||||||
|
|
||||||
kubeconfig 添加路径:系统管理 → 凭据 → Add Credentials → 类型 Secret file → 上传文件 → 填 ID。
|
kubeconfig 添加路径:系统管理 → 凭据 → Add Credentials → 类型 Secret file → 上传文件 → 填 ID。
|
||||||
|
|
||||||
|
|
@ -149,25 +150,25 @@ gitlabBranch(集成/推送触发)
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| Harbor 拉镜像信任 | 见 §4 第 3 步 `registries.yaml` |
|
| Harbor 拉镜像信任 | 见 §4 第 3 步 `registries.yaml` |
|
||||||
| 应用配置文件 | Jenkins 将 [k8s/config.example.yaml](k8s/config.example.yaml) 渲染为 `<APP_NAME>-config-template` ConfigMap,初始化容器复制到共享配置 PVC `/data/conf` |
|
| 应用配置文件 | Jenkins 将 [k8s/config.example.yaml](k8s/config.example.yaml) 渲染为 `<APP_NAME>-config-template` Secret,初始化容器首次复制到 `/var/lib/<APP_NAME>/conf/config.yaml` |
|
||||||
| 配置与上传存储 | `<APP_NAME>-config`(1Gi)和 `<APP_NAME>-uploads`(20Gi),均要求存储类支持 `ReadWriteMany` |
|
| 配置与上传存储 | `/var/lib/<APP_NAME>/conf`、`/var/lib/<APP_NAME>/uploads`;required podAffinity 保证后端与 MySQL 同节点,仅适用于当前单节点方案 |
|
||||||
| 数据持久化 | `/var/lib/<APP_NAME>/mysql`、`/var/lib/<APP_NAME>/redis`(节点本地目录,当前单节点集群适用;Redis initContainer 需要允许 root 修正 hostPath 权限) |
|
| 数据持久化 | `/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. 向导最后一步把数据库连接**写回**共享 PVC 中的 `/data/conf/config.yaml`,配置不会随 Pod 消失。
|
3. 向导最后一步把数据库连接**写回**节点目录中的 `/data/conf/config.yaml`,配置不会随 Pod 重建消失。
|
||||||
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`。后续手工改配置也需要执行滚动重启。
|
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 不会覆盖已有配置,避免覆盖初始化写入的数据库连接。
|
> `<APP_NAME>-config-template` Secret 只负责首次创建配置文件。初始化后,运行时配置以 hostPath 中的 `/data/conf/config.yaml` 为准;更新 Secret 或 Jenkins JWT 凭据不会覆盖已有配置,避免覆盖初始化写入的数据库连接。JWT 后续轮换应通过系统设置完成并滚动重启后端。
|
||||||
|
|
||||||
## 7. 首次部署清单(按顺序)
|
## 7. 首次部署清单(按顺序)
|
||||||
|
|
||||||
1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。
|
1. Harbor:建 `library` 项目 + robot 账号;证书分发到 131 宿主机与 128/129 节点。
|
||||||
2. RKE2 集群:确认 `KRA_SHARED_STORAGE_CLASS` 对应的存储类支持 `ReadWriteMany`。
|
2. RKE2 集群:确认 `/var/lib/<APP_NAME>/` 所在磁盘容量足够,并允许清单中的 root initContainer 修正目录权限。
|
||||||
3. Jenkins:安装插件;添加 §5.3 所列 5 个凭据;按 §5.4 建 `kra` job。
|
3. Jenkins:安装插件;添加 §5.3 所列 6 个凭据;按 §5.4 建 `kra` job。
|
||||||
4. GitLab:配置 Jenkins 集成(§3)。
|
4. GitLab:配置 Jenkins 集成(§3)。
|
||||||
5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。
|
5. 推送任意分支触发构建,观察首次构建(有依赖缓存预热,耗时会比后续长)。
|
||||||
6. 初始化向导(§6),完成后浏览器验证。
|
6. 初始化向导(§6),完成后浏览器验证。
|
||||||
|
|
@ -189,7 +190,7 @@ 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 / 初始化异常 | 配置 PVC 是否已绑定、ConfigMap 是否存在、后端日志是否连上 `<APP_NAME>-mysql:3306` |
|
| 后端 503 / 初始化异常 | `/var/lib/<APP_NAME>/conf/config.yaml` 权限、配置模板 Secret、后端日志是否连上 `<APP_NAME>-mysql:3306` |
|
||||||
| 登录态全部失效 | 是否在系统设置中更换了 JWT 签名密钥(换值即失效所有 token) |
|
| 登录态全部失效 | 是否在系统设置中更换了 JWT 签名密钥(换值即失效所有 token) |
|
||||||
| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 |
|
| 构建慢 | 缓存目录是否挂载成功;镜像源(goproxy/npmmirror/daocloud)偶发卡顿需重试 |
|
||||||
|
|
||||||
|
|
@ -197,4 +198,4 @@ curl -I http://192.168.200.129:30080/ # 同上验证 129
|
||||||
|
|
||||||
- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。
|
- 所有账号密码只存 Jenkins Credentials / 各系统自身,不写入仓库;`configs/config.yaml` 不要提交真实密钥。
|
||||||
- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。
|
- 轮换 Jenkins 密码:GitLab 集成表单改一次即可。
|
||||||
- 轮换 Harbor robot:更新 Jenkins 凭据后重新部署即可。轮换 MySQL 密码必须先在 MySQL 中执行 `ALTER USER` 并确认连接正常,再更新 Jenkins 凭据并重启相关 Pod;仅更新 Secret 不会修改已初始化 MySQL 数据目录中的 root 密码。JWT 签名密钥通过系统设置修改并保存到共享配置 PVC,随后滚动重启后端;换值会使现有登录态失效。
|
- 轮换 Harbor robot:更新 Jenkins 凭据后重新部署即可。轮换 MySQL 密码必须先在 MySQL 中执行 `ALTER USER` 并确认连接正常,再更新 Jenkins 凭据并重启相关 Pod;仅更新 Secret 不会修改已初始化 MySQL 数据目录中的 root 密码。JWT 签名密钥通过系统设置修改并保存到 hostPath 配置文件,随后滚动重启后端;换值会使现有登录态失效。
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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'
|
||||||
|
|
@ -30,9 +29,10 @@ pipeline {
|
||||||
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 $KRA_SHARED_STORAGE_CLASS $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 $BIN_NAME'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
|
|
@ -75,13 +75,18 @@ pipeline {
|
||||||
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
// job 的 SCM branch spec 固定(如 */main),集成触发时按实际推送分支重新检出,
|
||||||
// 保证单 job 同时服务 dev/main 两个环境。
|
// 保证单 job 同时服务 dev/main 两个环境。
|
||||||
script {
|
script {
|
||||||
env.KRA_TRIGGER_COMMIT = env.gitlabAfter?.trim()
|
// gitlabAfter 为 push 事件的目标 commit;手动触发时为空。
|
||||||
if (env.KRA_TRIGGER_COMMIT && !(env.KRA_TRIGGER_COMMIT ==~ /[0-9a-fA-F]{40}/)) {
|
// 注意:不能把 null 赋给 env.*(会被转成字符串 "null"),这里用局部变量。
|
||||||
error("Invalid GitLab commit SHA: ${env.KRA_TRIGGER_COMMIT}")
|
def triggerCommit = env.gitlabAfter?.trim()
|
||||||
|
if (triggerCommit && !(triggerCommit ==~ /[0-9a-fA-F]{40}/)) {
|
||||||
|
error("Invalid GitLab commit SHA: ${triggerCommit}")
|
||||||
}
|
}
|
||||||
if (env.KRA_TRIGGER_COMMIT && env.KRA_TRIGGER_COMMIT.replace('0', '').isEmpty()) {
|
if (triggerCommit && triggerCommit.replace('0', '').isEmpty()) {
|
||||||
error('GitLab event points to a deleted branch; refusing to deploy')
|
error('GitLab event points to a deleted branch; refusing to deploy')
|
||||||
}
|
}
|
||||||
|
if (triggerCommit) {
|
||||||
|
env.KRA_TRIGGER_COMMIT = triggerCommit
|
||||||
|
}
|
||||||
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']]])
|
||||||
|
|
@ -152,6 +157,7 @@ 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
|
||||||
|
|
@ -159,7 +165,6 @@ pipeline {
|
||||||
umask 077
|
umask 077
|
||||||
TMP_DIR=$(mktemp -d)
|
TMP_DIR=$(mktemp -d)
|
||||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
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 -
|
||||||
DOCKER_CONFIG_DIR="$TMP_DIR/docker"
|
DOCKER_CONFIG_DIR="$TMP_DIR/docker"
|
||||||
mkdir -p "$DOCKER_CONFIG_DIR"
|
mkdir -p "$DOCKER_CONFIG_DIR"
|
||||||
|
|
@ -169,27 +174,43 @@ pipeline {
|
||||||
--from-file=.dockerconfigjson="$DOCKER_CONFIG_DIR/config.json" \\
|
--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
|
docker --config "$DOCKER_CONFIG_DIR" logout "$REGISTRY" >/dev/null 2>&1 || true
|
||||||
|
test -n "$KRA_MYSQL_ROOT_PASSWORD" || { echo 'MySQL root password credential is empty' >&2; exit 1; }
|
||||||
printf '%s' "$KRA_MYSQL_ROOT_PASSWORD" > "$TMP_DIR/mysql-root-password"
|
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-file=mysql-root-password="$TMP_DIR/mysql-root-password" \\
|
--from-file=mysql-root-password="$TMP_DIR/mysql-root-password" \\
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
test -n "$KRA_JWT_SIGNING_KEY" || { echo 'JWT signing key credential is empty' >&2; exit 1; }
|
||||||
|
if [ "$(printf '%s' "$KRA_JWT_SIGNING_KEY" | wc -l)" -ne 0 ]; then
|
||||||
|
echo 'JWT signing key credential must be a single line' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$KRA_JWT_SIGNING_KEY" in
|
||||||
|
*"'"*) echo "JWT signing key credential must not contain a single quote" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
CONFIG_TEMPLATE="$TMP_DIR/config.yaml"
|
CONFIG_TEMPLATE="$TMP_DIR/config.yaml"
|
||||||
envsubst '$APP_NAME $KRA_ENV' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE"
|
envsubst '$APP_NAME $KRA_ENV $KRA_JWT_SIGNING_KEY' < deploy/k8s/config.example.yaml > "$CONFIG_TEMPLATE"
|
||||||
kubectl -n "$KRA_NAMESPACE" create configmap "${APP_NAME}-config-template" \\
|
kubectl -n "$KRA_NAMESPACE" create secret generic "${APP_NAME}-config-template" \\
|
||||||
--from-file=config.yaml="$CONFIG_TEMPLATE" \\
|
--from-file=config.yaml="$CONFIG_TEMPLATE" \\
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
kubectl -n "$KRA_NAMESPACE" delete configmap "${APP_NAME}-config-template" --ignore-not-found
|
||||||
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"
|
||||||
RENDERED_MANIFEST="$TMP_DIR/manifest.yaml"
|
RENDERED_MANIFEST="$TMP_DIR/manifest.yaml"
|
||||||
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST"
|
envsubst "$K8S_RENDER_VARS" < deploy/k8s/kra.yaml > "$RENDERED_MANIFEST"
|
||||||
|
MYSQL_SERVICE_IP=$(kubectl -n "$KRA_NAMESPACE" get service "${APP_NAME}-mysql" -o jsonpath='{.spec.clusterIP}' 2>/dev/null || true)
|
||||||
|
if [ -n "$MYSQL_SERVICE_IP" ] && [ "$MYSQL_SERVICE_IP" != 'None' ]; then
|
||||||
|
echo "${APP_NAME}-mysql already exists as a ClusterIP Service; delete it during a maintenance window before switching to headless mode" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
kubectl apply --dry-run=server -f "$RENDERED_MANIFEST"
|
kubectl apply --dry-run=server -f "$RENDERED_MANIFEST"
|
||||||
kubectl apply -f "$RENDERED_MANIFEST"
|
kubectl apply -f "$RENDERED_MANIFEST"
|
||||||
RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}')
|
RUNTIME_SECRET_VERSION=$(kubectl -n "$KRA_NAMESPACE" get secret "${APP_NAME}-runtime" -o jsonpath='{.metadata.resourceVersion}')
|
||||||
|
PATCH_FILE="$TMP_DIR/mysql-patch.json"
|
||||||
|
printf '{"spec":{"template":{"metadata":{"annotations":{"kra.dev/runtime-secret-version":"%s"}}}}}\n' \\
|
||||||
|
"$RUNTIME_SECRET_VERSION" > "$PATCH_FILE"
|
||||||
kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\
|
kubectl -n "$KRA_NAMESPACE" patch statefulset "${APP_NAME}-mysql" --type=merge \\
|
||||||
-p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"kra.dev/runtime-secret-version\":\"$RUNTIME_SECRET_VERSION\"}}}}}"
|
--patch-file="$PATCH_FILE"
|
||||||
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 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}-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
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的
|
||||||
- `deploy/Jenkinsfile` 的 `Select environment` 阶段按 `gitlabBranch` 路由:单 job `kra` 同时服务 dev/main 两个环境,构建阶段优先按 push payload 的最终提交 SHA 检出
|
- `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` 暴露。后端配置和上传文件使用支持 `ReadWriteMany` 的共享 PVC。
|
每个环境包含 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` 持久化。Redis 部署会先用一次性 root initContainer 修正 hostPath 权限,主进程仍以镜像内的非 root 用户运行。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,不提供数据库跨节点高可用。
|
MySQL、Redis、后端配置和上传文件都使用 `/var/lib/<APP_NAME>/` 下的节点本地目录。Redis 和后端会先用 root initContainer 修正 hostPath 根目录权限;后端通过 required podAffinity 固定到 MySQL 所在节点。当前两个 Rancher 集群都只有一个节点,这种方式适合当前环境,但不是真正的 RWX,也不提供跨节点高可用;扩展到多节点前必须迁移到 NFS、CephFS 等共享存储。
|
||||||
|
|
||||||
## Jenkins 前置条件
|
## Jenkins 前置条件
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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。
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-r
|
||||||
|
|
||||||
### 前置:放置配置文件
|
### 前置:放置配置文件
|
||||||
|
|
||||||
后端启动读取 `/data/conf/config.yaml`。流水线会把 `deploy/k8s/config.example.yaml` 渲染成 ConfigMap,并由初始化容器复制到共享配置 PVC;容器内运行用户为 UID/GID 1000,初始化向导会把数据库连接和 JWT 密钥写回该文件。
|
后端启动读取 `/data/conf/config.yaml`。流水线会把 `deploy/k8s/config.example.yaml` 渲染成 Kubernetes Secret,并由初始化容器首次复制到节点目录 `/var/lib/<APP_NAME>/conf`;容器内运行用户为 UID/GID 1000,初始化向导会把数据库连接和 JWT 密钥写回该文件。
|
||||||
|
|
||||||
### 初始化
|
### 初始化
|
||||||
|
|
||||||
|
|
@ -56,7 +57,7 @@ Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-r
|
||||||
| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 |
|
| 密码 | Jenkins 凭据 `kra-mysql-root-password` 的值 |
|
||||||
| 库名 | `kra`(与应用名一致) |
|
| 库名 | `kra`(与应用名一致) |
|
||||||
|
|
||||||
提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回共享 PVC 中的 `config.yaml`,Pod 重建后配置不丢失。初始化期间建议暂时保持后端为 1 个副本,完成后再扩容到 `KRA_REPLICAS`,避免多个 Pod 同时执行首次初始化。初始化完成后请执行一次后端滚动重启,让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。
|
提交后框架自动建表、写入种子数据(管理员账号、菜单、casbin)并把连接配置写回节点目录中的 `config.yaml`,Pod 重建后配置不丢失。初始化期间建议暂时保持后端为 1 个副本,完成后再扩容到 `KRA_REPLICAS`,避免多个 Pod 同时执行首次初始化。初始化完成后请执行一次后端滚动重启,让所有副本加载新配置。**不要**通过环境变量注入数据库 DSN:那会让应用跳过初始化向导,导致数据库有表无种子数据、无法登录。
|
||||||
|
|
||||||
## 基于 kra 模板接入新项目
|
## 基于 kra 模板接入新项目
|
||||||
|
|
||||||
|
|
@ -64,14 +65,13 @@ Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-r
|
||||||
|
|
||||||
| 变量 | 说明 | 默认值(kra 项目) |
|
| 变量 | 说明 | 默认值(kra 项目) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `APP_NAME` | 应用名,派生所有 K8s 资源名、数据库名、存储声明、镜像后缀 | `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` |
|
||||||
| `KRA_REPLICAS` | 前后端副本数 | `3` |
|
| `KRA_REPLICAS` | 前后端副本数 | `3` |
|
||||||
| `KRA_FRONTEND_NODEPORT` / `KRA_BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` |
|
| `KRA_FRONTEND_NODEPORT` / `KRA_BACKEND_NODEPORT` | NodePort 端口,同一集群多个项目必须错开 | `30080` / `30081` |
|
||||||
| `KRA_MYSQL_IMAGE` / `KRA_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` |
|
||||||
| `CRED_*` | Jenkins 凭据 ID,沿用现有凭据可不改 | 见文件 |
|
| `CRED_*` | Jenkins 凭据 ID,沿用现有凭据可不改 | 见文件 |
|
||||||
|
|
@ -91,7 +91,7 @@ Harbor 项目和 Kubernetes 两个环境都会由流水线自动创建 `harbor-r
|
||||||
|---|---|
|
|---|---|
|
||||||
| 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-template` |
|
| 配置模板 Secret | `<APP_NAME>-config-template` |
|
||||||
| 运行时 Secret | `<APP_NAME>-runtime`(key:`mysql-root-password`) |
|
| 运行时 Secret | `<APP_NAME>-runtime`(key:`mysql-root-password`) |
|
||||||
| 数据库名 | `<APP_NAME>` |
|
| 数据库名 | `<APP_NAME>` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# 后端配置文件模板。
|
# 后端配置文件模板。
|
||||||
# Jenkins 会用 APP_NAME 渲染本文件,并通过 ConfigMap/初始化容器写入共享配置 PVC。
|
# Jenkins 会渲染本文件并通过 Secret/初始化容器写入节点共享目录。
|
||||||
# 容器内运行用户为 UID/GID 1000,初始化向导会把运行时配置写回本文件。
|
# 容器内运行用户为 UID/GID 1000,初始化向导会把运行时配置写回本文件。
|
||||||
#
|
#
|
||||||
# 说明:
|
# 说明:
|
||||||
|
|
@ -40,7 +40,7 @@ data:
|
||||||
admin:
|
admin:
|
||||||
router_prefix: ""
|
router_prefix: ""
|
||||||
jwt:
|
jwt:
|
||||||
signing_key: ""
|
signing_key: '${KRA_JWT_SIGNING_KEY}'
|
||||||
expires_time: 604800s
|
expires_time: 604800s
|
||||||
buffer_time: 86400s
|
buffer_time: 86400s
|
||||||
issuer: ${APP_NAME}
|
issuer: ${APP_NAME}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
# 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
|
||||||
|
|
@ -39,6 +38,7 @@ spec:
|
||||||
app.kubernetes.io/name: ${KRA_APP}-mysql
|
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||||
app.kubernetes.io/part-of: ${KRA_APP}
|
app.kubernetes.io/part-of: ${KRA_APP}
|
||||||
spec:
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
containers:
|
containers:
|
||||||
- name: mysql
|
- name: mysql
|
||||||
image: ${KRA_MYSQL_IMAGE}
|
image: ${KRA_MYSQL_IMAGE}
|
||||||
|
|
@ -129,6 +129,7 @@ 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:
|
||||||
|
automountServiceAccountToken: false
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: prepare-redis-storage
|
- name: prepare-redis-storage
|
||||||
image: ${KRA_REDIS_IMAGE}
|
image: ${KRA_REDIS_IMAGE}
|
||||||
|
|
@ -142,12 +143,17 @@ spec:
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: redis-data
|
- name: redis-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- topologyKey: kubernetes.io/hostname
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||||
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
|
||||||
|
|
@ -206,13 +212,14 @@ spec:
|
||||||
securityContext:
|
securityContext:
|
||||||
fsGroup: 1000
|
fsGroup: 1000
|
||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
automountServiceAccountToken: false
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: initialize-shared-storage
|
- name: initialize-shared-storage
|
||||||
image: ${KRA_BACKEND_IMAGE}
|
image: ${KRA_BACKEND_IMAGE}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsUser: 1000
|
runAsUser: 0
|
||||||
runAsGroup: 1000
|
runAsGroup: 0
|
||||||
command: ["sh", "-c"]
|
command: ["sh", "-c"]
|
||||||
args:
|
args:
|
||||||
- >-
|
- >-
|
||||||
|
|
@ -220,6 +227,7 @@ spec:
|
||||||
if [ ! -f /data/conf/config.yaml ]; then
|
if [ ! -f /data/conf/config.yaml ]; then
|
||||||
cp /bootstrap/config.yaml /data/conf/config.yaml;
|
cp /bootstrap/config.yaml /data/conf/config.yaml;
|
||||||
fi;
|
fi;
|
||||||
|
chown 1000:1000 /data/conf /data/conf/config.yaml /app/uploads;
|
||||||
chmod u+rwX,g+rwX /data/conf /data/conf/config.yaml /app/uploads
|
chmod u+rwX,g+rwX /data/conf /data/conf/config.yaml /app/uploads
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
|
|
@ -229,11 +237,17 @@ spec:
|
||||||
- name: config-template
|
- name: config-template
|
||||||
mountPath: /bootstrap
|
mountPath: /bootstrap
|
||||||
readOnly: true
|
readOnly: true
|
||||||
# /data/conf 由初始化容器从 ConfigMap 初始化到共享 PVC,
|
# /data/conf 由初始化容器从 Secret 初始化到节点共享目录,
|
||||||
# 目录必须可写:初始化向导会把数据库连接写回该文件。
|
# 目录必须可写:初始化向导会把数据库连接写回该文件。
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: harbor-registry
|
- name: harbor-registry
|
||||||
affinity:
|
affinity:
|
||||||
|
podAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- topologyKey: kubernetes.io/hostname
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: ${KRA_APP}-mysql
|
||||||
podAntiAffinity:
|
podAntiAffinity:
|
||||||
preferredDuringSchedulingIgnoredDuringExecution:
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
- weight: 100
|
- weight: 100
|
||||||
|
|
@ -287,43 +301,22 @@ spec:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
memory: 1Gi
|
memory: 1Gi
|
||||||
volumes:
|
volumes:
|
||||||
|
# 当前是单节点集群;required podAffinity 保证所有后端副本落在
|
||||||
|
# MySQL 所在节点,从而共享同一组 hostPath 目录。它不是真正的 RWX。
|
||||||
|
# 扩展到多节点前,必须换成 PVC + 真正的共享存储(如 NFS/CephFS)。
|
||||||
- name: config
|
- name: config
|
||||||
persistentVolumeClaim:
|
hostPath:
|
||||||
claimName: ${KRA_APP}-config
|
path: /var/lib/${KRA_APP}/conf
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: uploads
|
- name: uploads
|
||||||
persistentVolumeClaim:
|
hostPath:
|
||||||
claimName: ${KRA_APP}-uploads
|
path: /var/lib/${KRA_APP}/uploads
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: config-template
|
- name: config-template
|
||||||
configMap:
|
secret:
|
||||||
name: ${KRA_APP}-config-template
|
name: ${KRA_APP}-config-template
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
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
|
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: ${KRA_APP}-backend
|
name: ${KRA_APP}-backend
|
||||||
|
|
@ -379,6 +372,7 @@ spec:
|
||||||
app.kubernetes.io/name: ${KRA_APP}-frontend
|
app.kubernetes.io/name: ${KRA_APP}-frontend
|
||||||
app.kubernetes.io/part-of: ${KRA_APP}
|
app.kubernetes.io/part-of: ${KRA_APP}
|
||||||
spec:
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: harbor-registry
|
- name: harbor-registry
|
||||||
affinity:
|
affinity:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue