feat(admin): add Docker and Kubernetes deployment for admin panel
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

This commit is contained in:
ZhenYi 2026-04-19 21:49:22 +08:00
parent 208b6ed84e
commit b8e5cbbb69
11 changed files with 660 additions and 0 deletions

50
admin/Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies first (layer cache optimization)
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps
# Copy source
COPY . .
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# =============================================================================
# Stage 2: Runtime (minimal Node.js image)
# =============================================================================
FROM node:20-alpine AS runtime
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
WORKDIR /app
# Copy built artifacts from builder
COPY --from=builder /app/.next .next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
USER nextjs
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"]
CMD ["node_modules/.bin/next", "start"]

13
admin/deploy/Chart.yaml Normal file
View File

@ -0,0 +1,13 @@
apiVersion: v2
name: admin
description: GitData.AI Admin Panel (Next.js)
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- admin
- nextjs
- gitdata
maintainers:
- name: gitdata Team
email: team@c.dev

View File

@ -0,0 +1,15 @@
{{/* =============================================================================
Common helpers
============================================================================= */}}
{{- define "admin.fullname" -}}
{{- .Release.Name -}}
{{- end -}}
{{- define "admin.namespace" -}}
{{- .Values.namespace | default .Release.Namespace -}}
{{- end -}}
{{- define "admin.image" -}}
{{- printf "%s/%s:%s" .Values.image.registry .Values.admin.image.repository .Values.admin.image.tag -}}
{{- end -}}

View File

@ -0,0 +1,117 @@
{{- if .Values.admin.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "admin.fullname" . }}-admin
namespace: {{ include "admin.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ .Values.admin.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- if $.Values.image.pullSecrets }}
imagePullSecrets:
{{- range $.Values.image.pullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
terminationGracePeriodSeconds: 30
containers:
- name: admin
image: "{{ include "admin.image" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.admin.service.port }}
protocol: TCP
env:
- name: NODE_ENV
value: {{ .Values.admin.config.nodeEnv | default "production" | quote }}
{{- if .Values.admin.config.appUrl }}
- name: NEXT_PUBLIC_APP_URL
value: {{ .Values.admin.config.appUrl | quote }}
{{- end }}
{{- if .Values.admin.config.appDomain }}
- name: NEXT_PUBLIC_APP_DOMAIN
value: {{ .Values.admin.config.appDomain | quote }}
{{- end }}
{{- if .Values.secrets.enabled }}
- name: {{ .Values.admin.secretKeys.databaseUrl }}
valueFrom:
secretKeyRef:
name: {{ include "admin.fullname" . }}-secrets
key: {{ .Values.admin.secretKeys.databaseUrl }}
- name: {{ .Values.admin.secretKeys.redisUrl }}
valueFrom:
secretKeyRef:
name: {{ include "admin.fullname" . }}-secrets
key: {{ .Values.admin.secretKeys.redisUrl }}
- name: {{ .Values.admin.secretKeys.nextAuthSecret }}
valueFrom:
secretKeyRef:
name: {{ include "admin.fullname" . }}-secrets
key: {{ .Values.admin.secretKeys.nextAuthSecret }}
{{- end }}
{{- range .Values.admin.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.admin.livenessProbe.path }}
port: {{ .Values.admin.livenessProbe.port }}
initialDelaySeconds: {{ .Values.admin.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.admin.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.admin.readinessProbe.path }}
port: {{ .Values.admin.readinessProbe.port }}
initialDelaySeconds: {{ .Values.admin.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.admin.readinessProbe.periodSeconds }}
{{- if .Values.admin.resources }}
resources:
{{- toYaml .Values.admin.resources | nindent 12 }}
{{- end }}
{{- with .Values.admin.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.admin.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.admin.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "admin.fullname" . }}-admin
namespace: {{ include "admin.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: {{ .Values.admin.service.type }}
ports:
- port: {{ .Values.admin.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ include "admin.fullname" . }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,48 @@
{{- if and .Values.admin.enabled .Values.admin.ingress.enabled -}}
{{- $fullName := include "admin.fullname" . -}}
{{- $ns := include "admin.namespace" . -}}
{{- $hosts := .Values.admin.ingress.hosts | default list -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}-admin-ingress
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-admin
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
{{- if .Values.admin.ingress.annotations }}
{{- toYaml .Values.admin.ingress.annotations | nindent 4 }}
{{- end }}
{{- if $.Values.certManager.enabled }}
cert-manager.io/cluster-issuer: {{ $.Values.certManager.clusterIssuerName }}
{{- end }}
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/enable-websocket: "true"
spec:
ingressClassName: nginx
{{- if and $hosts $.Values.certManager.enabled }}
tls:
{{- range $hosts }}
- hosts:
- {{ .host }}
secretName: {{ $fullName }}-admin-tls
{{- end }}
{{- end }}
rules:
{{- range $hosts }}
- host: {{ .host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $fullName }}-admin
port:
number: {{ $.Values.admin.service.port }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,31 @@
{{- /*
Bootstrap secrets for development only.
In production, use an external secret manager (Vault, SealedSecrets, External Secrets).
*/ -}}
{{- $secrets := .Values.secrets | default dict -}}
{{- if and (ne $secrets.enabled false) $secrets.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "admin.fullname" . }}-secrets
namespace: {{ include "admin.namespace" . }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
"helm.sh/resource-policy": keep
type: Opaque
stringData:
{{- if $secrets.databaseUrl }}
{{ .Values.admin.secretKeys.databaseUrl }}: {{ $secrets.databaseUrl | quote }}
{{- end }}
{{- if $secrets.redisUrl }}
{{ .Values.admin.secretKeys.redisUrl }}: {{ $secrets.redisUrl | quote }}
{{- end }}
{{- if $secrets.nextAuthSecret }}
{{ .Values.admin.secretKeys.nextAuthSecret }}: {{ $secrets.nextAuthSecret | quote }}
{{- end }}
{{- range $key, $value := $secrets.extra | default dict }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,44 @@
# User overrides for Helm deployment
# Copy to values.user.yaml and customize
namespace: gitdataai
releaseName: admin
image:
registry: harbor.gitdata.me/gta_team
pullSecrets:
- harbor-secret
# Admin config
admin:
replicaCount: 2
ingress:
enabled: true
hosts:
- host: admin.gitdata.ai
annotations:
# nginx.ingress.kubernetes.io/proxy-body-size: "50m"
config:
appUrl: "https://admin.gitdata.ai"
appDomain: "admin.gitdata.ai"
nodeEnv: production
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# Secrets (bootstrap use external secrets in production)
secrets:
enabled: true
databaseUrl: "postgresql://user:pass@host:5432/gitdata"
redisUrl: "redis://host:6379/0"
nextAuthSecret: "your-secret-here"
# Or use external secrets
# externalSecrets:
# storeName: vault-backend

78
admin/deploy/values.yaml Normal file
View File

@ -0,0 +1,78 @@
# =============================================================================
# Global / common settings
# =============================================================================
namespace: gitdataai
releaseName: admin
image:
registry: harbor.gitdata.me/gta_team
pullPolicy: IfNotPresent
pullSecrets: [ ]
# =============================================================================
# Cert-Manager Configuration (集群已安装 cert-manager)
# =============================================================================
certManager:
enabled: true
clusterIssuerName: cloudflare-acme-cluster-issuer
externalSecrets:
storeName: vault-backend
storeKind: SecretStore
admin:
enabled: true
replicaCount: 2
image:
repository: admin
tag: latest
service:
type: ClusterIP
port: 3000
ingress:
enabled: false
hosts: [ ]
annotations: { }
resources:
requests:
cpu: 100m
memory: 256Mi
livenessProbe:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
config:
appUrl: ""
appDomain: ""
nodeEnv: production
secretKeys:
databaseUrl: APP_DATABASE_URL
redisUrl: APP_REDIS_URL
nextAuthSecret: APP_NEXTAUTH_SECRET
env: [ ]
nodeSelector: { }
tolerations: [ ]
affinity: { }
secrets:
enabled: false
databaseUrl: ""
redisUrl: ""
nextAuthSecret: ""
extra: { }

81
admin/scripts/build.js Normal file
View File

@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Build Docker image for Admin
*
* Workflow:
* 1. npm ci --legacy-peer-deps (install dependencies)
* 2. npm run build (Next.js build, outputs to .next/)
* 3. docker build (multi-stage, minimal runtime image)
*
* Usage:
* node scripts/build.js # Build image
* node scripts/build.js --no-cache # Build without Docker layer cache
*
* Environment:
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
* TAG - Image tag (default: git short SHA)
*/
const { execSync } = require('child_process');
const path = require('path');
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
const TAG = process.env.TAG || GIT_SHA_SHORT;
const SERVICE = 'admin';
const args = process.argv.slice(2);
const noCache = args.includes('--no-cache');
const rootDir = path.join(__dirname, '..');
console.log(`\n=== Build Configuration ===`);
console.log(`Registry: ${REGISTRY}`);
console.log(`Tag: ${TAG}`);
console.log(`Service: ${SERVICE}`);
console.log(`No Cache: ${noCache}`);
console.log('');
// Step 1: npm ci
console.log(`==> Step 1: Installing dependencies (npm ci)`);
try {
execSync(`npm ci --legacy-peer-deps`, {
stdio: 'inherit',
cwd: rootDir,
});
console.log(` [OK] Dependencies installed`);
} catch (error) {
console.error(` [FAIL] npm ci failed`);
process.exit(1);
}
// Step 2: Next.js build
console.log(`\n==> Step 2: Building Next.js (npm run build)`);
try {
execSync(`npm run build`, {
stdio: 'inherit',
cwd: rootDir,
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
});
console.log(` [OK] Next.js build complete`);
} catch (error) {
console.error(` [FAIL] Next.js build failed`);
process.exit(1);
}
// Step 3: Docker build
console.log(`\n==> Step 3: Building Docker image`);
const dockerfile = path.join(rootDir, 'Dockerfile');
const image = `${REGISTRY}/${SERVICE}:${TAG}`;
const buildCmd = `docker build ${noCache ? '--no-cache' : ''} -f "${dockerfile}" -t "${image}" .`;
console.log(` Building ${image}`);
try {
execSync(buildCmd, { stdio: 'inherit', cwd: rootDir });
console.log(` [OK] ${image}`);
} catch (error) {
console.error(` [FAIL] Docker build failed`);
process.exit(1);
}
console.log(`\n=== Build Complete ===`);
console.log(` ${image}`);
console.log('');

110
admin/scripts/deploy.js Normal file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Deploy Admin via Helm
*
* Usage:
* node scripts/deploy.js # Deploy with defaults
* node scripts/deploy.js --dry-run # Dry run only
*
* Environment:
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
* TAG - Image tag (default: git short SHA)
* NAMESPACE - Kubernetes namespace (default: gitdataai)
* RELEASE - Helm release name (default: admin)
* KUBECONFIG - Path to kubeconfig (default: ~/.kube/config)
*/
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
const TAG = process.env.TAG || process.env.GITHUB_SHA?.substring(0, 8) || GIT_SHA_SHORT;
const NAMESPACE = process.env.NAMESPACE || 'gitdataai';
const RELEASE = process.env.RELEASE || 'admin';
const CHART_PATH = path.join(__dirname, '..', 'deploy');
const KUBECONFIG = process.env.KUBECONFIG || path.join(
process.env.HOME || process.env.USERPROFILE,
'.kube',
'config'
);
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
const SERVICE = 'admin';
// Validate kubeconfig
if (!fs.existsSync(KUBECONFIG)) {
console.error(`Error: kubeconfig not found at ${KUBECONFIG}`);
console.error('Set KUBECONFIG environment variable or ensure ~/.kube/config exists');
process.exit(1);
}
console.log(`\n=== Deploy Configuration ===`);
console.log(`Registry: ${REGISTRY}`);
console.log(`Tag: ${TAG}`);
console.log(`Namespace: ${NAMESPACE}`);
console.log(`Release: ${RELEASE}`);
console.log(`Service: ${SERVICE}`);
console.log(`Dry Run: ${isDryRun}`);
console.log('');
// Build helm values override
const valuesFile = path.join(CHART_PATH, 'values.yaml');
const userValuesFile = path.join(CHART_PATH, 'values.user.yaml');
const setValues = [
`image.registry=${REGISTRY}`,
`admin.image.tag=${TAG}`,
];
const helmArgs = [
'upgrade', '--install', RELEASE, CHART_PATH,
'--namespace', NAMESPACE,
'--create-namespace',
'-f', valuesFile,
...(fs.existsSync(userValuesFile) ? ['-f', userValuesFile] : []),
...setValues.flatMap(v => ['--set', v]),
'--wait',
'--timeout', '5m',
];
if (isDryRun) {
helmArgs.push('--dry-run');
console.log('==> Dry run mode - no changes will be made\n');
}
// Helm upgrade
console.log(`==> Running helm upgrade`);
console.log(` Command: helm ${helmArgs.join(' ')}\n`);
try {
execSync(`helm ${helmArgs.join(' ')}`, {
stdio: 'inherit',
env: { ...process.env, KUBECONFIG },
});
console.log(`\n[OK] Deployment ${isDryRun ? '(dry-run) ' : ''}complete`);
} catch (error) {
console.error('\n[FAIL] Deployment failed');
process.exit(1);
}
// Rollout status
if (!isDryRun) {
console.log('\n==> Checking rollout status');
const deploymentName = `${RELEASE}-${SERVICE}`;
console.log(` Checking ${deploymentName}...`);
try {
execSync(
`kubectl rollout status deployment/${deploymentName} -n ${NAMESPACE} --timeout=120s`,
{ stdio: 'pipe', env: { ...process.env, KUBECONFIG } }
);
console.log(` [OK] ${deploymentName}`);
} catch (error) {
console.error(` [WARN] ${deploymentName} rollout timeout or failed`);
}
}
console.log('\n=== Deploy Complete ===\n');

73
admin/scripts/push.js Normal file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Push Admin Docker image to registry
*
* Usage:
* node scripts/push.js # Push image
* node scripts/push.js --dry-run # Show what would be pushed
*
* Environment:
* REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team)
* TAG - Image tag (default: git short SHA)
* DOCKER_USER - Registry username
* DOCKER_PASS - Registry password
*/
const { execSync } = require('child_process');
const path = require('path');
const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team';
const GIT_SHA_SHORT = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
const TAG = process.env.TAG || GIT_SHA_SHORT;
const SERVICE = 'admin';
const DOCKER_USER = process.env.DOCKER_USER || process.env.HARBOR_USERNAME;
const DOCKER_PASS = process.env.DOCKER_PASS || process.env.HARBOR_PASSWORD;
const args = process.argv.slice(2);
const isDryRun = args.includes('--dry-run');
if (!DOCKER_USER || !DOCKER_PASS) {
console.error('Error: DOCKER_USER and DOCKER_PASS environment variables are required');
console.error('Set HARBOR_USERNAME and HARBOR_PASSWORD as alternative');
process.exit(1);
}
const image = `${REGISTRY}/${SERVICE}:${TAG}`;
console.log(`\n=== Push Configuration ===`);
console.log(`Registry: ${REGISTRY}`);
console.log(`Tag: ${TAG}`);
console.log(`Image: ${image}`);
console.log(`Dry Run: ${isDryRun}`);
console.log('');
if (isDryRun) {
console.log('[DRY RUN] Would push:', image);
console.log('');
process.exit(0);
}
// Login
console.log(`==> Logging in to ${REGISTRY}`);
try {
execSync(`docker login ${REGISTRY} -u "${DOCKER_USER}" -p "${DOCKER_PASS}"`, {
stdio: 'inherit',
});
} catch (error) {
console.error('Login failed');
process.exit(1);
}
// Push
console.log(`\n==> Pushing ${image}`);
try {
execSync(`docker push "${image}"`, { stdio: 'inherit' });
console.log(` [OK] ${image}`);
} catch (error) {
console.error(` [FAIL] Push failed`);
process.exit(1);
}
console.log(`\n=== Push Complete ===`);
console.log(` ${image}`);
console.log('');