[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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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: ""
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user