From b4a55cfa7bbdc8f954b87b2157200e22d9a5683d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 25 Jul 2026 12:06:39 +0700 Subject: [PATCH] feat(llm-api): add CI/CD, Dockerfile, compose, Traefik routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infra/docker/llm-api.Dockerfile — multi-stage Rust build with cargo-chef - infra/compose/llm-api.yml — service with model volume mount & healthcheck - infra/traefik/dynamic/apps.yaml — router for ai.asepharyana.my.id - CI: build-push workflow detects llm-api changes, builds image - CD: deploy workflow includes llm-api.yml in compose stack --- .github/workflows/deploy-docker.yml | 2 +- .github/workflows/docker-build-push.yml | 16 +++++++-- infra/compose/llm-api.yml | 28 +++++++++++++++ infra/docker/llm-api.Dockerfile | 45 +++++++++++++++++++++++++ infra/traefik/dynamic/apps.yaml | 12 +++++++ 5 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 infra/compose/llm-api.yml create mode 100644 infra/docker/llm-api.Dockerfile diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml index 736b292..c90e6fb 100644 --- a/.github/workflows/deploy-docker.yml +++ b/.github/workflows/deploy-docker.yml @@ -89,7 +89,7 @@ jobs: git fetch origin main --depth=1 || true # Detect changed files before resetting - ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/elysia.yml infra/compose/react.yml infra/compose/rust-auth.yml" + ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/elysia.yml infra/compose/react.yml infra/compose/rust-auth.yml infra/compose/llm-api.yml" TRAEFIK_DYNAMIC_DIR="infra/traefik/dynamic" if git rev-parse HEAD >/dev/null 2>&1; then diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 6813a70..c6e9b1d 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -37,6 +37,7 @@ jobs: elysia-api: ${{ steps.filter.outputs['elysia-api'] == 'true' || steps.dispatch.outputs['elysia-api'] == 'true' || github.event_name == 'workflow_dispatch' }} react-web: ${{ steps.filter.outputs['react-web'] == 'true' || steps.dispatch.outputs['react-web'] == 'true' || github.event_name == 'workflow_dispatch' }} rust-auth: ${{ steps.filter.outputs['rust-auth'] == 'true' || steps.dispatch.outputs['rust-auth'] == 'true' || github.event_name == 'workflow_dispatch' }} + llm-api: ${{ steps.filter.outputs['llm-api'] == 'true' || steps.dispatch.outputs['llm-api'] == 'true' || github.event_name == 'workflow_dispatch' }} steps: - uses: actions/checkout@v6 with: @@ -65,7 +66,10 @@ jobs: echo "scraper-api=$(changed '^(apps/scraper(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/scraper\.Dockerfile$)')" >> "$GITHUB_OUTPUT" echo "elysia-api=$(changed '^(apps/elysia(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/elysia\.Dockerfile$)')" >> "$GITHUB_OUTPUT" echo "react-web=$(changed '^(apps/react(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/react\.Dockerfile$)')" >> "$GITHUB_OUTPUT" - echo "rust-auth=$(changed '^(apps/rust-auth(/|$)|infra/docker/rust\.Dockerfile$|\.github/workflows/docker-build-push\.yml$|\.gitmodules$)')" >> "$GITHUB_OUTPUT" + echo "rust-auth=$(changed '^(apps/rust-auth(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/rust-auth\.Dockerfile$|\.gitmodules$')" >> "$GITHUB_OUTPUT" + echo "llm-api=$(changed '^(apps/llm-api(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/llm-api\.Dockerfile$|\.gitmodules$')" >> "$GITHUB_OUTPUT" + + - name: Parse repository_dispatch payload - name: Parse repository_dispatch payload id: dispatch @@ -87,7 +91,7 @@ jobs: fi case "$SERVICE" in - scraper-api|elysia-api|react-web|rust-auth) ;; + scraper-api|elysia-api|react-web|rust-auth|llm-api) ;; *) exit 1 ;; @@ -95,7 +99,7 @@ jobs: if ! [[ "$SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "::error::Invalid sha '$SHA'. Expected 40 hex characters"; exit 1; fi - SERVICES=(scraper-api elysia-api react-web rust-auth) + SERVICES=(scraper-api elysia-api react-web rust-auth llm-api) for svc in "${SERVICES[@]}"; do if [ "$SERVICE" = "$svc" ]; then echo "${svc}=true" >> "$GITHUB_OUTPUT" @@ -116,6 +120,7 @@ jobs: if [ "${{ steps.filter.outputs['elysia-api'] == 'true' || steps.dispatch.outputs['elysia-api'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "elysia-api" "docker-elysia" "apps/elysia"; fi if [ "${{ steps.filter.outputs['react-web'] == 'true' || steps.dispatch.outputs['react-web'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "react-web" "docker-react" "apps/react"; fi if [ "${{ steps.filter.outputs['rust-auth'] == 'true' || steps.dispatch.outputs['rust-auth'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "rust-auth" "docker-rust-auth" "apps/rust-auth"; fi + if [ "${{ steps.filter.outputs['llm-api'] == 'true' || steps.dispatch.outputs['llm-api'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "llm-api" "docker-llm-api" "apps/llm-api"; fi JSON_ARRAY="[$(IFS=,; echo "${SERVICES[*]}")]" echo "matrix=$JSON_ARRAY" >> $GITHUB_OUTPUT @@ -138,6 +143,7 @@ jobs: "elysia-api") REPO="https://github.com/asepharyana/asepharyana-hub-elysia.git" ;; "react-web") REPO="https://github.com/asepharyana/asepharyana-hub-react.git" ;; "rust-auth") REPO="https://github.com/asepharyana/asepharyana-hub-rust-auth.git" ;; + {indent}"llm-api") REPO="https://github.com/asepharyana/asepharyana-hub-llm-api.git" ;; *) echo "::error::Unsupported service '$SERVICE'" exit 1 @@ -222,6 +228,7 @@ jobs: "elysia-api") echo "dockerfile=infra/docker/elysia.Dockerfile" >> $GITHUB_OUTPUT ;; "react-web") echo "dockerfile=infra/docker/react.Dockerfile" >> $GITHUB_OUTPUT ;; "rust-auth") echo "dockerfile=infra/docker/rust.Dockerfile" >> $GITHUB_OUTPUT ;; + "llm-api") echo "dockerfile=infra/docker/llm-api.Dockerfile" >> $GITHUB_OUTPUT ;; esac - name: Build and Push Docker image @@ -269,12 +276,14 @@ jobs: SERVICES["elysia-api"]="elysia.yml" SERVICES["react-web"]="react.yml" SERVICES["rust-auth"]="rust-auth.yml" + SERVICES["llm-api"]="llm-api.yml" declare -A PATHS PATHS["scraper-api"]="apps/scraper" PATHS["elysia-api"]="apps/elysia" PATHS["react-web"]="apps/react" PATHS["rust-auth"]="apps/rust-auth" + PATHS["llm-api"]="apps/llm-api" # Use git config for possible commits git config --local user.email "action@github.com" @@ -286,6 +295,7 @@ jobs: if [ "${{ needs.changes.outputs['elysia-api'] }}" == "true" ] && [ "$id" == "elysia-api" ]; then SHOULD_HAVE_RUN=true; fi if [ "${{ needs.changes.outputs['react-web'] }}" == "true" ] && [ "$id" == "react-web" ]; then SHOULD_HAVE_RUN=true; fi if [ "${{ needs.changes.outputs['rust-auth'] }}" == "true" ] && [ "$id" == "rust-auth" ]; then SHOULD_HAVE_RUN=true; fi + if [ "${{ needs.changes.outputs['llm-api'] }}" == "true" ] && [ "$id" == "llm-api" ]; then SHOULD_HAVE_RUN=true; fi if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then SHOULD_HAVE_RUN=true; fi diff --git a/infra/compose/llm-api.yml b/infra/compose/llm-api.yml new file mode 100644 index 0000000..c935c5f --- /dev/null +++ b/infra/compose/llm-api.yml @@ -0,0 +1,28 @@ +services: + llm-api: + container_name: llm-api + image: ghcr.io/asepharyana/asepharyana-hub/llm-api:latest + restart: always + networks: + app-shared-net: + aliases: + - llm-api + volumes: + - /root/models/gguf:/root/models/gguf:ro + environment: + - MODEL_PATH=/root/models/gguf/MiniCPM-V-4.6-Q4_K_M.gguf + healthcheck: + test: ['CMD-SHELL', 'curl -so /dev/null --connect-timeout 5 http://localhost:8080/health || test $? -eq 22'] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + labels: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + +networks: + app-shared-net: + name: app-shared-net + external: true diff --git a/infra/docker/llm-api.Dockerfile b/infra/docker/llm-api.Dockerfile new file mode 100644 index 0000000..573c142 --- /dev/null +++ b/infra/docker/llm-api.Dockerfile @@ -0,0 +1,45 @@ +# ── Build stage: cargo-chef ── +FROM lukemathwalker/cargo-chef:latest-rust-1.89.0 AS chef +RUN apt-get update && apt-get install -y --no-install-recommends \ + libclang-dev cmake \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +FROM chef AS planner +COPY apps/llm-api . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo chef cook --release --recipe-path recipe.json + +COPY apps/llm-api . +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo build --release && \ + cp target/release/llm-api /app/llm-api + +# ── Runtime image ── +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libssl3 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1001 appgroup && \ + useradd -u 1001 -g appgroup -s /bin/sh appuser + +WORKDIR /app +COPY --from=builder /app/llm-api /app/llm-api + +# Model will be mounted at runtime +RUN mkdir -p /root/models/gguf && chown -R appuser:appgroup /root/models/gguf + +USER appuser + +EXPOSE 8080 +CMD ["./llm-api"] diff --git a/infra/traefik/dynamic/apps.yaml b/infra/traefik/dynamic/apps.yaml index 2915dee..9ea3de9 100644 --- a/infra/traefik/dynamic/apps.yaml +++ b/infra/traefik/dynamic/apps.yaml @@ -42,6 +42,14 @@ http: rule: Host(`auth.asepharyana.my.id`) || Host(`auth.asepharyana.web.id`) service: rust-auth-service tls: {} + llm-api: + entryPoints: + - websecure + middlewares: + - common-chain@file + rule: Host(`ai.asepharyana.my.id`) || Host(`ai.asepharyana.web.id`) + service: llm-api-service + tls: {} scraper: entryPoints: - websecure @@ -76,6 +84,10 @@ http: loadBalancer: servers: - url: http://rust-auth:3000 + llm-api-service: + loadBalancer: + servers: + - url: http://llm-api:8080 scraper-service: loadBalancer: servers: