diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..955c54f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,221 @@ +--- +# ============================================================================= +# Drone CI Pipeline +# ============================================================================= +kind: pipeline +type: kubernetes +name: default + +trigger: + event: + - push + - tag + branch: + - main + +environment: + REGISTRY: harbor.gitdata.me/gta_team + CARGO_TERM_COLOR: always + BUILD_TARGET: x86_64-unknown-linux-gnu + +steps: + # ============================================================================= + # Clone + # ============================================================================= + - name: clone + image: bitnami/git:latest + commands: + - | + if [ -n "${DRONE_TAG}" ]; then + git checkout ${DRONE_TAG} + fi + + # ============================================================================= + # Stage 1: Rust – fmt + clippy + test + # ============================================================================= + - name: rust-fmt + image: rust:1.94 + commands: + - cargo fmt --all -- --check + + - name: rust-clippy + image: rust:1.94 + commands: + - apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev libclang-dev libgit2-dev zlib1g-dev + - rustup component add clippy + - cargo clippy --workspace --all-targets -- -D warnings + + - name: rust-test + image: rust:1.94 + commands: + - apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev libclang-dev libgit2-dev zlib1g-dev + - cargo test --workspace --all-features + + # ============================================================================= + # Stage 2: Frontend – lint + typecheck + build + # ============================================================================= + - name: frontend-deps + image: node:22-alpine + commands: + - corepack enable + - corepack prepare pnpm@10 --activate + - pnpm install --frozen-lockfile + + - name: frontend-lint + image: node:22-alpine + commands: + - pnpm lint + depends_on: [ frontend-deps ] + + - name: frontend-typecheck + image: node:22-alpine + commands: + - pnpm tsc -b --noEmit + depends_on: [ frontend-lint ] + + - name: frontend-build + image: node:22-alpine + commands: + - pnpm build + depends_on: [ frontend-typecheck ] + + # ============================================================================= + # Stage 3: Docker build & push + # ============================================================================= + - name: docker-login + image: docker:latest + commands: + - docker login ${REGISTRY} --username ${DRONE_SECRET_DOCKER_USERNAME} --password-stdin <<< ${DRONE_SECRET_DOCKER_PASSWORD} + when: + status: [ success ] + + - name: docker-build-and-push + image: docker:latest + environment: + DOCKER_BUILDKIT: "1" + commands: + - | + TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" + echo "==> Building images with tag: ${TAG}" + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/app.Dockerfile -t ${REGISTRY}/app:${TAG} . + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/gitserver.Dockerfile -t ${REGISTRY}/gitserver:${TAG} . + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/email-worker.Dockerfile -t ${REGISTRY}/email-worker:${TAG} . + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/git-hook.Dockerfile -t ${REGISTRY}/git-hook:${TAG} . + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/migrate.Dockerfile -t ${REGISTRY}/migrate:${TAG} . + docker build --build-arg BUILD_TARGET=${BUILD_TARGET} -f docker/operator.Dockerfile -t ${REGISTRY}/operator:${TAG} . + docker build -f docker/static.Dockerfile -t ${REGISTRY}/static:${TAG} . + docker build -f docker/frontend.Dockerfile -t ${REGISTRY}/frontend:${TAG} . + echo "==> Pushing images" + for svc in app gitserver email-worker git-hook migrate operator static frontend; do + docker push ${REGISTRY}/${svc}:${TAG} + done + echo "==> Tagging as latest" + for svc in app gitserver email-worker git-hook migrate operator static frontend; do + docker tag ${REGISTRY}/${svc}:${TAG} ${REGISTRY}/${svc}:latest + docker push ${REGISTRY}/${svc}:latest + done + echo "==> All images pushed" + depends_on: [ docker-login, frontend-build ] + settings: + privileged: true + when: + status: [ success ] + + # ============================================================================= + # Stage 4: Deploy to Kubernetes + # ============================================================================= + - name: prepare-kubeconfig + image: alpine:latest + commands: + - apk add --no-cache kubectl + - mkdir -p ~/.kube + - echo "${KUBECONFIG}" | base64 -d > ~/.kube/config + - chmod 600 ~/.kube/config + + - name: create-namespace + image: bitnami/kubectl:latest + commands: + - kubectl create namespace gitdataai --dry-run=client -o yaml | kubectl apply -f - + depends_on: [ prepare-kubeconfig ] + when: + branch: [ main ] + + - name: deploy-configmap + image: bitnami/kubectl:latest + commands: + - kubectl apply -f deploy/configmap.yaml + depends_on: [ create-namespace ] + when: + branch: [ main ] + + - name: helm-deploy + image: alpine/helm:latest + commands: + - apk add --no-cache curl kubectl + - curl -fsSL -o /tmp/helm.tar.gz https://get.helm.sh/helm-v3.15.0-linux-amd64.tar.gz + - tar -xzf /tmp/helm.tar.gz -C /tmp + - mv /tmp/linux-amd64/helm /usr/local/bin/helm && chmod +x /usr/local/bin/helm + - | + TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}" + helm upgrade --install gitdata deploy/ \ + --namespace gitdataai \ + -f deploy/values.yaml \ + -f deploy/secrets.yaml \ + --set image.registry=${REGISTRY} \ + --set app.image.tag=${TAG} \ + --set gitserver.image.tag=${TAG} \ + --set emailWorker.image.tag=${TAG} \ + --set gitHook.image.tag=${TAG} \ + --set operator.image.tag=${TAG} \ + --set static.image.tag=${TAG} \ + --set frontend.image.tag=${TAG} \ + --wait \ + --timeout 5m \ + --atomic + depends_on: [ deploy-configmap ] + when: + status: [ success ] + branch: [ main ] + + - name: verify-rollout + image: bitnami/kubectl:latest + commands: + - kubectl rollout status deployment/gitdata-frontend -n gitdataai --timeout=300s + - kubectl rollout status deployment/gitdata-app -n gitdataai --timeout=300s + - kubectl rollout status deployment/gitdata-gitserver -n gitdataai --timeout=300s + - kubectl rollout status deployment/gitdata-email-worker -n gitdataai --timeout=300s + - kubectl rollout status deployment/gitdata-git-hook -n gitdataai --timeout=300s + depends_on: [ helm-deploy ] + when: + status: [ success ] + branch: [ main ] + +# ============================================================================= +# Secrets (register via drone CLI) +# +# # Harbor username for docker login +# drone secret add \ +# --repository \ +# --name drone_secret_docker_username \ +# --data +# +# # Harbor password for docker login +# drone secret add \ +# --repository \ +# --name drone_secret_docker_password \ +# --data +# +# # kubeconfig (base64 encoded) +# drone secret add \ +# --repository \ +# --name kubeconfig \ +# --data "$(cat ~/.kube/config | base64 -w 0)" +# +# # Local exec (for testing): +# drone exec --trusted \ +# --secret=DRONE_SECRET_DOCKER_USERNAME= \ +# --secret=DRONE_SECRET_DOCKER_PASSWORD= \ +# --secret=KUBECONFIG=$(base64 -w 0 ~/.kube/config) +# ============================================================================= diff --git a/.gitea/README.md b/.gitea/README.md deleted file mode 100644 index 5b3253f..0000000 --- a/.gitea/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Gitea Actions 配置 - -## 目录结构 - -``` -.gitea/ -└── workflows/ - └── build.yaml # 构建流水线 -``` - -## 快速开始 - -### 1. 启用 Gitea Actions - -在 Gitea 管理面板中启用 Actions: - -```ini -[actions] -ENABLED = true -``` - -### 2. 注册 Runner - -在 Gitea 仓库设置中创建 Runner,获取 Token 后配置: - -```bash -# Docker 部署 -docker run -d \ - --name act-runner \ - -e GITEA_INSTANCE_URL=https://git.example.com \ - -e GITEA_RUNNER_TOKEN= \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v act-runner-data:/data \ - gitea/act-runner:latest - -# 或使用 Helm (见下方) -``` - -### 3. 添加 Secrets - -在 Gitea 仓库设置中添加: - -| Secret | 说明 | -|--------|------| -| `HARBOR_USERNAME` | Harbor 用户名 | -| `HARBOR_PASSWORD` | Harbor 密码 | - -## Helm 部署 Act Runner - -```bash -# values override -cat > act-runner-values.yaml << 'EOF' -actRunner: - enabled: true - replicaCount: 2 - capacity: 2 - labels: - - gitea - - docker - resources: - requests: - cpu: 500m - memory: 1Gi - limits: - cpu: 2000m - memory: 4Gi -EOF - -helm upgrade --install act-runner ./deploy \ - -f act-runner-values.yaml \ - -n c-----code \ - --create-namespace -``` - -## Workflow 说明 - -### build.yaml - -| Stage | 说明 | -|-------|------| -| `ci` | 格式化检查、Clippy lint、单元测试 | -| `docker` | x86_64 镜像构建并推送到 Harbor | -| `docker-arm64` | ARM64 镜像构建 (需 ARM64 runner) | -| `manifest` | 多架构镜像清单合并 | - -### Runner 标签 - -| Label | 说明 | -|-------|------| -| `gitea` | 默认 runner | -| `docker` | 支持 Docker-in-Docker | -| `arm64` | ARM64 架构 runner | - -### 触发条件 - -- Push 到 `main` 分支 -- Pull Request 到 `main` 分支 - -## 本地测试 - -使用 [nektos/act](https://github.com/nektos/act) 本地运行: - -```bash -# 安装 -curl -sSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sh - -# 运行 workflow -act -W .gitea/workflows/build.yaml -``` diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml deleted file mode 100644 index 53d086a..0000000 --- a/.gitea/workflows/build.yaml +++ /dev/null @@ -1,159 +0,0 @@ -name: Build and Publish - -on: - push: - branches: - - main - pull_request: - branches: - - main - -env: - REGISTRY: harbor.gitdata.me/gta_team - CARGO_TERM_COLOR: always - -jobs: - # ---- Lint & Test ---- - ci: - runs-on: gitea - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-action@stable - with: - toolchain: 1.94 - - - name: Cache Cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Check formatting - run: cargo fmt --check - - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Test - run: cargo test --workspace -- --test-threads=4 - - # ---- Docker Build (x86_64) ---- - docker: - needs: ci - if: github.event_name == 'push' - runs-on: gitea - strategy: - matrix: - service: - - app - - gitserver - - email-worker - - git-hook - - migrate - - operator - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Harbor - uses: docker/login-action@v3 - with: - registry: harbor.gitdata.me - username: ${{ secrets.HARBOR_USERNAME }} - password: ${{ secrets.HARBOR_PASSWORD }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ matrix.service }} - tags: | - type=sha,prefix=,format={{sha}} - type=raw,value=latest - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: docker/${{ matrix.service }}.Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - BUILD_TARGET=x86_64-unknown-linux-gnu - - # ---- ARM64 Build ---- - docker-arm64: - needs: ci - if: github.event_name == 'push' - runs-on: gitea-arm64 - strategy: - matrix: - service: - - app - - gitserver - - email-worker - - git-hook - - migrate - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Harbor - uses: docker/login-action@v3 - with: - registry: harbor.gitdata.me - username: ${{ secrets.HARBOR_USERNAME }} - password: ${{ secrets.HARBOR_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: docker/${{ matrix.service }}.Dockerfile - platforms: linux/arm64 - push: true - tags: | - ${{ env.REGISTRY }}/${{ matrix.service }}:latest-arm64 - ${{ env.REGISTRY }}/${{ matrix.service }}:sha-${{ github.sha }} - build-args: | - BUILD_TARGET=aarch64-unknown-linux-gnu - - # ---- Publish Manifest (multi-arch) ---- - manifest: - needs: [docker, docker-arm64] - if: github.event_name == 'push' - runs-on: gitea - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Login to Harbor - uses: docker/login-action@v3 - with: - registry: harbor.gitdata.me - username: ${{ secrets.HARBOR_USERNAME }} - password: ${{ secrets.HARBOR_PASSWORD }} - - - name: Create and push manifest - run: | - for service in app gitserver email-worker git-hook migrate; do - docker manifest create ${{ env.REGISTRY }}/$service:latest \ - ${{ env.REGISTRY }}/$service:latest \ - ${{ env.REGISTRY }}/$service:latest-arm64 - docker manifest push ${{ env.REGISTRY }}/$service:latest - done diff --git a/.gitignore b/.gitignore index 5e3ce22..372ffb7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .env .env.local dist +deploy/secrets.yaml .codex .qwen .opencode diff --git a/.idea/code.iml b/.idea/code.iml index 2961ea3..0c9ffc0 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -16,6 +16,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index e734d0f..e8b520c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,29 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.12.0" @@ -2269,6 +2292,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equator" version = "0.4.2" @@ -3239,6 +3285,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -3707,6 +3759,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -4373,6 +4449,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4997,6 +5085,50 @@ dependencies = [ "serde", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", + "unicase", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", + "unicase", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -5153,6 +5285,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -6795,6 +6936,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -7144,6 +7291,23 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static-server" +version = "0.2.9" +dependencies = [ + "actix-cors", + "actix-files", + "actix-web", + "anyhow", + "env_logger", + "mime", + "mime_guess2", + "serde", + "serde_json", + "slog", + "tokio", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -7909,6 +8073,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 3eb6ae7..d47bb32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "apps/gitserver", "apps/email", "apps/operator", + "apps/static", ] resolver = "3" diff --git a/apps/static/Cargo.toml b/apps/static/Cargo.toml new file mode 100644 index 0000000..fe1d52e --- /dev/null +++ b/apps/static/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "static-server" +version.workspace = true +edition.workspace = true + +[dependencies] +actix-web = { workspace = true } +actix-files = { workspace = true } +actix-cors = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } +serde_json = { workspace = true } +mime = { workspace = true } +mime_guess2 = { workspace = true } +slog = { workspace = true } +anyhow = { workspace = true } +env_logger = { workspace = true } + +[profile.release] +strip = true +lto = "thin" +opt-level = 3 diff --git a/apps/static/src/main.rs b/apps/static/src/main.rs new file mode 100644 index 0000000..c85d0a2 --- /dev/null +++ b/apps/static/src/main.rs @@ -0,0 +1,110 @@ +use actix_cors::Cors; +use actix_files::Files; +use actix_web::{http::header, middleware::Logger, web, App, HttpResponse, HttpServer}; +use std::path::PathBuf; + +/// Static file server for avatar, blob, and other static files +/// Serves files from /data/{type} directories + +#[derive(Clone)] +struct StaticConfig { + root: PathBuf, + cors_enabled: bool, +} + +impl StaticConfig { + fn from_env() -> Self { + let root = std::env::var("STATIC_ROOT").unwrap_or_else(|_| "/data".to_string()); + let cors = std::env::var("STATIC_CORS").unwrap_or_else(|_| "true".to_string()); + + Self { + root: PathBuf::from(root), + cors_enabled: cors == "true" || cors == "1", + } + } + + fn ensure_dir(&self, name: &str) -> PathBuf { + let dir = self.root.join(name); + if !dir.exists() { + std::fs::create_dir_all(&dir).ok(); + } + dir + } +} + +async fn health() -> HttpResponse { + HttpResponse::Ok().json(serde_json::json!({ + "status": "ok", + "service": "static-server" + })) +} + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + + let cfg = StaticConfig::from_env(); + let bind = std::env::var("STATIC_BIND").unwrap_or_else(|_| "0.0.0.0:8081".to_string()); + + println!("Static file server starting..."); + println!(" Root: {:?}", cfg.root); + println!(" Bind: {}", bind); + println!(" CORS: {}", if cfg.cors_enabled { "enabled" } else { "disabled" }); + + // Ensure all directories exist + for name in ["avatar", "blob", "media", "static"] { + let dir = cfg.ensure_dir(name); + println!(" {} dir: {:?}", name, dir); + } + + let root = cfg.root.clone(); + let cors_enabled = cfg.cors_enabled; + + HttpServer::new(move || { + let root = root.clone(); + + let cors = if cors_enabled { + Cors::default() + .allow_any_origin() + .allowed_methods(vec!["GET", "HEAD", "OPTIONS"]) + .allowed_headers(vec![ + header::AUTHORIZATION, + header::ACCEPT, + header::CONTENT_TYPE, + ]) + .max_age(3600) + } else { + Cors::permissive() + }; + + App::new() + .wrap(cors) + .wrap(Logger::default()) + .route("/health", web::get().to(health)) + .service( + Files::new("/avatar", root.join("avatar")) + .prefer_utf8(true) + .index_file("index.html"), + ) + .service( + Files::new("/blob", root.join("blob")) + .prefer_utf8(true) + .index_file("index.html"), + ) + .service( + Files::new("/media", root.join("media")) + .prefer_utf8(true) + .index_file("index.html"), + ) + .service( + Files::new("/static", root.join("static")) + .prefer_utf8(true) + .index_file("index.html"), + ) + }) + .bind(&bind)? + .run() + .await?; + + Ok(()) +} diff --git a/deploy/Chart.yaml b/deploy/Chart.yaml index 3f14379..51063ed 100644 --- a/deploy/Chart.yaml +++ b/deploy/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: c-----code +name: gitdata description: Self-hosted GitHub + Slack alternative platform type: application version: 0.1.0 @@ -9,5 +9,6 @@ keywords: - collaboration - self-hosted maintainers: - - name: C-----code Team + - name: gitdata Team email: team@c.dev + diff --git a/deploy/configmap.yaml b/deploy/configmap.yaml new file mode 100644 index 0000000..aa77919 --- /dev/null +++ b/deploy/configmap.yaml @@ -0,0 +1,66 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: gitdata-config + namespace: gitdataai + labels: + app.kubernetes.io/name: gitdata + app.kubernetes.io/instance: gitdata + app.kubernetes.io/version: "0.1.0" +data: + # App Info + APP_NAME: "gitdata" + APP_VERSION: "0.1.0" + APP_STATIC_DOMAIN: "https://static.gitdata.ai" + APP_MEDIA_DOMAIN: "https://static.gitdata.ai" + APP_GIT_HTTP_DOMAIN: "https://git.gitdata.ai" + APP_AVATAR_PATH: "/data/avatar" + APP_REPOS_ROOT: "/data/repos" + APP_DATABASE_URL: "postgresql://gitdataai:gitdataai123@cnpg-cluster-rw.cnpg:5432/gitdataai?sslmode=disable" + APP_DATABASE_MAX_CONNECTIONS: "100" + APP_DATABASE_MIN_CONNECTIONS: "5" + APP_DATABASE_IDLE_TIMEOUT: "600" + APP_DATABASE_MAX_LIFETIME: "3600" + APP_DATABASE_CONNECTION_TIMEOUT: "30" + APP_DATABASE_SCHEMA_SEARCH_PATH: "public" + APP_DATABASE_HEALTH_CHECK_INTERVAL: "30" + APP_DATABASE_RETRY_ATTEMPTS: "3" + APP_DATABASE_RETRY_DELAY: "1" + APP_REDIS_URL: "redis://default:redis123@valkey-cluster.valkey-cluster.svc.cluster.local:6379" + APP_REDIS_POOL_SIZE: "16" + APP_REDIS_CONNECT_TIMEOUT: "5" + APP_REDIS_ACQUIRE_TIMEOUT: "1" + NATS_URL: "nats://nats-client.nats.svc.cluster.local:4222" + HOOK_POOL_MAX_CONCURRENT: "100" + HOOK_POOL_CPU_THRESHOLD: "80" + HOOK_POOL_REDIS_LIST_PREFIX: "{hook}" + HOOK_POOL_REDIS_LOG_CHANNEL: "hook:logs" + HOOK_POOL_REDIS_BLOCK_TIMEOUT: "5" + HOOK_POOL_REDIS_MAX_RETRIES: "3" + APP_LOG_LEVEL: "info" + APP_LOG_FORMAT: "json" + APP_LOG_FILE_ENABLED: "false" + APP_LOG_FILE_PATH: "/var/log/gitdata/app.log" + APP_LOG_FILE_ROTATION: "daily" + APP_LOG_FILE_MAX_FILES: "7" + APP_LOG_FILE_MAX_SIZE: "100" + APP_OTEL_ENABLED: "false" + APP_OTEL_ENDPOINT: "" + APP_OTEL_SERVICE_NAME: "gitdata" + APP_OTEL_SERVICE_VERSION: "0.1.0" + APP_SMTP_HOST: "smtp.exmail.qq.com" + APP_SMTP_PORT: "465" + APP_SMTP_USERNAME: "gitdata-bot@gitdata.ai" + APP_SMTP_PASSWORD: "Dha88YLtNicGUj4G" + APP_SMTP_FROM: "gitdata-bot@gitdata.ai" + APP_SMTP_TLS: "true" + APP_SMTP_TIMEOUT: "30" + APP_SSH_DOMAIN: "git.gitdata.ai" + APP_SSH_PORT: "22" + APP_AI_BASIC_URL: "https://axonhub.gitdata.me/v1" + APP_AI_API_KEY: "ah-629e2cfb5a58f6b7053cd890c6bd6c0de4537fa2f816ccc984090d022a50262e" + APP_EMBED_MODEL_BASE_URL: "https://api.siliconflow.cn/v1" + APP_EMBED_MODEL_API_KEY: "sk-xzehcnpedeijpgyiitcalzhlcdrmyujezubudvpacukyvzmo" + APP_EMBED_MODEL_NAME: "BAAI/bge-m3" + APP_EMBED_MODEL_DIMENSIONS: "1024" + APP_QDRANT_URL: "http://qdrant.qdrant.svc.cluster.local:6333" diff --git a/deploy/secrets.yaml.example b/deploy/secrets.yaml.example new file mode 100644 index 0000000..f09a348 --- /dev/null +++ b/deploy/secrets.yaml.example @@ -0,0 +1,71 @@ +# ============================================================================= +# Secrets Configuration - 示例文件 (外部 Secret Manager) +# ============================================================================= +# 生产环境使用 External Secrets Operator (ESO) 从 Vault/AWS SM/Azure KeyVault 同步 +# https://external-secrets.io/ +# +# 密钥管理器需要预先配置 SecretStore,例如 Vault: +# apiVersion: external-secrets.io/v1beta1 +# kind: SecretStore +# metadata: +# name: vault-backend +# namespace: gitdataai +# spec: +# vault: +# server: "https://vault.example.com" +# pathPrefix: /secret +# auth: +# kubernetes: +# mountPath: kubernetes +# role: gitdata +# +# 密钥路径约定: +# gitdata/database → { url: "postgresql://..." } +# gitdata/redis → { url: "redis://..." } +# gitdata/qdrant → { apiKey: "..." } +# ============================================================================= + +# ----------------------------------------------------------------------------- +# External Secrets 配置 +# ----------------------------------------------------------------------------- +externalSecrets: + # SecretStore / ClusterSecretStore 名称 (集群预先配置) + storeName: "vault-backend" + storeKind: "SecretStore" # 或 ClusterSecretStore (跨 namespace) + + # Vault 密钥路径 + databaseKey: "gitdata/database" + redisKey: "gitdata/redis" + qdrantKey: "gitdata/qdrant" + +# ----------------------------------------------------------------------------- +# Secret 名称 (与 ExternalSecret target.name 对应) +# ----------------------------------------------------------------------------- +database: + existingSecret: "gitdata-database-secret" + secretKeys: + url: APP_DATABASE_URL + +redis: + existingSecret: "gitdata-redis-secret" + secretKeys: + url: APP_REDIS_URL + +# ----------------------------------------------------------------------------- +# Qdrant (启用 AI 功能时需要) +# ----------------------------------------------------------------------------- +qdrant: + enabled: true + url: "http://qdrant.qdrant.svc.cluster.local:6333" + existingSecret: "gitdata-qdrant-secret" + secretKeys: + apiKey: APP_QDRANT_API_KEY + +# ----------------------------------------------------------------------------- +# 本地开发 / CI/CD 快速部署 (secrets.create: true) +# 生产环境请使用 externalSecrets 配置 +# ----------------------------------------------------------------------------- +# secrets: +# create: true +# databaseUrl: "postgresql://..." +# redisUrl: "redis://..." diff --git a/deploy/templates/NOTES.txt b/deploy/templates/NOTES.txt index c044cee..a1f5247 100644 --- a/deploy/templates/NOTES.txt +++ b/deploy/templates/NOTES.txt @@ -26,7 +26,7 @@ Or set .Values.secrets in values.yaml. 🔄 To run database migrations: - helm upgrade {{ .Release.Name }} ./c-----code -n {{ .Release.Namespace }} \ + helm upgrade {{ .Release.Name }} ./gitdata -n {{ .Release.Namespace }} \ --set migrate.enabled=true 📖 Useful commands: diff --git a/deploy/templates/_helpers.tpl b/deploy/templates/_helpers.tpl index 4f7e72b..7ef1fdd 100644 --- a/deploy/templates/_helpers.tpl +++ b/deploy/templates/_helpers.tpl @@ -2,29 +2,29 @@ Common helpers ============================================================================= */}} -{{- define "c-----code.fullname" -}} +{{- define "gitdata.fullname" -}} {{- .Release.Name -}} {{- end -}} -{{- define "c-----code.namespace" -}} +{{- define "gitdata.namespace" -}} {{- .Values.namespace | default .Release.Namespace -}} {{- end -}} -{{- define "c-----code.image" -}} +{{- define "gitdata.image" -}} {{- $registry := .Values.image.registry -}} {{- $pullPolicy := .Values.image.pullPolicy -}} {{- printf "%s/%s:%s" $registry .image.repository .image.tag -}} {{- end -}} {{/* Inject image pull policy into sub-chart image dict */}} -{{- define "c-----code.mergeImage" -}} +{{- define "gitdata.mergeImage" -}} {{- $merged := dict "pullPolicy" $.Values.image.pullPolicy -}} {{- $merged = merge $merged .image -}} {{- printf "%s/%s:%s" $.Values.image.registry $merged.repository $merged.tag -}} {{- end -}} {{/* Build a key-value env var list, optionally reading from a Secret */}} -{{- define "c-----code.envFromSecret" -}} +{{- define "gitdata.envFromSecret" -}} {{- $secretName := .existingSecret -}} {{- $keys := .secretKeys -}} {{- $result := list -}} @@ -36,7 +36,7 @@ {{- end -}} {{/* Merge two env lists (extra env over auto-injected) */}} -{{- define "c-----code.mergeEnv" -}} +{{- define "gitdata.mergeEnv" -}} {{- $auto := .auto -}} {{- $extra := .extra | default list -}} {{- $merged := append $auto $extra | toJson | fromJson -}} diff --git a/deploy/templates/act-runner-deployment.yaml b/deploy/templates/act-runner-deployment.yaml deleted file mode 100644 index 783a068..0000000 --- a/deploy/templates/act-runner-deployment.yaml +++ /dev/null @@ -1,158 +0,0 @@ -{{- if .Values.actRunner.enabled -}} -{{- $fullName := include "c-----code.fullname" . -}} -{{- $ns := include "c-----code.namespace" . -}} -{{- $runner := .Values.actRunner -}} - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ $fullName }}-act-runner - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/version: {{ .Chart.AppVersion }} -spec: - replicas: {{ $runner.replicaCount }} - selector: - matchLabels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} - template: - metadata: - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} - spec: - serviceAccountName: {{ $fullName }}-act-runner - containers: - - name: runner - image: "{{ .Values.image.registry }}/act-runner:{{ $runner.image.tag }}" - imagePullPolicy: {{ $runner.image.pullPolicy | default .Values.image.pullPolicy }} - args: - - --config - - /runner/config.yaml - - --replaces-self - env: - - name: CONFIG_FILE - value: /runner/config.yaml - {{- if .Values.nats.enabled }} - - name: HOOK_POOL_REDIS_LIST_PREFIX - value: "{hook}" - - name: HOOK_POOL_REDIS_LOG_CHANNEL - value: "hook:logs" - {{- end }} - {{- range $runner.env }} - - name: {{ .name }} - value: {{ .value | quote }} - {{- end }} - volumeMounts: - - name: runner-config - mountPath: /runner - readOnly: true - - name: docker-socket - mountPath: /var/run/docker.sock - resources: - {{- toYaml $runner.resources | nindent 10 }} - volumes: - - name: runner-config - configMap: - name: {{ $fullName }}-act-runner-config - - name: docker-socket - hostPath: - path: /var/run/docker.sock - type: Socket - {{- with $runner.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $runner.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with $runner.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ $fullName }}-act-runner-config - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} -data: - config.yaml: | - # Act Runner Configuration - # Generated by Helm values - log: - level: {{ $runner.logLevel | default "info" }} - runner: - capacity: {{ $runner.capacity | default 2 }} - labels: - {{- range $runner.labels }} - - {{ . }} - {{- end }} - cache: - {{- if $runner.cache.enabled }} - enabled: true - dir: {{ $runner.cache.dir | default "/tmp/actions-cache" }} - {{- else }} - enabled: false - {{- end }} - docker: - host: unix:///var/run/docker.sock - ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ $fullName }}-act-runner - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: {{ $fullName }}-act-runner - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} -rules: - - apiGroups: [""] - resources: ["pods", "pods/log"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "list", "create", "update", "patch"] - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: {{ $fullName }}-act-runner - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-act-runner - app.kubernetes.io/instance: {{ .Release.Name }} -subjects: - - kind: ServiceAccount - name: {{ $fullName }}-act-runner - namespace: {{ $ns }} -roleRef: - kind: Role - name: {{ $fullName }}-act-runner - apiGroup: rbac.authorization.k8s.io - -{{- end }} diff --git a/deploy/templates/app-deployment.yaml b/deploy/templates/app-deployment.yaml index d2eb191..3688c55 100644 --- a/deploy/templates/app-deployment.yaml +++ b/deploy/templates/app-deployment.yaml @@ -2,24 +2,25 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "c-----code.fullname" . }}-app - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-app + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} spec: replicas: {{ .Values.app.replicaCount }} selector: matchLabels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} spec: + terminationGracePeriodSeconds: 30 containers: - name: app image: "{{ .Values.image.registry }}/{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}" @@ -31,34 +32,52 @@ spec: env: - name: APP_DATABASE_URL valueFrom: - secretKeyRef: - name: {{ .Values.database.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.database.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_DATABASE_URL optional: true - name: APP_REDIS_URL valueFrom: - secretKeyRef: - name: {{ .Values.redis.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.redis.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_REDIS_URL + optional: true + - name: NATS_URL + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: NATS_URL optional: true - {{- if .Values.nats.enabled }} - name: HOOK_POOL_REDIS_LIST_PREFIX - value: "{hook}" + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: HOOK_POOL_REDIS_LIST_PREFIX + optional: true - name: HOOK_POOL_REDIS_LOG_CHANNEL - value: "hook:logs" - {{- end }} - {{- if .Values.qdrant.enabled }} + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: HOOK_POOL_REDIS_LOG_CHANNEL + optional: true - name: APP_QDRANT_URL - value: {{ .Values.qdrant.url }} - {{- if and .Values.qdrant.existingSecret .Values.qdrant.secretKeys.apiKey }} + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_QDRANT_URL + optional: true - name: APP_QDRANT_API_KEY valueFrom: - secretKeyRef: - name: {{ .Values.qdrant.existingSecret }} - key: {{ .Values.qdrant.secretKeys.apiKey }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_QDRANT_API_KEY + optional: true + - name: APP_AVATAR_PATH + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_AVATAR_PATH optional: true - {{- end }} - {{- end }} {{- range .Values.app.env }} - name: {{ .name }} value: {{ .value | quote }} @@ -75,8 +94,28 @@ spec: port: {{ .Values.app.readinessProbe.port }} initialDelaySeconds: {{ .Values.app.readinessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.app.readinessProbe.periodSeconds }} + {{- if .Values.app.startupProbe }} + startupProbe: + httpGet: + path: {{ .Values.app.startupProbe.path }} + port: {{ .Values.app.startupProbe.port }} + initialDelaySeconds: {{ .Values.app.startupProbe.initialDelaySeconds | default 0 }} + periodSeconds: {{ .Values.app.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.app.startupProbe.failureThreshold }} + {{- end }} resources: {{- toYaml .Values.app.resources | nindent 10 }} + {{- if .Values.storage.enabled }} + volumeMounts: + - name: shared-data + mountPath: /data + {{- end }} + {{- if .Values.storage.enabled }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ include "gitdata.fullname" . }}-shared-data + {{- end }} {{- with .Values.app.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -93,10 +132,10 @@ spec: apiVersion: v1 kind: Service metadata: - name: {{ include "c-----code.fullname" . }}-app - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-app + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} spec: type: {{ .Values.app.service.type }} @@ -106,6 +145,6 @@ spec: protocol: TCP name: http selector: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} diff --git a/deploy/templates/configmap.yaml b/deploy/templates/configmap.yaml index 02add79..1fb00b6 100644 --- a/deploy/templates/configmap.yaml +++ b/deploy/templates/configmap.yaml @@ -1,15 +1,51 @@ +{{- /* Application configuration - non-sensitive values */ -}} apiVersion: v1 kind: ConfigMap metadata: - name: {{ include "c-----code.fullname" . }}-config - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-config + namespace: {{ include "gitdata.namespace" . }} labels: app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} data: -{{- if .Values.app.config }} -{{- range $key, $value := .Values.app.config }} - {{ $key }}: {{ $value | quote }} -{{- end }} -{{- end }} + APP_NAME: {{ .Values.app.name | default "gitdata" | quote }} + APP_VERSION: {{ .Chart.AppVersion | quote }} + APP_STATIC_DOMAIN: {{ .Values.config.staticDomain | default "" | quote }} + APP_MEDIA_DOMAIN: {{ .Values.config.mediaDomain | default "" | quote }} + APP_GIT_HTTP_DOMAIN: {{ .Values.config.gitHttpDomain | default "" | quote }} + APP_AVATAR_PATH: {{ .Values.config.avatarPath | default "/data/avatar" | quote }} + APP_REPOS_ROOT: {{ .Values.config.reposRoot | default "/data/repos" | quote }} + APP_LOG_LEVEL: {{ .Values.config.logLevel | default "info" | quote }} + APP_LOG_FORMAT: {{ .Values.config.logFormat | default "json" | quote }} + APP_LOG_FILE_ENABLED: {{ .Values.config.logFileEnabled | default "false" | quote }} + APP_LOG_FILE_PATH: {{ .Values.config.logFilePath | default "/var/log/gitdata/app.log" | quote }} + APP_LOG_FILE_ROTATION: {{ .Values.config.logFileRotation | default "daily" | quote }} + APP_LOG_FILE_MAX_FILES: {{ .Values.config.logFileMaxFiles | default "7" | quote }} + APP_LOG_FILE_MAX_SIZE: {{ .Values.config.logFileMaxSize | default "100" | quote }} + APP_OTEL_ENABLED: {{ .Values.config.otelEnabled | default "false" | quote }} + APP_OTEL_ENDPOINT: {{ .Values.config.otelEndpoint | default "" | quote }} + APP_OTEL_SERVICE_NAME: {{ .Values.config.otelServiceName | default "gitdata" | quote }} + APP_OTEL_SERVICE_VERSION: {{ .Chart.AppVersion | quote }} + APP_DATABASE_MAX_CONNECTIONS: {{ .Values.config.databaseMaxConnections | default "100" | quote }} + APP_DATABASE_MIN_CONNECTIONS: {{ .Values.config.databaseMinConnections | default "5" | quote }} + APP_DATABASE_IDLE_TIMEOUT: {{ .Values.config.databaseIdleTimeout | default "600" | quote }} + APP_DATABASE_MAX_LIFETIME: {{ .Values.config.databaseMaxLifetime | default "3600" | quote }} + APP_DATABASE_CONNECTION_TIMEOUT: {{ .Values.config.databaseConnectionTimeout | default "30" | quote }} + APP_DATABASE_SCHEMA_SEARCH_PATH: {{ .Values.config.databaseSchemaSearchPath | default "public" | quote }} + APP_DATABASE_HEALTH_CHECK_INTERVAL: {{ .Values.config.databaseHealthCheckInterval | default "30" | quote }} + APP_DATABASE_RETRY_ATTEMPTS: {{ .Values.config.databaseRetryAttempts | default "3" | quote }} + APP_DATABASE_RETRY_DELAY: {{ .Values.config.databaseRetryDelay | default "1" | quote }} + APP_REDIS_POOL_SIZE: {{ .Values.config.redisPoolSize | default "16" | quote }} + APP_REDIS_CONNECT_TIMEOUT: {{ .Values.config.redisConnectTimeout | default "5" | quote }} + APP_REDIS_ACQUIRE_TIMEOUT: {{ .Values.config.redisAcquireTimeout | default "1" | quote }} + HOOK_POOL_MAX_CONCURRENT: {{ .Values.config.hookPoolMaxConcurrent | default "100" | quote }} + HOOK_POOL_CPU_THRESHOLD: {{ .Values.config.hookPoolCpuThreshold | default "80" | quote }} + HOOK_POOL_REDIS_LIST_PREFIX: {{ .Values.config.hookPoolRedisListPrefix | default "{hook}" | quote }} + HOOK_POOL_REDIS_LOG_CHANNEL: {{ .Values.config.hookPoolRedisLogChannel | default "hook:logs" | quote }} + HOOK_POOL_REDIS_BLOCK_TIMEOUT: {{ .Values.config.hookPoolRedisBlockTimeout | default "5" | quote }} + HOOK_POOL_REDIS_MAX_RETRIES: {{ .Values.config.hookPoolRedisMaxRetries | default "3" | quote }} + APP_SMTP_PORT: {{ .Values.config.smtpPort | default "587" | quote }} + APP_SMTP_TLS: {{ .Values.config.smtpTls | default "true" | quote }} + APP_SMTP_TIMEOUT: {{ .Values.config.smtpTimeout | default "30" | quote }} + APP_SSH_PORT: {{ .Values.config.sshPort | default "22" | quote }} diff --git a/deploy/templates/email-worker-deployment.yaml b/deploy/templates/email-worker-deployment.yaml index 173c4a0..436568d 100644 --- a/deploy/templates/email-worker-deployment.yaml +++ b/deploy/templates/email-worker-deployment.yaml @@ -2,22 +2,22 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "c-----code.fullname" . }}-email-worker - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-email-worker + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-email-worker + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-email-worker + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-email-worker + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker app.kubernetes.io/instance: {{ .Release.Name }} spec: containers: @@ -27,15 +27,15 @@ spec: env: - name: APP_DATABASE_URL valueFrom: - secretKeyRef: - name: {{ .Values.database.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.database.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_DATABASE_URL optional: true - name: APP_REDIS_URL valueFrom: - secretKeyRef: - name: {{ .Values.redis.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.redis.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_REDIS_URL optional: true {{- range .Values.emailWorker.env }} - name: {{ .name }} @@ -43,6 +43,30 @@ spec: {{- end }} resources: {{- toYaml .Values.emailWorker.resources | nindent 10 }} + {{- if .Values.emailWorker.livenessProbe }} + livenessProbe: + exec: + command: + {{- range .Values.emailWorker.livenessProbe.exec.command }} + - {{ . | quote }} + {{- end }} + initialDelaySeconds: {{ .Values.emailWorker.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.emailWorker.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.emailWorker.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.emailWorker.livenessProbe.failureThreshold }} + {{- end }} + {{- if .Values.emailWorker.readinessProbe }} + readinessProbe: + exec: + command: + {{- range .Values.emailWorker.readinessProbe.exec.command }} + - {{ . | quote }} + {{- end }} + initialDelaySeconds: {{ .Values.emailWorker.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.emailWorker.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.emailWorker.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.emailWorker.readinessProbe.failureThreshold }} + {{- end }} {{- with .Values.emailWorker.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -55,4 +79,10 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} + {{- if .Values.storage.enabled }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ include "gitdata.fullname" . }}-shared-data + {{- end }} {{- end }} diff --git a/deploy/templates/external-secrets.yaml b/deploy/templates/external-secrets.yaml new file mode 100644 index 0000000..b05876d --- /dev/null +++ b/deploy/templates/external-secrets.yaml @@ -0,0 +1,76 @@ +{{- /* + External Secrets - 从外部 Secret Manager 同步密钥 + 需要集群安装: External Secrets Operator (ESO) + https://external-secrets.io/ +*/ -}} + +{{- $ns := include "gitdata.namespace" . -}} + +{{- /* Database Secret */ -}} +{{- if .Values.database.existingSecret -}} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: {{ .Values.database.existingSecret }} + namespace: {{ $ns }} +spec: + refreshInterval: 1h + secretStoreRef: + name: {{ .Values.externalSecrets.storeName | default "vault-backend" }} + kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }} + target: + name: {{ .Values.database.existingSecret }} + creationPolicy: Owner + data: + - secretKey: {{ .Values.database.secretKeys.url }} + remoteRef: + key: {{ .Values.externalSecrets.databaseKey | default "gitdata/database" }} + property: url +{{- end }} + +{{- /* Redis Secret */ -}} +{{- if .Values.redis.existingSecret -}} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: {{ .Values.redis.existingSecret }} + namespace: {{ $ns }} +spec: + refreshInterval: 1h + secretStoreRef: + name: {{ .Values.externalSecrets.storeName | default "vault-backend" }} + kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }} + target: + name: {{ .Values.redis.existingSecret }} + creationPolicy: Owner + data: + - secretKey: {{ .Values.redis.secretKeys.url }} + remoteRef: + key: {{ .Values.externalSecrets.redisKey | default "gitdata/redis" }} + property: url +{{- end }} + +{{- /* Qdrant Secret */ -}} +{{- if and .Values.qdrant.enabled .Values.qdrant.existingSecret -}} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: {{ .Values.qdrant.existingSecret }} + namespace: {{ $ns }} +spec: + refreshInterval: 1h + secretStoreRef: + name: {{ .Values.externalSecrets.storeName | default "vault-backend" }} + kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }} + target: + name: {{ .Values.qdrant.existingSecret }} + creationPolicy: Owner + data: + - secretKey: {{ .Values.qdrant.secretKeys.apiKey }} + remoteRef: + key: {{ .Values.externalSecrets.qdrantKey | default "gitdata/qdrant" }} + property: apiKey +{{- end }} diff --git a/deploy/templates/frontend-deployment.yaml b/deploy/templates/frontend-deployment.yaml new file mode 100644 index 0000000..149d216 --- /dev/null +++ b/deploy/templates/frontend-deployment.yaml @@ -0,0 +1,82 @@ +{{- if .Values.frontend.enabled -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} +{{- $svc := .Values.frontend -}} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }}-frontend + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/version: {{ .Chart.AppVersion }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: frontend + image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}" + imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + resources: + {{- toYaml $svc.resources | nindent 10 }} + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-frontend + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + type: {{ $svc.service.type }} + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP + selector: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/templates/frontend-ingress.yaml b/deploy/templates/frontend-ingress.yaml new file mode 100644 index 0000000..7259024 --- /dev/null +++ b/deploy/templates/frontend-ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.frontend.ingress.enabled -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} +{{- $ing := .Values.frontend.ingress -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }}-frontend + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-frontend + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }} +{{- if $ing.annotations }} +{{ toYaml $ing.annotations | indent 4 }} +{{- end }} +{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }} +{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }} + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +{{- end }} +{{- end }} +spec: +{{- if $ing.className }} + ingressClassName: {{ $ing.className }} +{{- end }} +{{- if $ing.tls }} + tls: +{{- range $ing.tls }} + - hosts: +{{- range .hosts }} + - {{ . | quote }} +{{- end }} + secretName: {{ .secretName }} +{{- end }} +{{- else }} + tls: +{{- range $ing.hosts }} + - hosts: + - {{ .host | quote }} + secretName: {{ $fullName }}-frontend-tls +{{- end }} +{{- end }} + rules: +{{- range $ing.hosts }} + - host: {{ .host | quote }} + http: + paths: +{{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }}-frontend + port: + number: 80 +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/templates/git-hook-deployment.yaml b/deploy/templates/git-hook-deployment.yaml index 17107ce..0a109e7 100644 --- a/deploy/templates/git-hook-deployment.yaml +++ b/deploy/templates/git-hook-deployment.yaml @@ -2,22 +2,22 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "c-----code.fullname" . }}-git-hook - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-git-hook + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-git-hook + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} spec: replicas: {{ .Values.gitHook.replicaCount | default 2 }} selector: matchLabels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-git-hook + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-git-hook + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook app.kubernetes.io/instance: {{ .Release.Name }} spec: containers: @@ -27,28 +27,58 @@ spec: env: - name: APP_DATABASE_URL valueFrom: - secretKeyRef: - name: {{ .Values.database.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.database.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_DATABASE_URL optional: true - name: APP_REDIS_URL valueFrom: - secretKeyRef: - name: {{ .Values.redis.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.redis.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_REDIS_URL optional: true - {{- if .Values.nats.enabled }} - name: HOOK_POOL_REDIS_LIST_PREFIX - value: "{hook}" + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: HOOK_POOL_REDIS_LIST_PREFIX + optional: true - name: HOOK_POOL_REDIS_LOG_CHANNEL - value: "hook:logs" - {{- end }} + valueFrom: + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: HOOK_POOL_REDIS_LOG_CHANNEL + optional: true {{- range .Values.gitHook.env }} - name: {{ .name }} value: {{ .value | quote }} {{- end }} resources: {{- toYaml .Values.gitHook.resources | nindent 10 }} + {{- if .Values.gitHook.livenessProbe }} + livenessProbe: + exec: + command: + {{- range .Values.gitHook.livenessProbe.exec.command }} + - {{ . | quote }} + {{- end }} + initialDelaySeconds: {{ .Values.gitHook.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.gitHook.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.gitHook.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.gitHook.livenessProbe.failureThreshold }} + {{- end }} + {{- if .Values.gitHook.readinessProbe }} + readinessProbe: + exec: + command: + {{- range .Values.gitHook.readinessProbe.exec.command }} + - {{ . | quote }} + {{- end }} + initialDelaySeconds: {{ .Values.gitHook.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.gitHook.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.gitHook.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.gitHook.readinessProbe.failureThreshold }} + {{- end }} {{- with .Values.gitHook.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -61,4 +91,10 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} + {{- if .Values.storage.enabled }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ include "gitdata.fullname" . }}-shared-data + {{- end }} {{- end }} diff --git a/deploy/templates/gitserver-deployment.yaml b/deploy/templates/gitserver-deployment.yaml index 388f381..c8b3a43 100644 --- a/deploy/templates/gitserver-deployment.yaml +++ b/deploy/templates/gitserver-deployment.yaml @@ -1,29 +1,9 @@ {{- if .Values.gitserver.enabled -}} -{{- $fullName := include "c-----code.fullname" . -}} -{{- $ns := include "c-----code.namespace" . -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} {{- $svc := .Values.gitserver -}} -{{/* PersistentVolumeClaim for git repositories */}} -{{- if $svc.persistence.enabled }} ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ $fullName }}-repos - namespace: {{ $ns }} - labels: - app.kubernetes.io/name: {{ $fullName }}-gitserver - app.kubernetes.io/instance: {{ $.Release.Name }} -spec: - accessModes: - - {{ $svc.persistence.accessMode | default "ReadWriteOnce" }} - resources: - requests: - storage: {{ $svc.persistence.size }} - {{- if $svc.persistence.storageClass }} - storageClassName: {{ $svc.persistence.storageClass }} - {{- end }} -{{- end }} +{{/* Uses shared PVC defined in storage.yaml */}} --- apiVersion: apps/v1 @@ -56,47 +36,70 @@ spec: containerPort: {{ $svc.service.http.port }} protocol: TCP - name: ssh - containerPort: {{ $svc.ssh.port }} + containerPort: {{ $svc.service.ssh.port }} protocol: TCP env: - name: APP_REPOS_ROOT value: /data/repos - name: APP_DATABASE_URL valueFrom: - secretKeyRef: - name: {{ $.Values.database.existingSecret | default (printf "%s-secrets" $fullName) }} - key: {{ $.Values.database.secretKeys.url }} + configMapKeyRef: + name: {{ $fullName }}-config + key: APP_DATABASE_URL optional: true - name: APP_REDIS_URL valueFrom: - secretKeyRef: - name: {{ $.Values.redis.existingSecret | default (printf "%s-secrets" $fullName) }} - key: {{ $.Values.redis.secretKeys.url }} + configMapKeyRef: + name: {{ $fullName }}-config + key: APP_REDIS_URL optional: true - {{- if $svc.ssh.domain }} - name: APP_SSH_DOMAIN - value: {{ $svc.ssh.domain }} - {{- end }} - {{- if $svc.ssh.port }} + valueFrom: + configMapKeyRef: + name: {{ $fullName }}-config + key: APP_SSH_DOMAIN + optional: true - name: APP_SSH_PORT - value: {{ $svc.ssh.port | quote }} - {{- end }} + valueFrom: + configMapKeyRef: + name: {{ $fullName }}-config + key: APP_SSH_PORT + optional: true {{- range $svc.env }} - name: {{ .name }} value: {{ .value | quote }} {{- end }} resources: {{- toYaml $svc.resources | nindent 10 }} + {{- if $svc.livenessProbe }} + livenessProbe: + tcpSocket: + port: {{ $svc.livenessProbe.tcpSocket.port }} + initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + timeoutSeconds: {{ $svc.livenessProbe.timeoutSeconds }} + failureThreshold: {{ $svc.livenessProbe.failureThreshold }} + {{- end }} + {{- if $svc.readinessProbe }} + readinessProbe: + tcpSocket: + port: {{ $svc.readinessProbe.tcpSocket.port }} + initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + timeoutSeconds: {{ $svc.readinessProbe.timeoutSeconds }} + failureThreshold: {{ $svc.readinessProbe.failureThreshold }} + {{- end }} volumeMounts: - {{- if $svc.persistence.enabled }} - - name: repos + {{- if and $svc.persistence.enabled $.Values.storage.enabled }} + - name: shared-data mountPath: /data/repos + subPath: repos/ {{- end }} volumes: - {{- if $svc.persistence.enabled }} - - name: repos + {{- if and $svc.persistence.enabled $.Values.storage.enabled }} + - name: shared-data persistentVolumeClaim: - claimName: {{ $fullName }}-repos + claimName: {{ $fullName }}-shared-data {{- end }} {{- with $svc.nodeSelector }} nodeSelector: @@ -142,19 +145,40 @@ metadata: labels: app.kubernetes.io/name: {{ $fullName }}-gitserver app.kubernetes.io/instance: {{ $.Release.Name }} + {{- if $svc.service.ssh.loadBalancerSourceRanges }} + annotations: + metallb.universe.tf/loadBalancerIPs: {{ $svc.service.ssh.loadBalancerIP | default "" }} + {{- end }} spec: type: {{ $svc.service.ssh.type }} - {{- if eq $svc.service.ssh.type "NodePort" }} + {{- if eq $svc.service.ssh.type "LoadBalancer" }} ports: - name: ssh - port: {{ $svc.ssh.port }} + port: {{ $svc.service.ssh.port }} targetPort: ssh - nodePort: {{ $svc.service.ssh.nodePort }} + protocol: TCP + {{- if $svc.service.ssh.loadBalancerIP }} + loadBalancerIP: {{ $svc.service.ssh.loadBalancerIP }} + {{- end }} + {{- if $svc.service.ssh.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- range $svc.service.ssh.loadBalancerSourceRanges }} + - {{ . }} + {{- end }} + {{- end }} + {{- else if eq $svc.service.ssh.type "NodePort" }} + ports: + - name: ssh + port: {{ $svc.service.ssh.port }} + targetPort: ssh + nodePort: {{ $svc.service.ssh.nodePort | default 30222 }} + protocol: TCP {{- else }} ports: - name: ssh - port: {{ $svc.ssh.port }} + port: {{ $svc.service.ssh.port }} targetPort: ssh + protocol: TCP {{- end }} selector: app.kubernetes.io/name: {{ $fullName }}-gitserver diff --git a/deploy/templates/gitserver-ingress.yaml b/deploy/templates/gitserver-ingress.yaml new file mode 100644 index 0000000..1b87461 --- /dev/null +++ b/deploy/templates/gitserver-ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.gitserver.ingress.enabled -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} +{{- $ing := .Values.gitserver.ingress -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }}-gitserver-http + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-gitserver + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }} +{{- if $ing.annotations }} +{{ toYaml $ing.annotations | indent 4 }} +{{- end }} +{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }} +{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }} + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +{{- end }} +{{- end }} +spec: +{{- if $ing.className }} + ingressClassName: {{ $ing.className }} +{{- end }} +{{- if $ing.tls }} + tls: +{{- range $ing.tls }} + - hosts: +{{- range .hosts }} + - {{ . | quote }} +{{- end }} + secretName: {{ .secretName }} +{{- end }} +{{- else }} + tls: +{{- range $ing.hosts }} + - hosts: + - {{ .host | quote }} + secretName: {{ $fullName }}-gitserver-tls +{{- end }} +{{- end }} + rules: +{{- range $ing.hosts }} + - host: {{ .host | quote }} + http: + paths: +{{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }}-gitserver-http + port: + number: {{ $.Values.gitserver.service.http.port }} +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/templates/ingress.yaml b/deploy/templates/ingress.yaml index 6031323..76589b1 100644 --- a/deploy/templates/ingress.yaml +++ b/deploy/templates/ingress.yaml @@ -1,46 +1,61 @@ {{- if .Values.app.ingress.enabled -}} -{{- $svcName := printf "%s-app" (include "c-----code.fullname" .) -}} -{{- $ns := include "c-----code.namespace" . -}} +{{- $svcName := printf "%s-app" (include "gitdata.fullname" .) -}} +{{- $ns := include "gitdata.namespace" . -}} {{- $ing := .Values.app.ingress -}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: {{ include "c-----code.fullname" . }}-ingress + name: {{ include "gitdata.fullname" . }}-ingress namespace: {{ $ns }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-app + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app app.kubernetes.io/instance: {{ .Release.Name }} - {{- with $ing.annotations }} annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if $ing.className }} - ingressClassName: {{ $ing.className }} - {{- end }} - {{- if $ing.tls }} - tls: - {{- range $ing.tls }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} - rules: - {{- range $ing.hosts }} - - host: {{ .host | quote }} - http: - paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType | default "Prefix" }} - backend: - service: - name: {{ $svcName }} - port: - number: {{ $.Values.app.service.port }} - {{- end }} - {{- end }} + cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }} +{{- if $ing.annotations }} +{{ toYaml $ing.annotations | indent 4 }} +{{- end }} +{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }} +{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }} + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +{{- end }} +{{- end }} +spec: +{{- if $ing.className }} + ingressClassName: {{ $ing.className }} +{{- end }} +{{- if $ing.tls }} + tls: +{{- range $ing.tls }} + - hosts: +{{- range .hosts }} + - {{ . | quote }} +{{- end }} + secretName: {{ .secretName }} +{{- end }} +{{- else }} + tls: +{{- range $ing.hosts }} + - hosts: + - {{ .host | quote }} + secretName: {{ include "gitdata.fullname" $ }}-app-tls +{{- end }} +{{- end }} + rules: +{{- range $ing.hosts }} + - host: {{ .host | quote }} + http: + paths: +{{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $svcName }} + port: + number: {{ $.Values.app.service.port }} +{{- end }} +{{- end }} {{- end }} diff --git a/deploy/templates/migrate-job.yaml b/deploy/templates/migrate-job.yaml index 4023783..e0ffd83 100644 --- a/deploy/templates/migrate-job.yaml +++ b/deploy/templates/migrate-job.yaml @@ -2,10 +2,10 @@ apiVersion: batch/v1 kind: Job metadata: - name: {{ include "c-----code.fullname" . }}-migrate - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-migrate + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-migrate + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-migrate app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} helm.sh/hook: post-install,post-upgrade @@ -15,7 +15,7 @@ spec: template: metadata: labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-migrate + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-migrate app.kubernetes.io/instance: {{ .Release.Name }} spec: restartPolicy: OnFailure @@ -32,9 +32,9 @@ spec: env: - name: APP_DATABASE_URL valueFrom: - secretKeyRef: - name: {{ .Values.database.existingSecret | default (printf "%s-secrets" (include "c-----code.fullname" .)) }} - key: {{ .Values.database.secretKeys.url }} + configMapKeyRef: + name: {{ include "gitdata.fullname" . }}-config + key: APP_DATABASE_URL {{- range .Values.migrate.env }} - name: {{ .name }} value: {{ .value | quote }} diff --git a/deploy/templates/namespace.yaml b/deploy/templates/namespace.yaml new file mode 100644 index 0000000..50f1fae --- /dev/null +++ b/deploy/templates/namespace.yaml @@ -0,0 +1,10 @@ +{{- /* Unified namespace declaration */ -}} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + helm.sh/resource-policy: keep diff --git a/deploy/templates/operator-deployment.yaml b/deploy/templates/operator-deployment.yaml index 19968a5..4cd7e75 100644 --- a/deploy/templates/operator-deployment.yaml +++ b/deploy/templates/operator-deployment.yaml @@ -2,31 +2,53 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "c-----code.fullname" . }}-operator - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-operator + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-operator + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion }} spec: replicas: 1 selector: matchLabels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-operator + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator app.kubernetes.io/instance: {{ .Release.Name }} template: metadata: labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-operator + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator app.kubernetes.io/instance: {{ .Release.Name }} spec: - serviceAccountName: {{ include "c-----code.fullname" . }}-operator + serviceAccountName: {{ include "gitdata.fullname" . }}-operator + terminationGracePeriodSeconds: 10 + volumes: + - name: tmp + emptyDir: {} containers: - name: operator image: "{{ .Values.image.registry }}/{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag }}" imagePullPolicy: {{ .Values.operator.image.pullPolicy | default .Values.image.pullPolicy }} + env: + - name: OPERATOR_IMAGE_PREFIX + value: {{ .Values.operator.imagePrefix | default (printf "%s/" (include "gitdata.fullname" .)) | quote }} + - name: OPERATOR_LOG_LEVEL + value: {{ .Values.operator.logLevel | default "info" | quote }} + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace resources: {{- toYaml .Values.operator.resources | nindent 10 }} + volumeMounts: + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL {{- with .Values.operator.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} @@ -44,9 +66,51 @@ spec: apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "c-----code.fullname" . }}-operator - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-operator + namespace: {{ include "gitdata.namespace" . }} labels: - app.kubernetes.io/name: {{ include "c-----code.fullname" . }}-operator + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator app.kubernetes.io/instance: {{ .Release.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "gitdata.fullname" . }}-operator + namespace: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator + app.kubernetes.io/instance: {{ .Release.Name }} +rules: + - apiGroups: ["code.dev"] + resources: ["apps", "gitservers", "emailworkers", "githooks", "migrates"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["code.dev"] + resources: ["apps/status", "gitservers/status", "emailworkers/status", "githooks/status", "migrates/status"] + verbs: ["get", "patch", "update"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services", "persistentvolumeclaims", "configmaps", "secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "deletecollection"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "gitdata.fullname" . }}-operator + namespace: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator + app.kubernetes.io/instance: {{ .Release.Name }} +subjects: + - kind: ServiceAccount + name: {{ include "gitdata.fullname" . }}-operator + namespace: {{ include "gitdata.namespace" . }} +roleRef: + kind: Role + name: {{ include "gitdata.fullname" . }}-operator + apiGroup: rbac.authorization.k8s.io {{- end }} diff --git a/deploy/templates/pdb.yaml b/deploy/templates/pdb.yaml new file mode 100644 index 0000000..61a056b --- /dev/null +++ b/deploy/templates/pdb.yaml @@ -0,0 +1,48 @@ +{{- /* PodDisruptionBudgets for high-availability services */ -}} + +{{- if and .Values.app.enabled .Values.app.pdb.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "gitdata.fullname" . }}-app + namespace: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + {{- if .Values.app.pdb.minAvailable }} + minAvailable: {{ .Values.app.pdb.minAvailable }} + {{- else if .Values.app.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.app.pdb.maxUnavailable }} + {{- else }} + minAvailable: 1 + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- if and .Values.gitHook.enabled .Values.gitHook.pdb.enabled -}} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "gitdata.fullname" . }}-git-hook + namespace: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + {{- if .Values.gitHook.pdb.minAvailable }} + minAvailable: {{ .Values.gitHook.pdb.minAvailable }} + {{- else if .Values.gitHook.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.gitHook.pdb.maxUnavailable }} + {{- else }} + minAvailable: 1 + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/templates/secret.yaml b/deploy/templates/secret.yaml index b1b354e..0f797ae 100644 --- a/deploy/templates/secret.yaml +++ b/deploy/templates/secret.yaml @@ -1,17 +1,63 @@ -{{- /* Template for bootstrap secrets – replace with external secret manager in prod */ -}} -{{- if .Values.secrets }} +{{- /* + Bootstrap secrets for development only. + In production, use an external secret manager (Vault, SealedSecrets, External Secrets). +*/ -}} + +{{- /* + Bootstrap secrets for development only. + In production, use an external secret manager (Vault, SealedSecrets, External Secrets). +*/ -}} + +{{- $secrets := .Values.secrets | default dict -}} +{{- if $secrets.create -}} apiVersion: v1 kind: Secret metadata: - name: {{ include "c-----code.fullname" . }}-secrets - namespace: {{ include "c-----code.namespace" . }} + name: {{ include "gitdata.fullname" . }}-secrets + namespace: {{ include "gitdata.namespace" . }} labels: app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/version: {{ .Chart.AppVersion }} + annotations: + "helm.sh/resource-policy": keep type: Opaque stringData: -{{- range $key, $value := .Values.secrets }} + {{- if $secrets.databaseUrl }} + APP_DATABASE_URL: {{ $secrets.databaseUrl | quote }} + {{- end }} + {{- if $secrets.redisUrl }} + APP_REDIS_URL: {{ $secrets.redisUrl | quote }} + {{- end }} + {{- if .Values.nats.enabled }} + NATS_URL: {{ .Values.nats.url | quote }} + {{- end }} + {{- if $secrets.aiApiKey }} + APP_AI_BASIC_URL: {{ $secrets.aiBasicUrl | quote }} + APP_AI_API_KEY: {{ $secrets.aiApiKey | quote }} + {{- end }} + {{- if $secrets.embedApiKey }} + APP_EMBED_MODEL_BASE_URL: {{ $secrets.embedBasicUrl | quote }} + APP_EMBED_MODEL_API_KEY: {{ $secrets.embedApiKey | quote }} + APP_EMBED_MODEL_NAME: {{ $secrets.embedModelName | quote }} + {{- end }} + {{- if and .Values.qdrant.enabled .Values.qdrant.url }} + APP_QDRANT_URL: {{ .Values.qdrant.url | quote }} + {{- end }} + {{- if .Values.qdrant.apiKey }} + APP_QDRANT_API_KEY: {{ .Values.qdrant.apiKey | quote }} + {{- end }} + {{- if $secrets.smtpHost }} + APP_SMTP_HOST: {{ $secrets.smtpHost | quote }} + APP_SMTP_PORT: {{ $secrets.smtpPort | default "587" | quote }} + APP_SMTP_USERNAME: {{ $secrets.smtpUsername | quote }} + APP_SMTP_PASSWORD: {{ $secrets.smtpPassword | quote }} + APP_SMTP_FROM: {{ $secrets.smtpFrom | default $secrets.smtpUsername | quote }} + APP_SMTP_TLS: {{ $secrets.smtpTls | default "true" | quote }} + {{- end }} + {{- if $secrets.sshDomain }} + APP_SSH_DOMAIN: {{ $secrets.sshDomain | quote }} + {{- end }} + {{- range $key, $value := $secrets.extra | default dict }} {{ $key }}: {{ $value | quote }} -{{- end }} + {{- end }} {{- end }} diff --git a/deploy/templates/static-deployment.yaml b/deploy/templates/static-deployment.yaml new file mode 100644 index 0000000..1b851c6 --- /dev/null +++ b/deploy/templates/static-deployment.yaml @@ -0,0 +1,112 @@ +{{- if .Values.static.enabled -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} +{{- $svc := .Values.static -}} + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }}-static + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/version: {{ .Chart.AppVersion }} +spec: + replicas: {{ $svc.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: static + image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}" + imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ $svc.service.port }} + protocol: TCP + env: + - name: STATIC_ROOT + value: /data + - name: STATIC_BIND + value: {{ printf "0.0.0.0:%s" (print $svc.service.port) }} + - name: STATIC_CORS + value: {{ $svc.cors | default "true" | quote }} + {{- if $svc.logLevel }} + - name: STATIC_LOG_LEVEL + value: {{ $svc.logLevel }} + {{- end }} + {{- range $svc.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + resources: + {{- toYaml $svc.resources | nindent 10 }} + {{- if $svc.livenessProbe }} + livenessProbe: + httpGet: + path: /health + port: {{ $svc.service.port }} + initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.livenessProbe.periodSeconds }} + timeoutSeconds: {{ $svc.livenessProbe.timeoutSeconds }} + failureThreshold: {{ $svc.livenessProbe.failureThreshold }} + {{- end }} + {{- if $svc.readinessProbe }} + readinessProbe: + httpGet: + path: /health + port: {{ $svc.service.port }} + initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ $svc.readinessProbe.periodSeconds }} + timeoutSeconds: {{ $svc.readinessProbe.timeoutSeconds }} + failureThreshold: {{ $svc.readinessProbe.failureThreshold }} + {{- end }} + volumeMounts: + - name: shared-data + mountPath: /data + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ $fullName }}-shared-data + {{- with $svc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $svc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-static + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + type: {{ $svc.service.type }} + ports: + - name: http + port: {{ $svc.service.port }} + targetPort: http + protocol: TCP + selector: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/templates/static-ingress.yaml b/deploy/templates/static-ingress.yaml new file mode 100644 index 0000000..1312990 --- /dev/null +++ b/deploy/templates/static-ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.static.ingress.enabled -}} +{{- $fullName := include "gitdata.fullname" . -}} +{{- $ns := include "gitdata.namespace" . -}} +{{- $ing := .Values.static.ingress -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }}-static + namespace: {{ $ns }} + labels: + app.kubernetes.io/name: {{ $fullName }}-static + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }} +{{- if $ing.annotations }} +{{ toYaml $ing.annotations | indent 4 }} +{{- end }} +{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }} +{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }} + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +{{- end }} +{{- end }} +spec: +{{- if $ing.className }} + ingressClassName: {{ $ing.className }} +{{- end }} +{{- if $ing.tls }} + tls: +{{- range $ing.tls }} + - hosts: +{{- range .hosts }} + - {{ . | quote }} +{{- end }} + secretName: {{ .secretName }} +{{- end }} +{{- else }} + tls: +{{- range $ing.hosts }} + - hosts: + - {{ .host | quote }} + secretName: {{ $fullName }}-static-tls +{{- end }} +{{- end }} + rules: +{{- range $ing.hosts }} + - host: {{ .host | quote }} + http: + paths: +{{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }}-static + port: + number: {{ $.Values.static.service.port }} +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/templates/storage.yaml b/deploy/templates/storage.yaml new file mode 100644 index 0000000..6ab0b69 --- /dev/null +++ b/deploy/templates/storage.yaml @@ -0,0 +1,18 @@ +{{- /* Global shared PVC for all components */ -}} +{{- if .Values.storage.enabled -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "gitdata.fullname" . }}-shared-data + namespace: {{ include "gitdata.namespace" . }} + labels: + app.kubernetes.io/name: {{ include "gitdata.fullname" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + accessModes: + - {{ .Values.storage.accessMode | default "ReadWriteMany" }} + storageClassName: {{ .Values.storage.storageClass }} + resources: + requests: + storage: {{ .Values.storage.size }} +{{- end }} diff --git a/deploy/values.user.yaml.example b/deploy/values.user.yaml.example index ca96b5d..56fdd7c 100644 --- a/deploy/values.user.yaml.example +++ b/deploy/values.user.yaml.example @@ -3,13 +3,13 @@ # PostgreSQL database: - existingSecret: c-----code-secrets + existingSecret: gitdata-secrets secretKeys: url: APP_DATABASE_URL # Redis redis: - existingSecret: c-----code-secrets + existingSecret: gitdata-secrets secretKeys: url: APP_REDIS_URL @@ -31,12 +31,15 @@ app: hosts: - host: git.example.com -# Gitserver persistence +# Gitserver gitserver: persistence: size: 100Gi storageClass: fast-ssd - -# Act Runner -actRunner: - enabled: false + ingress: + enabled: true + hosts: + - host: git-http.example.com + annotations: + # Override default proxy-body-size if needed + # nginx.ingress.kubernetes.io/proxy-body-size: "0" # 0 = unlimited diff --git a/deploy/values.yaml b/deploy/values.yaml index 441064d..a5fa195 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -1,16 +1,104 @@ # ============================================================================= # Global / common settings # ============================================================================= -namespace: c-----code -releaseName: c-----code +namespace: gitdataai +releaseName: gitdata image: registry: harbor.gitdata.me/gta_team pullPolicy: IfNotPresent -# PostgreSQL (required) – set connection string via secret or values +# ============================================================================= +# Cert-Manager Configuration (集群已安装 cert-manager) +# ============================================================================= +certManager: + enabled: true + clusterIssuerName: cloudflare-acme-cluster-issuer # 引用集群已有的 ClusterIssuer + +# ============================================================================= +# External Secrets Configuration (需要集群安装 ESO) +# ============================================================================= +externalSecrets: + storeName: "vault-backend" + storeKind: "SecretStore" + databaseKey: "gitdata/database" + redisKey: "gitdata/redis" + qdrantKey: "gitdata/qdrant" + +# ============================================================================= +# Shared persistent storage (aliyun-nfs) +# ============================================================================= +storage: + enabled: true + storageClass: aliyun-nfs + size: 20Ti + accessMode: ReadWriteMany # NFS supports multiple readers/writers + +# ============================================================================= +# Application config (non-sensitive, shared via ConfigMap) +# ============================================================================= +config: + # App info + name: gitdata + + # Domain configuration + staticDomain: "https://static.gitdata.ai" + mediaDomain: "" + gitHttpDomain: "https://git.gitdata.ai" + + # Storage paths + avatarPath: /data/avatar + reposRoot: /data/repos + + # Logging + logLevel: info + logFormat: json + logFileEnabled: "false" + logFilePath: /var/log/gitdata/app.log + logFileRotation: daily + logFileMaxFiles: "7" + logFileMaxSize: "100" + + # OpenTelemetry + otelEnabled: "false" + otelEndpoint: "" + otelServiceName: gitdata + + # Database pool tuning + databaseMaxConnections: "100" + databaseMinConnections: "5" + databaseIdleTimeout: "600" + databaseMaxLifetime: "3600" + databaseConnectionTimeout: "30" + databaseSchemaSearchPath: public + databaseHealthCheckInterval: "30" + databaseRetryAttempts: "3" + databaseRetryDelay: "1" + + # Redis tuning + redisPoolSize: "16" + redisConnectTimeout: "5" + redisAcquireTimeout: "1" + + # Hook pool + hookPoolMaxConcurrent: "100" + hookPoolCpuThreshold: "80" + hookPoolRedisListPrefix: "{hook}" + hookPoolRedisLogChannel: hook:logs + hookPoolRedisBlockTimeout: "5" + hookPoolRedisMaxRetries: "3" + + # SSH + sshPort: "22" + + # SMTP (non-sensitive defaults) + smtpPort: "465" + smtpTls: "true" + smtpTimeout: "30" + +# PostgreSQL (required) database: - existingSecret: "" + existingSecret: "" # 留空则使用默认名 {release-name}-secrets secretKeys: url: APP_DATABASE_URL @@ -20,19 +108,64 @@ redis: secretKeys: url: APP_REDIS_URL -# NATS (optional – required only if HOOK_POOL is enabled) +# NATS (optional) nats: - enabled: false - url: nats://nats:4222 + enabled: true + url: "nats://nats-client.nats.svc.cluster.local:4222" -# Qdrant (optional – required only if AI embeddings are used) +# Qdrant (optional) qdrant: - enabled: false - url: http://qdrant:6333 + enabled: true + url: "http://qdrant.qdrant.svc.cluster.local:6333" existingSecret: "" secretKeys: apiKey: APP_QDRANT_API_KEY +# ============================================================================= +# Frontend - React SPA +# ============================================================================= +frontend: + enabled: true + replicaCount: 2 + + image: + repository: frontend + tag: latest + + service: + type: ClusterIP + + ingress: + enabled: true + className: nginx + annotations: {} + hosts: + - host: gitdata.ai + paths: + - path: / + pathType: Prefix + tls: [] + + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + + livenessProbe: + initialDelaySeconds: 5 + periodSeconds: 10 + + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 5 + + nodeSelector: {} + tolerations: [] + affinity: {} + # ============================================================================= # App – main web/API service # ============================================================================= @@ -44,19 +177,26 @@ app: repository: app tag: latest + # Pod disruption budget + pdb: + enabled: true + minAvailable: 2 # Keep at least 2 pods available during disruptions + service: type: ClusterIP port: 8080 ingress: - enabled: false - className: cilium # Cilium Ingress (or envoy for EnvoyGateway) + enabled: true + className: nginx annotations: {} hosts: - - host: c-----.local + - host: gitdata.ai paths: - path: / pathType: Prefix + - path: /api + pathType: Prefix tls: [] resources: @@ -79,13 +219,76 @@ app: initialDelaySeconds: 5 periodSeconds: 5 - # Extra env vars (merge with auto-injected ones) + startupProbe: + path: /health + port: 8080 + initialDelaySeconds: 0 + periodSeconds: 10 + failureThreshold: 30 # Allow up to 5 minutes for slow starts + env: [] nodeSelector: {} tolerations: [] affinity: {} +# ============================================================================= +# Static server - avatar, blob, media files +# ============================================================================= +static: + enabled: true + replicaCount: 2 + + image: + repository: static + tag: latest + + service: + type: ClusterIP + port: 8081 + + ingress: + enabled: true + className: nginx + annotations: {} + hosts: + - host: static.gitdata.ai + paths: + - path: / + pathType: Prefix + + cors: true + logLevel: info + + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + + livenessProbe: + path: /health + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + + readinessProbe: + path: /health + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + + env: [] + nodeSelector: {} + tolerations: [] + affinity: {} + # ============================================================================= # Gitserver – git daemon / SSH + HTTP server # ============================================================================= @@ -102,8 +305,11 @@ gitserver: type: ClusterIP port: 8022 ssh: - type: NodePort - nodePort: 30222 + type: LoadBalancer + port: 22 + domain: "" + loadBalancerIP: "" + loadBalancerSourceRanges: [] resources: requests: @@ -113,16 +319,38 @@ gitserver: cpu: 500m memory: 512Mi - # Storage for git repos + livenessProbe: + tcpSocket: + port: 8022 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + tcpSocket: + port: 8022 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + persistence: enabled: true storageClass: "" size: 50Gi accessMode: ReadWriteOnce - ssh: - domain: "" - port: 22 + ingress: + enabled: true + className: nginx + annotations: {} + hosts: + - host: git.gitdata.ai + paths: + - path: / + pathType: Prefix + tls: [] env: [] @@ -140,6 +368,28 @@ emailWorker: repository: email-worker tag: latest + livenessProbe: + exec: + command: + - /bin/sh + - -c + - "pgrep email-worker || exit 1" + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + exec: + command: + - /bin/sh + - -c + - "pgrep email-worker || exit 1" + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + resources: requests: cpu: 50m @@ -166,6 +416,32 @@ gitHook: replicaCount: 2 + pdb: + enabled: true + minAvailable: 1 + + livenessProbe: + exec: + command: + - /bin/sh + - -c + - "pgrep git-hook || exit 1" + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + + readinessProbe: + exec: + command: + - /bin/sh + - -c + - "pgrep git-hook || exit 1" + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: requests: cpu: 50m @@ -196,15 +472,18 @@ migrate: env: [] # ============================================================================= -# Operator – Kubernetes operator (manages custom App/GitServer CRDs) +# Operator – Kubernetes operator # ============================================================================= operator: - enabled: false # Enable only if running the custom operator + enabled: false image: repository: operator tag: latest + imagePrefix: "" + logLevel: info + resources: requests: cpu: 50m @@ -216,47 +495,3 @@ operator: nodeSelector: {} tolerations: [] affinity: {} - -# ============================================================================= -# Act Runner – Gitea Actions self-hosted runner -# ============================================================================= -actRunner: - enabled: false - - image: - repository: act-runner - tag: latest - - replicaCount: 2 - - # Concurrency per runner instance - capacity: 2 - - # Runner labels (must match workflow `runs-on`) - labels: - - gitea - - docker - - logLevel: info - - cache: - enabled: true - dir: /tmp/actions-cache - - resources: - requests: - cpu: 500m - memory: 1Gi - limits: - cpu: 2000m - memory: 4Gi - - env: [] - - nodeSelector: {} - tolerations: - - key: "runner" - operator: "Equal" - value: "true" - effect: "NoSchedule" - affinity: {} diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 0000000..4b4b6ca --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,50 @@ +# ---- Stage 1: Build ---- +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && corepack prepare pnpm@10 --activate + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source +COPY . . + +# Build +RUN pnpm build + +# ---- Stage 2: Serve with nginx ---- +FROM nginx:alpine AS runtime + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# nginx configuration for SPA +RUN echo 'server { \ + listen 80; \ + server_name _; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + location /api/ { \ + proxy_pass http://app:8080/api/; \ + proxy_set_header Host $host; \ + proxy_set_header X-Real-IP $remote_addr; \ + } \ + location /ws/ { \ + proxy_pass http://app:8080/ws/; \ + proxy_http_version 1.1; \ + proxy_set_header Upgrade $http_upgrade; \ + proxy_set_header Connection "upgrade"; \ + proxy_set_header Host $host; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +ENTRYPOINT ["nginx", "-g", "daemon off;"] diff --git a/docker/static.Dockerfile b/docker/static.Dockerfile new file mode 100644 index 0000000..f863c50 --- /dev/null +++ b/docker/static.Dockerfile @@ -0,0 +1,41 @@ +# ---- Stage 1: Build ---- +FROM rust:1.94-bookworm AS builder + +ARG BUILD_TARGET=x86_64-unknown-linux-gnu +ENV TARGET=${BUILD_TARGET} + +WORKDIR /build + +# Copy workspace manifests +COPY Cargo.toml Cargo.lock ./ +COPY libs/ libs/ +COPY apps/static/ apps/static/ + +# Pre-build dependencies only +RUN cargo fetch + +# Build the binary +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=target \ + cargo build --release --package static-server --target ${TARGET} + +# ---- Stage 2: Runtime ---- +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=builder /build/target/${TARGET}/release/static-server /app/static-server + +ENV RUST_LOG=info +ENV STATIC_LOG_LEVEL=info +ENV STATIC_BIND=0.0.0.0:8081 +ENV STATIC_ROOT=/data +ENV STATIC_CORS=true + +EXPOSE 8081 + +ENTRYPOINT ["/app/static-server"] diff --git a/scripts/README.md b/scripts/README.md index ee25f29..8f15af9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,11 +20,11 @@ TARGET=aarch64-unknown-linux-gnu node scripts/build.js **环境变量:** -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | -| `TAG` | `latest` | 镜像标签 | -| `TARGET` | `x86_64-unknown-linux-gnu` | Rust 交叉编译目标 | +| 变量 | 默认值 | 说明 | +|------------|------------------------------|-------------| +| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | +| `TAG` | `latest` | 镜像标签 | +| `TARGET` | `x86_64-unknown-linux-gnu` | Rust 交叉编译目标 | --- @@ -40,12 +40,12 @@ HARBOR_USERNAME=user HARBOR_PASSWORD=pass TAG=sha-abc123 node scripts/push.js ap **环境变量:** -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | -| `TAG` | `latest` 或 Git SHA | 镜像标签 | -| `HARBOR_USERNAME` | - | **必填** 仓库用户名 | -| `HARBOR_PASSWORD` | - | **必填** 仓库密码 | +| 变量 | 默认值 | 说明 | +|-------------------|------------------------------|--------------| +| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | +| `TAG` | `latest` 或 Git SHA | 镜像标签 | +| `HARBOR_USERNAME` | - | **必填** 仓库用户名 | +| `HARBOR_PASSWORD` | - | **必填** 仓库密码 | --- @@ -70,13 +70,13 @@ NAMESPACE=staging node scripts/deploy.js **环境变量:** -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | -| `TAG` | `latest` 或 Git SHA | 镜像标签 | -| `NAMESPACE` | `c-----code` | K8s 命名空间 | -| `RELEASE` | `c-----code` | Helm Release 名称 | -| `KUBECONFIG` | `~/.kube/config` | Kubeconfig 路径 | +| 变量 | 默认值 | 说明 | +|--------------|------------------------------|-----------------| +| `REGISTRY` | `harbor.gitdata.me/gta_team` | 镜像仓库 | +| `TAG` | `latest` 或 Git SHA | 镜像标签 | +| `NAMESPACE` | `gitdata` | K8s 命名空间 | +| `RELEASE` | `gitdata` | Helm Release 名称 | +| `KUBECONFIG` | `~/.kube/config` | Kubeconfig 路径 | --- diff --git a/scripts/build.js b/scripts/build.js index dc92899..e003fad 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -20,7 +20,7 @@ const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team'; const TAG = process.env.TAG || 'latest'; const BUILD_TARGET = process.env.TARGET || 'x86_64-unknown-linux-gnu'; -const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'migrate', 'operator']; +const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'migrate', 'operator', 'static', 'frontend']; const args = process.argv.slice(2); const targets = args.length > 0 ? args : SERVICES; diff --git a/scripts/deploy.js b/scripts/deploy.js index baa5285..3b1156c 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -10,19 +10,19 @@ * Environment: * REGISTRY - Docker registry (default: harbor.gitdata.me/gta_team) * TAG - Image tag (default: latest) - * NAMESPACE - Kubernetes namespace (default: c-----code) - * RELEASE - Helm release name (default: c-----code) + * NAMESPACE - Kubernetes namespace (default: gitdata) + * RELEASE - Helm release name (default: gitdata) * KUBECONFIG - Path to kubeconfig (default: ~/.kube/config) */ -const { execSync } = require('child_process'); +const {execSync} = require('child_process'); const path = require('path'); const fs = require('fs'); const REGISTRY = process.env.REGISTRY || 'harbor.gitdata.me/gta_team'; const TAG = process.env.TAG || process.env.GITHUB_SHA?.substring(0, 8) || 'latest'; -const NAMESPACE = process.env.NAMESPACE || 'c-----code'; -const RELEASE = process.env.RELEASE || 'c-----code'; +const NAMESPACE = process.env.NAMESPACE || 'gitdata'; +const RELEASE = process.env.RELEASE || 'gitdata'; const CHART_PATH = path.join(__dirname, '..', 'deploy'); const KUBECONFIG = process.env.KUBECONFIG || path.join(process.env.HOME || process.env.USERPROFILE, '.kube', 'config'); @@ -34,9 +34,9 @@ const SERVICES = ['app', 'gitserver', 'email-worker', 'git-hook', 'operator']; // 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.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 ===`); @@ -53,32 +53,32 @@ const valuesFile = path.join(CHART_PATH, 'values.yaml'); const userValuesFile = path.join(CHART_PATH, 'values.user.yaml'); const setValues = [ - `image.registry=${REGISTRY}`, - `app.image.tag=${TAG}`, - `gitserver.image.tag=${TAG}`, - `emailWorker.image.tag=${TAG}`, - `gitHook.image.tag=${TAG}`, - `operator.image.tag=${TAG}`, + `image.registry=${REGISTRY}`, + `app.image.tag=${TAG}`, + `gitserver.image.tag=${TAG}`, + `emailWorker.image.tag=${TAG}`, + `gitHook.image.tag=${TAG}`, + `operator.image.tag=${TAG}`, ]; if (runMigrate) { - setValues.push('migrate.enabled=true'); + setValues.push('migrate.enabled=true'); } 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', + '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'); + helmArgs.push('--dry-run'); + console.log('==> Dry run mode - no changes will be made\n'); } // Helm upgrade @@ -86,29 +86,29 @@ 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`); + 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); + console.error('\n[FAIL] Deployment failed'); + process.exit(1); } // Rollout status if (!isDryRun) { - console.log('\n==> Checking rollout status'); - for (const service of SERVICES) { - 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==> Checking rollout status'); + for (const service of SERVICES) { + 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');