1
0
forked from boos/BoosAPI

[codex] Add digest-safe k3s update-center assets (#326)

* Add digest-safe k3s update-center assets

* Harden k3s update-center assets

* Trim k3s env secret names safely
This commit is contained in:
Cita
2026-03-30 17:17:24 +08:00
committed by GitHub
parent 7d91823f6c
commit 01481be511
9 changed files with 353 additions and 3 deletions
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: metapi
description: Minimal metapi chart for digest-aware k3s update-center setups
type: application
version: 0.1.0
appVersion: "1.2.3"
+27
View File
@@ -0,0 +1,27 @@
{{- define "metapi.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "metapi.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := include "metapi.name" . -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "metapi.envSecretName" -}}
{{- printf "%s-env" (include "metapi.fullname" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "metapi.labels" -}}
app.kubernetes.io/name: {{ include "metapi.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
@@ -0,0 +1,59 @@
{{- if and .Values.persistence.hostPath (gt (int .Values.replicaCount) 1) -}}
{{- fail "deploy/k3s/chart uses node-local hostPath storage; keep replicaCount at 1 or switch persistence.hostPath to shared storage before scaling" -}}
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "metapi.fullname" . }}
labels:
{{- include "metapi.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
revisionHistoryLimit: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: {{ .Values.deployment.strategy.maxSurge }}
maxUnavailable: {{ .Values.deployment.strategy.maxUnavailable }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "metapi.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "metapi.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
metapi/image-tag: {{ .Values.image.tag | quote }}
metapi/image-digest: {{ .Values.image.digest | quote }}
checksum/env-secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum | quote }}
spec:
containers:
- name: metapi
image: "{{ .Values.image.repository }}{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ .Values.image.tag }}{{ end }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "metapi.envSecretName" . }}
ports:
- name: http
containerPort: 4000
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 20
volumeMounts:
- name: metapi-data
mountPath: /app/data
volumes:
- name: metapi-data
hostPath:
path: {{ .Values.persistence.hostPath }}
type: DirectoryOrCreate
+23
View File
@@ -0,0 +1,23 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "metapi.envSecretName" . }}
labels:
{{- include "metapi.labels" . | nindent 4 }}
type: Opaque
stringData:
AUTH_TOKEN: {{ required "env.authToken is required" .Values.env.authToken | quote }}
PROXY_TOKEN: {{ required "env.proxyToken is required" .Values.env.proxyToken | quote }}
DB_TYPE: {{ .Values.env.dbType | quote }}
{{- if eq (lower (toString .Values.env.dbType)) "sqlite" }}
DB_URL: {{ default "./data/hub.db" .Values.env.dbUrl | quote }}
{{- else }}
DB_URL: {{ required "env.dbUrl is required when env.dbType is mysql/postgres" .Values.env.dbUrl | quote }}
{{- end }}
DB_SSL: {{ .Values.env.dbSsl | quote }}
DATA_DIR: {{ .Values.env.dataDir | quote }}
PORT: {{ .Values.env.port | quote }}
TZ: {{ .Values.env.tz | quote }}
CHECKIN_CRON: {{ .Values.env.checkinCron | quote }}
BALANCE_REFRESH_CRON: {{ .Values.env.balanceRefreshCron | quote }}
DEPLOY_HELPER_TOKEN: {{ .Values.env.deployHelperToken | quote }}
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "metapi.fullname" . }}
labels:
{{- include "metapi.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
app.kubernetes.io/name: {{ include "metapi.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
{{- if and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
+33
View File
@@ -0,0 +1,33 @@
replicaCount: 1
image:
repository: 1467078763/metapi
tag: latest
digest: ""
pullPolicy: Always
service:
type: NodePort
port: 4000
nodePort: 30400
deployment:
strategy:
maxSurge: 1
maxUnavailable: 0
persistence:
hostPath: /opt/1panel/docker/compose/metapihub/data
env:
authToken: ""
proxyToken: ""
dbType: postgres
dbUrl: ""
dbSsl: "false"
dataDir: /app/data
port: "4000"
tz: Asia/Shanghai
checkinCron: "0 8 * * *"
balanceRefreshCron: "0 * * * *"
deployHelperToken: ""
+36 -1
View File
@@ -47,7 +47,33 @@ spec:
- name: helper
image: 1467078763/metapi:latest
imagePullPolicy: Always
command: ["npm", "run", "start:deploy-helper"]
command: ["/bin/sh", "-lc"]
args:
- |
TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
NAMESPACE="$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)"
cat >/tmp/kubeconfig <<EOF
apiVersion: v1
kind: Config
clusters:
- name: in-cluster
cluster:
certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
server: https://kubernetes.default.svc
contexts:
- name: in-cluster
context:
cluster: in-cluster
namespace: ${NAMESPACE}
user: helper
current-context: in-cluster
users:
- name: helper
user:
token: ${TOKEN}
EOF
export KUBECONFIG=/tmp/kubeconfig
exec npm run start:deploy-helper
ports:
- containerPort: 9850
name: http
@@ -73,6 +99,15 @@ spec:
port: http
initialDelaySeconds: 15
periodSeconds: 20
volumeMounts:
- name: metapi-chart
mountPath: /opt/metapi-k3s/chart
readOnly: true
volumes:
- name: metapi-chart
hostPath:
path: /opt/metapi-k3s/chart
type: Directory
---
apiVersion: v1
kind: Service
+84 -2
View File
@@ -170,6 +170,7 @@ Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Me
仓库里带了一个最小示例清单:
- `deploy/k3s/metapi-deploy-helper.yaml`
- `deploy/k3s/chart`
使用前至少要改这些值:
@@ -177,13 +178,67 @@ Deploy Helper 是一个跑在集群里的小服务。它不负责对外提供 Me
- `DEPLOY_HELPER_TOKEN`
- `image`
如果你准备直接按仓库示例接入,推荐把它们放到这两个路径:
- `/opt/metapi-k3s/metapi-deploy-helper.yaml`
- `/opt/metapi-k3s/chart`
这里有一个很容易漏掉、但会直接决定更新中心是否真的可靠的约束:
- 后台里的 `Chart Ref`
- helper Pod 里挂载进去的 chart 路径
这两个值必须指向同一份 chart。
如果你在设置页把 `Chart Ref` 填成 `/opt/metapi-k3s/chart`,但 helper Pod 里根本没有把那份 chart 挂进去,那么更新中心即使能看到版本,也无法稳定把 digest 部署到真正运行中的 Pod。
最小部署命令示例:
```bash
kubectl create namespace ai
kubectl apply -f deploy/k3s/metapi-deploy-helper.yaml
mkdir -p /opt/metapi-k3s/chart
cp -R deploy/k3s/chart/. /opt/metapi-k3s/chart/
cp deploy/k3s/metapi-deploy-helper.yaml /opt/metapi-k3s/metapi-deploy-helper.yaml
kubectl apply -f /opt/metapi-k3s/metapi-deploy-helper.yaml
```
### 1.1 首次接入前一定要做的自检
新用户最容易踩的坑不是 helper 起不来,而是:
- chart 虽然能执行 `helm upgrade`
- 但它其实没有消费 `image.digest`
- 同时 `imagePullPolicy` 还停留在 `IfNotPresent`
这样更新中心会显示任务成功、Helm revision 也会增加,但线上真正运行的镜像可能根本没变。
仓库自带的 `deploy/k3s/chart` 已经把这件事处理成默认正确:
- `values.yaml` 里包含 `image.digest`
- `templates/deployment.yaml` 在有 digest 时会渲染成 `repository@digest`
- chart 默认 `imagePullPolicy: Always`
- helper 示例清单也默认 `imagePullPolicy: Always`
你可以在接入前先跑一次自检:
```bash
helm template metapi /opt/metapi-k3s/chart \
--set image.repository=1467078763/metapi \
--set image.tag=latest \
--set-string image.digest=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
| grep -n 'image:'
```
你期望看到的是:
- `image: "1467078763/metapi@sha256:..."`
如果输出仍然是:
- `image: "1467078763/metapi:latest"`
那说明这份 chart 还是 tag 语义,先不要继续配置更新中心。
### 2. 让主 Metapi 和 helper 用同一个 token
主 Metapi 端支持这两个环境变量,设置一个即可:
@@ -206,7 +261,7 @@ helper 端使用:
| `Deploy Helper URL` | 主 Metapi 访问 helper 的地址 | `http://metapi-deploy-helper.ai.svc.cluster.local:9850` |
| `Namespace` | 目标 release 所在命名空间 | `ai` |
| `Release Name` | Helm release 名 | `metapi` |
| `Chart Ref` | 你的 Helm chart 引用 | `oci://ghcr.io/cita-777/charts/metapi` |
| `Chart Ref` | helper Pod 能访问到的 chart 引用 | `/opt/metapi-k3s/chart` |
| `Image Repository` | 升级时写入 chart 的镜像仓库 | `1467078763/metapi` |
| `默认部署来源` | 默认从 GitHub 还是 Docker Hub 取版本 | `GitHub Releases` |
@@ -233,6 +288,17 @@ helper 端使用:
如果实时日志流断开,页面会自动回退到最近任务快照。
### 4.1 新用户最值得先做的一次确认
第一次接入时,不要急着点部署。先确认下面 4 件事同时成立:
1. `Chart Ref` 和 helper 挂载路径指向的是同一份 chart。
2. 这份 chart 支持 `image.digest`,并且在有 digest 时会渲染成 `repository@digest`。
3. chart 和 helper 都没有继续使用会掩盖新镜像的 `imagePullPolicy: IfNotPresent`。
4. 页面里显示的目标 digest,和 helper 最终读到的运行中 digest 能对上。
只要这 4 件事成立,新用户就不会再遇到“页面说部署成功,但线上还是旧镜像”的假成功。
## 这套能力当前不适合什么场景
- 只有一台 Docker 主机、一个 Compose 文件、一个 Metapi 容器
@@ -268,6 +334,22 @@ About 页里的“更新提醒”更像一个轻量入口:
缺其中任何一项,都只能看,不能真正部署。
### 为什么页面显示任务成功,但线上镜像还是没变
最常见的根因只有两类:
- 你的 chart 其实没有消费 `image.digest`
- 你的 Deployment 或 helper 还在使用 `imagePullPolicy: IfNotPresent`
这两种情况叠在一起时,就会出现“Helm revision 往前走了,但实际运行的 imageID 没动”的假成功。
如果你是第一次接入,最稳妥的方式就是直接从仓库自带的:
- `deploy/k3s/chart`
- `deploy/k3s/metapi-deploy-helper.yaml`
开始,不要自己先抄一份简化版再改。
### 为什么我只有 Docker Compose,按钮没有意义
因为当前部署链路不是去操作外部 Docker 主机,而是去调用集群里的 helper,然后由 helper 执行 `helm` / `kubectl`。
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function readRepoFile(relativePath: string) {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
function normalizeWhitespace(value: string) {
return value.replace(/\s+/g, ' ').trim();
}
describe('k3s deploy assets', () => {
it('ships a digest-aware example chart for update-center users', () => {
const requiredFiles = [
'deploy/k3s/chart/Chart.yaml',
'deploy/k3s/chart/values.yaml',
'deploy/k3s/chart/templates/_helpers.tpl',
'deploy/k3s/chart/templates/deployment.yaml',
'deploy/k3s/chart/templates/secret.yaml',
'deploy/k3s/chart/templates/service.yaml',
];
for (const filePath of requiredFiles) {
expect(existsSync(resolve(process.cwd(), filePath)), filePath).toBe(true);
}
const values = readRepoFile('deploy/k3s/chart/values.yaml');
const deploymentTemplate = readRepoFile('deploy/k3s/chart/templates/deployment.yaml');
const serviceTemplate = readRepoFile('deploy/k3s/chart/templates/service.yaml');
const helpersTemplate = readRepoFile('deploy/k3s/chart/templates/_helpers.tpl');
const normalizedDeploymentTemplate = normalizeWhitespace(deploymentTemplate);
const normalizedServiceTemplate = normalizeWhitespace(serviceTemplate);
expect(values).toContain('digest:');
expect(values).toContain('pullPolicy: Always');
expect(deploymentTemplate).toMatch(/\.Values\.image\.digest/);
expect(normalizedDeploymentTemplate).toContain('{{ if .Values.image.digest }}@{{ .Values.image.digest }}{{ else }}:{{ .Values.image.tag }}{{ end }}');
expect(deploymentTemplate).toMatch(/metapi\/image-digest:\s*\{\{\s*\.Values\.image\.digest\s*\|\s*quote\s*\}\}/);
expect(deploymentTemplate).toMatch(/checksum\/env-secret:/);
expect(deploymentTemplate).toMatch(/replicaCount at 1 or switch persistence\.hostPath to shared storage before scaling/);
expect(normalizedServiceTemplate).toContain('{{- if and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) .Values.service.nodePort }}');
expect(helpersTemplate).toContain('define "metapi.envSecretName"');
expect(helpersTemplate).toContain('printf "%s-env"');
expect(deploymentTemplate).toContain('include "metapi.envSecretName"');
expect(readRepoFile('deploy/k3s/chart/templates/secret.yaml')).toContain('include "metapi.envSecretName"');
});
it('keeps the helper manifest on a pull policy that can pick up fresh latest tags', () => {
const helperManifest = readRepoFile('deploy/k3s/metapi-deploy-helper.yaml');
expect(helperManifest).toContain('imagePullPolicy: Always');
expect(helperManifest).toContain('mountPath: /opt/metapi-k3s/chart');
expect(helperManifest).toContain('readOnly: true');
expect(helperManifest).toContain('path: /opt/metapi-k3s/chart');
});
it('documents the local chart path and digest requirement for new k3s users', () => {
const docs = readRepoFile('docs/k3s-update-center.md');
expect(docs).toContain('deploy/k3s/chart');
expect(docs).toContain('/opt/metapi-k3s/chart');
expect(docs).toContain('image.digest');
expect(docs).toContain('imagePullPolicy: Always');
expect(docs).toContain('repository@digest');
});
});