diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..33f5e0e --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,200 @@ +name: Build and Deploy + +on: + push: + branches: + - master + - dev + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + git clone --depth=1 --branch=${{ github.ref_name }} https://gitea.airlabs.art/${{ github.repository }}.git . + + - name: Set environment by branch + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + BUILD_DATE=$(date +%Y%m%d) + + if [[ "${{ github.ref_name }}" == "master" ]]; then + echo "IMAGE_TAG=prod-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV + echo "CR_SERVER_ACTIVE=gitea-prod-cn-shanghai.cr.volces.com" >> $GITHUB_ENV + echo "CR_USERNAME_ACTIVE=seaislee@76339115" >> $GITHUB_ENV + echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PROD_PASSWORD }}" >> $GITHUB_ENV + echo "CR_ORG=prod" >> $GITHUB_ENV + echo "DEPLOY_ENV=production" >> $GITHUB_ENV + echo "DOMAIN_WEB=airspark.airlabs.art" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "dev" ]]; then + echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV + echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV + echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV + echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV + echo "CR_ORG=dev" >> $GITHUB_ENV + echo "DEPLOY_ENV=development" >> $GITHUB_ENV + echo "DOMAIN_WEB=airspark.test.airlabs.art" >> $GITHUB_ENV + fi + + - name: Login to Volcano Engine CR + run: | + echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }} + + - name: Build and Push Web + id: build_web + run: | + set -o pipefail + ok=0 + for attempt in 1 2 3; do + echo "Build web attempt $attempt/3..." + DOCKER_BUILDKIT=0 docker build \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airspark-web:${{ env.IMAGE_TAG }} \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airspark-web:latest \ + ./frontend 2>&1 | tee /tmp/build.log && { ok=1; break; } + echo "Attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: web build failed after 3 attempts"; exit 1; } + ok=0 + for attempt in 1 2 3; do + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airspark-web:${{ env.IMAGE_TAG }} && \ + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airspark-web:latest && { ok=1; break; } + echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: web push failed after 3 attempts"; exit 1; } + + - name: Setup Kubectl + run: | + if ! command -v kubectl &>/dev/null; then + ok=0 + for attempt in 1 2 3; do + curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && { ok=1; break; } + echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5 + done + [ $ok -eq 1 ] || { echo "ERROR: kubectl download failed after 3 attempts"; exit 1; } + chmod +x kubectl && mv kubectl /usr/bin/kubectl + fi + kubectl version --client + + - name: Set kubeconfig + run: | + mkdir -p $HOME/.kube + if [[ "${{ github.ref_name }}" == "master" ]]; then + printf '%s\n' '${{ secrets.VOLCANO_PROD_KUBE_CONFIG }}' > $HOME/.kube/config + elif [[ "${{ github.ref_name }}" == "dev" ]]; then + printf '%s\n' '${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}' > $HOME/.kube/config + fi + chmod 600 $HOME/.kube/config + echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)" + grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig" + + - name: Deploy to K3s + id: deploy + run: | + echo "Environment: ${{ env.DEPLOY_ENV }}" + CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}" + + # Replace image placeholders + sed -i "s|\${CI_REGISTRY_IMAGE}/airspark-web:latest|${CR_IMAGE}/airspark-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml + + # Replace domain placeholder in ingress + sed -i "s|airspark.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml + + # All kubectl operations with retry (K3s 内网连接可能抖动) + export KUBECTL_TIMEOUT="--request-timeout=4s" + + ok=0 + for attempt in 1 2 3 4 5; do + echo "Deploy attempt $attempt/5..." + { + # Create/update image pull secret for CR + kubectl $KUBECTL_TIMEOUT create secret docker-registry cr-pull-secret \ + --docker-server="${{ env.CR_SERVER_ACTIVE }}" \ + --docker-username="${{ env.CR_USERNAME_ACTIVE }}" \ + --docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \ + --dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f - + + # Apply manifests + kubectl $KUBECTL_TIMEOUT apply -f k8s/cert-manager-issuer.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/redirect-https-middleware.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/ingress.yaml + + # Preserve real client IP + kubectl $KUBECTL_TIMEOUT patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true + + kubectl $KUBECTL_TIMEOUT rollout restart deployment/airspark-web + } 2>&1 | tee /tmp/deploy.log && { ok=1; break; } + echo "Attempt $attempt failed, retrying in 30s..." + sleep 30 + done + [ $ok -eq 1 ] || { echo "ERROR: deploy to K3s failed after 5 attempts — check /tmp/deploy.log"; exit 1; } + + # ===== Log Center: failure reporting ===== + - name: Report failure to Log Center + if: failure() + run: | + BUILD_LOG="" + DEPLOY_LOG="" + FAILED_STEP="unknown" + + if [[ "${{ steps.build_web.outcome }}" == "failure" ]]; then + FAILED_STEP="build" + if [ -f /tmp/build.log ]; then + BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + fi + elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then + FAILED_STEP="deploy" + if [ -f /tmp/deploy.log ]; then + DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + fi + fi + + ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}" + if [ -z "$ERROR_LOG" ]; then + ERROR_LOG="No captured output. Check Gitea Actions UI for details." + fi + + if [[ "$FAILED_STEP" == "deploy" ]]; then + SOURCE="deployment" + ERROR_TYPE="DeployError" + else + SOURCE="cicd" + ERROR_TYPE="DockerBuildError" + fi + + curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \ + -H "Content-Type: application/json" \ + -d "{ + \"project_id\": \"airspark\", + \"environment\": \"${{ env.DEPLOY_ENV }}\", + \"level\": \"ERROR\", + \"source\": \"${SOURCE}\", + \"commit_hash\": \"${{ github.sha }}\", + \"repo_url\": \"https://gitea.airlabs.art/zyc/AirSpark.git\", + \"error\": { + \"type\": \"${ERROR_TYPE}\", + \"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\", + \"stack_trace\": [\"${ERROR_LOG}\"] + }, + \"context\": { + \"job_name\": \"build-and-deploy\", + \"step_name\": \"${FAILED_STEP}\", + \"workflow\": \"${{ github.workflow }}\", + \"run_id\": \"${{ github.run_number }}\", + \"branch\": \"${{ github.ref_name }}\", + \"actor\": \"${{ github.actor }}\", + \"commit\": \"${{ github.sha }}\", + \"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\" + } + }" || true + + # ===== Cleanup: remove unused Docker resources ===== + - name: Docker Cleanup + if: always() + run: | + docker container prune -f + docker image prune -f + docker builder prune -a -f + echo "Disk usage after cleanup:" + df -h / | tail -1 diff --git a/README.md b/README.md index 22db4c3..575edaf 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,23 @@ Project(一部动画 IP) - [UI-Design-System.md](./UI-Design-System.md) — 设计系统 - [DEV-LOG.md](./DEV-LOG.md) — 开发日志 - [seedance视频生成文档.md](./seedance视频生成文档.md) — Seedance API 接入 + +--- + +## 部署 + +CI/CD 走 Gitea Actions + 火山引擎 CR + K3s(traefik + cert-manager)。 + +| 分支 | 环境 | 域名 | Image tag | +| --- | --- | --- | --- | +| `master` | production | `airspark.airlabs.art` | `prod-YYYYMMDD-` | +| `dev` | development | `airspark.test.airlabs.art` | `dev-YYYYMMDD-` | + +推到对应分支自动触发 [.gitea/workflows/deploy.yaml](.gitea/workflows/deploy.yaml): +checkout → `docker build ./frontend` (multi-stage: deps → builder → runner)→ push `airspark-web` → kubectl apply [k8s/](k8s/) → rollout restart。 + +**Next.js standalone 模式:** [frontend/next.config.ts](frontend/next.config.ts) 中 `output: "standalone"`,Docker runtime 仅拷贝 `.next/standalone` + `.next/static` + `public`,以非 root 用户运行 `node server.js`,监听 `:3000`。Service 80 → targetPort 3000。 + +**Gitea 仓库需要配置的 Secrets:** +- prod: `CR_PROD_PASSWORD` · `VOLCANO_PROD_KUBE_CONFIG` +- dev: `CR_SERVER` · `CR_USERNAME` · `CR_PASSWORD` · `VOLCANO_TEST_KUBE_CONFIG` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..645fcde --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,15 @@ +Dockerfile +.dockerignore +.git +.gitignore +.DS_Store +node_modules +.next +out +build +coverage +*.log +.env* +.vercel +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d2f7e30 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,39 @@ +# ---- Deps Stage ---- +FROM docker.m.daocloud.io/node:20-alpine AS deps + +RUN npm config set registry https://registry.npmmirror.com + +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +# ---- Build Stage ---- +FROM docker.m.daocloud.io/node:20-alpine AS builder + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ---- Runtime Stage ---- +FROM docker.m.daocloud.io/node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +# Standalone output bundles minimal node_modules + server.js +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index ca6c939..ead555b 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { devIndicators: false, + output: "standalone", }; export default nextConfig; diff --git a/k8s/cert-manager-issuer.yaml b/k8s/cert-manager-issuer.yaml new file mode 100644 index 0000000..b78a0e3 --- /dev/null +++ b/k8s/cert-manager-issuer.yaml @@ -0,0 +1,15 @@ +# ClusterIssuer for Let's Encrypt automatic certificate generation & renewal +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: airlabsv001@gmail.com + privateKeySecretRef: + name: letsencrypt-prod-key + solvers: + - http01: + ingress: + class: traefik diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..09e5d19 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: airspark-ingress + annotations: + kubernetes.io/ingress.class: "traefik" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd" +spec: + tls: + - hosts: + - airspark.airlabs.art + secretName: airspark-tls + rules: + - host: airspark.airlabs.art + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: airspark-web + port: + number: 80 diff --git a/k8s/redirect-https-middleware.yaml b/k8s/redirect-https-middleware.yaml new file mode 100644 index 0000000..e5eedb9 --- /dev/null +++ b/k8s/redirect-https-middleware.yaml @@ -0,0 +1,8 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: redirect-https +spec: + redirectScheme: + scheme: https + permanent: true diff --git a/k8s/web-deployment.yaml b/k8s/web-deployment.yaml new file mode 100644 index 0000000..bcfb59a --- /dev/null +++ b/k8s/web-deployment.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: airspark-web + labels: + app: airspark-web +spec: + replicas: 1 + selector: + matchLabels: + app: airspark-web + template: + metadata: + labels: + app: airspark-web + spec: + imagePullSecrets: + - name: cr-pull-secret + containers: + - name: airspark-web + image: ${CI_REGISTRY_IMAGE}/airspark-web:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + env: + - name: NODE_ENV + value: "production" + - name: PORT + value: "3000" + - name: HOSTNAME + value: "0.0.0.0" + livenessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: airspark-web +spec: + selector: + app: airspark-web + ports: + - protocol: TCP + port: 80 + targetPort: 3000