diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..9ed1a0e --- /dev/null +++ b/admin/Dockerfile @@ -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"] diff --git a/admin/deploy/Chart.yaml b/admin/deploy/Chart.yaml new file mode 100644 index 0000000..edc9d73 --- /dev/null +++ b/admin/deploy/Chart.yaml @@ -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 diff --git a/admin/deploy/templates/_helpers.tpl b/admin/deploy/templates/_helpers.tpl new file mode 100644 index 0000000..c8265d0 --- /dev/null +++ b/admin/deploy/templates/_helpers.tpl @@ -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 -}} diff --git a/admin/deploy/templates/admin-deployment.yaml b/admin/deploy/templates/admin-deployment.yaml new file mode 100644 index 0000000..27d5a60 --- /dev/null +++ b/admin/deploy/templates/admin-deployment.yaml @@ -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 }} diff --git a/admin/deploy/templates/admin-ingress.yaml b/admin/deploy/templates/admin-ingress.yaml new file mode 100644 index 0000000..4e8cfd2 --- /dev/null +++ b/admin/deploy/templates/admin-ingress.yaml @@ -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 }} diff --git a/admin/deploy/templates/secret.yaml b/admin/deploy/templates/secret.yaml new file mode 100644 index 0000000..b348867 --- /dev/null +++ b/admin/deploy/templates/secret.yaml @@ -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 }} diff --git a/admin/deploy/values.user.yaml.example b/admin/deploy/values.user.yaml.example new file mode 100644 index 0000000..159f0b4 --- /dev/null +++ b/admin/deploy/values.user.yaml.example @@ -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 diff --git a/admin/deploy/values.yaml b/admin/deploy/values.yaml new file mode 100644 index 0000000..82b8cec --- /dev/null +++ b/admin/deploy/values.yaml @@ -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: { } diff --git a/admin/scripts/build.js b/admin/scripts/build.js new file mode 100644 index 0000000..3bf6211 --- /dev/null +++ b/admin/scripts/build.js @@ -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(''); diff --git a/admin/scripts/deploy.js b/admin/scripts/deploy.js new file mode 100644 index 0000000..9fc380a --- /dev/null +++ b/admin/scripts/deploy.js @@ -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'); diff --git a/admin/scripts/push.js b/admin/scripts/push.js new file mode 100644 index 0000000..b6d6842 --- /dev/null +++ b/admin/scripts/push.js @@ -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('');