将预览密钥内置到API镜像
Project CI / Repository checks (push) Successful in 4m32s
Project CI / Frontend tests (push) Successful in 4m34s
Project CI / Backend tests (push) Successful in 5m18s
Project CI / Native shell tests (push) Failing after 1m37s

固定Jenkins预览密钥宿主路径并收紧文件校验

通过BuildKit secret向API和工作进程镜像安装密钥文件

增加摘要缓存门禁和普通无密钥构建兼容验证

同步预览部署文档、安全边界与排障记忆
This commit is contained in:
2026-08-22 15:45:40 +08:00
parent 27de28a487
commit d631253c34
8 changed files with 154 additions and 3 deletions
+69
View File
@@ -5,6 +5,10 @@ const jenkinsfile = readFileSync(
'utf8',
);
const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8');
const apiServerDockerfile = readFileSync(
'deploy/container/api-server.Dockerfile',
'utf8',
);
const resultWriter = readFileSync(
'scripts/preview-deployment-status.mjs',
'utf8',
@@ -125,6 +129,67 @@ assertIncludes(
'restart: on-failure',
'预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。',
);
assertIncludes(
deployer,
'GENARRATIVE_PREVIEW_SECRETS_SHA256',
'预览构建必须把固定 secrets 文件摘要作为镜像缓存与完整性校验参数。',
);
assertIncludes(
jenkinsfile,
"GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local'",
'Jenkins 必须从受保护的固定宿主路径读取预览 secrets。',
);
assertIncludes(
deployer,
'[[ "${secrets_mode}" == "600" ]]',
'预览构建必须拒绝权限过宽的 secrets 文件。',
);
assertIncludes(
deployer,
'[[ "${secrets_owner}" == "${EUID}" ]]',
'预览构建必须校验 secrets 文件归 Jenkins 执行用户所有。',
);
assertIncludes(
deployer,
'genarrative_preview_secrets',
'预览 API 与外部生成 worker 构建必须使用固定名称的 BuildKit secret。',
);
assertCount(
deployer,
'target: genarrative_preview_secrets',
2,
'预览 secrets 必须且只能提供给 API 和外部生成 worker 两个构建。',
);
assertIncludes(
apiServerDockerfile,
'ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=',
'API 镜像必须允许普通构建不提供预览 secrets 摘要。',
);
assertIncludes(
apiServerDockerfile,
'RUN --mount=type=secret,id=genarrative_preview_secrets,required=false',
'API 镜像必须通过可选 BuildKit secret 接收预览 secrets 文件。',
);
assertIncludes(
apiServerDockerfile,
'test -f /run/secrets/genarrative_preview_secrets;',
'提供预览 secrets 摘要时必须要求 BuildKit secret 存在。',
);
assertIncludes(
apiServerDockerfile,
'sha256sum /run/secrets/genarrative_preview_secrets',
'API 镜像必须在安装前校验预览 secrets 文件摘要。',
);
assertIncludes(
apiServerDockerfile,
'install -o genarrative -g genarrative -m 0400',
'预览 secrets 文件必须只允许 API 运行用户读取。',
);
assertIncludes(
apiServerDockerfile,
'/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local;',
'预览 secrets 文件必须安装到 API 启动时读取的固定路径。',
);
assertIncludes(
deployer,
'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true',
@@ -256,3 +321,7 @@ function assertIncludes(content, expected, message) {
function assertExcludes(content, expected, message) {
if (content.includes(expected)) failures.push(message);
}
function assertCount(content, expected, count, message) {
if (content.split(expected).length - 1 !== count) failures.push(message);
}
+39
View File
@@ -10,6 +10,7 @@ SOURCE_DIR="${SOURCE_DIR:-${WORKSPACE:-$(pwd)}/source}"
RESULT_FILE="${RESULT_FILE:-${WORKSPACE:-$(pwd)}/preview-result.json}"
DESCRIPTION_FILE="${DESCRIPTION_FILE:-${WORKSPACE:-$(pwd)}/.jenkins-preview-description}"
STATE_ROOT="${GENARRATIVE_PREVIEW_STATE_ROOT:-/data/jenkins/preview-deployments}"
PREVIEW_SECRETS_FILE="${GENARRATIVE_PREVIEW_SECRETS_FILE:-/data/jenkins/preview-secrets/.env.secrets.local}"
WEB_HOST="${GENARRATIVE_PREVIEW_WEB_HOST:-}"
LOCK_FILE="${GENARRATIVE_PREVIEW_LOCK_FILE:-${STATE_ROOT}/.lock}"
@@ -18,6 +19,7 @@ STATE_FILE=""
PROJECT_NAME=""
SCRIPT_ROOT=""
SCRIPT_FAILED=1
PREVIEW_SECRETS_SHA256=""
fail() {
echo "[preview-deployer] $*" >&2
@@ -244,6 +246,27 @@ allocate_port() {
fail "端口范围 ${start}-${end} 已无可用端口。"
}
validate_preview_secrets_file() {
local secrets_dir secrets_mode secrets_owner canonical_secrets canonical_source
[[ "${PREVIEW_SECRETS_FILE}" == /* ]] || fail "预览 secrets 文件必须使用绝对路径。"
[[ -f "${PREVIEW_SECRETS_FILE}" && ! -L "${PREVIEW_SECRETS_FILE}" && -r "${PREVIEW_SECRETS_FILE}" ]] || \
fail "预览 secrets 文件必须是 Jenkins 可读的非符号链接普通文件: ${PREVIEW_SECRETS_FILE}"
secrets_dir="$(dirname "${PREVIEW_SECRETS_FILE}")"
[[ -d "${secrets_dir}" && ! -L "${secrets_dir}" ]] || \
fail "预览 secrets 目录必须是非符号链接目录: ${secrets_dir}"
secrets_mode="$(stat -c '%a' "${PREVIEW_SECRETS_FILE}")"
[[ "${secrets_mode}" == "600" ]] || fail "预览 secrets 文件权限必须是 0600: ${PREVIEW_SECRETS_FILE}"
secrets_owner="$(stat -c '%u' "${PREVIEW_SECRETS_FILE}")"
[[ "${secrets_owner}" == "${EUID}" ]] || fail "预览 secrets 文件必须归当前 Jenkins 执行用户所有。"
canonical_secrets="$(realpath -e "${PREVIEW_SECRETS_FILE}")"
canonical_source="$(realpath -e "${SOURCE_DIR}")"
[[ "${canonical_secrets}" != "${canonical_source}"/* ]] || \
fail "预览 secrets 文件不能位于目标分支源码上下文内。"
PREVIEW_SECRETS_SHA256="$(sha256sum "${PREVIEW_SECRETS_FILE}")"
PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256%% *}"
[[ "${PREVIEW_SECRETS_SHA256}" =~ ^[0-9a-f]{64}$ ]] || fail "无法计算预览 secrets 文件摘要。"
}
remove_project_resources() {
local ids=()
local images=()
@@ -273,6 +296,8 @@ compose() {
COMPOSE_PROJECT_NAME="${PROJECT_NAME}" \
GENARRATIVE_PREVIEW_SOURCE_DIR="${SOURCE_DIR}" \
GENARRATIVE_PREVIEW_CONTROLLER_ROOT="${SCRIPT_ROOT}/.." \
GENARRATIVE_PREVIEW_SECRETS_FILE="${PREVIEW_SECRETS_FILE}" \
GENARRATIVE_PREVIEW_SECRETS_SHA256="${PREVIEW_SECRETS_SHA256}" \
GENARRATIVE_CONTAINER_API_ENV_FILE="${STATE_DIR}/api-server.env" \
GENARRATIVE_CONTAINER_HTTP_PORT="${WEB_PORT}" \
GENARRATIVE_CONTAINER_SPACETIME_PORT="${SPACETIME_PORT}" \
@@ -292,10 +317,20 @@ services:
build:
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
args:
GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256}
secrets:
- source: preview_runtime_env
target: genarrative_preview_secrets
external-generation-worker:
build:
context: ${GENARRATIVE_PREVIEW_SOURCE_DIR}
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
args:
GENARRATIVE_PREVIEW_SECRETS_SHA256: ${GENARRATIVE_PREVIEW_SECRETS_SHA256}
secrets:
- source: preview_runtime_env
target: genarrative_preview_secrets
restart: on-failure
nginx:
build:
@@ -306,6 +341,9 @@ services:
ports: !reset []
otelcol:
ports: !reset []
secrets:
preview_runtime_env:
file: ${GENARRATIVE_PREVIEW_SECRETS_FILE}
YAML
chmod 0600 "${override_file}"
}
@@ -394,6 +432,7 @@ deploy() {
[[ "${source_commit}" == "${requested_commit}" ]] || fail "源码 checkout 与 COMMIT_HASH 不一致。"
fi
validate_preview_secrets_file
saved_web="$(state_value webPort)"
WEB_PORT="${saved_web:-8400}"
SPACETIME_PORT=""