Author SHA1 Message Date
mytheclipsebotreview 9ebf1b4655 fix: auto-fix code quality [skip ci] 2026-07-25 12:48:41 +07:00
mytheclipsebotreview cd36326202 fix: auto-fix code quality [skip ci] 2026-07-25 12:43:40 +07:00
asepharyana b4a55cfa7b feat(llm-api): add CI/CD, Dockerfile, compose, Traefik routing
- 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
2026-07-25 12:06:39 +07:00
asepharyana 781e091508 chore(llm-api): migrate to asepharyana-hub-llm-api submodule
Rename and migrate following hub naming convention.
- Old: asepharyana/llm-api
- New: asepharyana/asepharyana-hub-llm-api
- Commit: c77ceee (tool calling + sampling params)
2026-07-25 11:48:54 +07:00
asepharyana e1c4d33eec fix: add LSAR routes, fix default TLS cert (asephstech.pem → asepharyana.my.id.pem) 2026-07-18 15:22:42 +07:00
asepharyana de6b17bf87 feat(traefik): add pr-agent route for GitHub App webhook 2026-07-17 07:56:31 +07:00
8 changed files with 199 additions and 82 deletions
+14 -33
View File
@@ -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
@@ -202,22 +202,21 @@ jobs:
echo "🧹 Removing stale target containers by container_name..."
# Extract all explicitly defined container_names from compose files and remove them to prevent conflicts
remove_stale_containers() {
local files="$1"
local f
for f in $files; do
if [ -f "$f" ]; then
grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do
docker rm -f "$cname" >/dev/null 2>&1 || true
done
fi
done
}
if [ -n "$TARGET_COMPOSE" ]; then
for f in $TARGET_COMPOSE; do
if [ -f "$f" ]; then
grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do
docker rm -f "$cname" >/dev/null 2>&1 || true
done
fi
done
remove_stale_containers "$TARGET_COMPOSE"
else
for f in $ALL_COMPOSE_FILES; do
if [ -f "$f" ]; then
grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do
docker rm -f "$cname" >/dev/null 2>&1 || true
done
fi
done
remove_stale_containers "$ALL_COMPOSE_FILES"
fi
echo "🆙 Starting services..."
@@ -234,21 +233,3 @@ jobs:
echo "✅ Traefik reload signal sent"
fi
EOF
# - name: Restart coolify-proxy (delay 1 min)
# env:
# SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
# VPS_HOST: ${{ secrets.VPS_HOST }}
# VPS_USER: ${{ secrets.VPS_USER }}
# run: |
# mkdir -p ~/.ssh
# echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
# chmod 600 ~/.ssh/id_rsa
# ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts
#
# echo "⏳ Waiting 60s for containers to settle before restarting proxy..."
# sleep 60
#
# echo "🔄 Restarting coolify-proxy..."
# ssh "${VPS_USER}@${VPS_HOST}" "docker restart coolify-proxy"
# echo "✅ coolify-proxy restarted."
+11 -3
View File
@@ -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,8 @@ 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
id: dispatch
@@ -87,7 +89,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 +97,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 +118,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 +141,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" ;;
"llm-api") REPO="https://github.com/asepharyana/asepharyana-hub-llm-api.git" ;;
*)
echo "::error::Unsupported service '$SERVICE'"
exit 1
@@ -222,6 +226,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 +274,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 +293,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
+3
View File
@@ -10,3 +10,6 @@
[submodule "apps/rust-auth"]
path = apps/rust-auth
url = https://github.com/asepharyana/asepharyana-hub-rust-auth.git
[submodule "apps/llm-api"]
path = apps/llm-api
url = https://github.com/asepharyana/asepharyana-hub-llm-api.git
Submodule
+1
Submodule apps/llm-api added at c77ceee83f
+32
View File
@@ -0,0 +1,32 @@
# ── llm-api service (Rust GGUF inference) ──
# Model file is mounted from host; healthcheck tolerates HTTP 404 (exit 22)
# from curl to confirm the server is alive regardless of endpoint availability.
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
+49
View File
@@ -0,0 +1,49 @@
# ── 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
# ca-certificates: TLS for API calls | curl: healthcheck | libssl3: TLS runtime dep
# libgomp1: OpenMP parallelism for GGUF model inference
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for security
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 volume mount point (provided at runtime)
RUN mkdir -p /root/models/gguf && chown -R appuser:appgroup /root/models/gguf
USER appuser
EXPOSE 8080
CMD ["./llm-api"]
+83 -41
View File
@@ -1,57 +1,99 @@
# ── Traefik dynamic configuration (watched at runtime) ──
# Routers map host rules to backend services; services resolve via Docker
# internal DNS (container_name:port).
http:
routers:
# ── React SPA (domain root) ──
react:
rule: 'Host(`asepharyana.my.id`) || Host(`asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
service: react-service
# ── Scraper API ──
scraper:
rule: 'Host(`scraper.asepharyana.my.id`) || Host(`api.asepharyana.my.id`) || Host(`scraper.asepharyana.web.id`) || Host(`api.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: scraper-service
# ── Elysia API ──
elysia:
rule: 'Host(`elysia.asepharyana.my.id`) || Host(`elysia.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
- websecure
middlewares:
- common-chain@file
- common-chain@file
rule: Host(`elysia.asepharyana.my.id`) || Host(`elysia.asepharyana.web.id`)
service: elysia-service
# ── Rust Auth API ──
rust-auth:
rule: 'Host(`auth.asepharyana.my.id`) || Host(`auth.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
lidm-backend:
entryPoints:
- websecure
rule: Host(`lidm-api.asepharyana.my.id`) && PathPrefix(`/api`)
service: lidm-backend-service
tls: {}
lidm-frontend:
entryPoints:
- websecure
rule: Host(`lidm.asepharyana.my.id`)
service: lidm-frontend-service
tls: {}
pr-agent:
entryPoints:
- websecure
middlewares:
- common-chain@file
- common-chain@file
rule: Host(`pr-agent.asepharyana.my.id`) || Host(`pr-agent.asepharyana.web.id`)
service: pr-agent-service
tls: {}
react:
entryPoints:
- websecure
rule: Host(`asepharyana.my.id`) || Host(`asepharyana.web.id`)
service: react-service
tls: {}
rust-auth:
entryPoints:
- websecure
middlewares:
- common-chain@file
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
middlewares:
- common-chain@file
rule: Host(`scraper.asepharyana.my.id`) || Host(`api.asepharyana.my.id`) ||
Host(`scraper.asepharyana.web.id`) || Host(`api.asepharyana.web.id`)
service: scraper-service
tls: {}
# ── Backend service definitions ──
services:
react-service:
loadBalancer:
servers:
- url: 'http://react-web:80'
scraper-service:
loadBalancer:
servers:
- url: 'http://scraper-api:4091'
elysia-service:
loadBalancer:
servers:
- url: 'http://elysia-api:4092'
- url: http://elysia-api:4092
lidm-backend-service:
loadBalancer:
servers:
- url: http://lidm-backend:3001
lidm-frontend-service:
loadBalancer:
servers:
- url: http://lidm-frontend:3000
pr-agent-service:
loadBalancer:
servers:
- url: http://pr-agent-server:3000
react-service:
loadBalancer:
servers:
- url: http://react-web:80
rust-auth-service:
loadBalancer:
servers:
- url: 'http://rust-auth:3000'
- url: http://rust-auth:3000
llm-api-service:
loadBalancer:
servers:
- url: http://llm-api:8080
scraper-service:
loadBalancer:
servers:
- url: http://scraper-api:4091
+6 -5
View File
@@ -1,8 +1,9 @@
# ── TLS certificate configuration ──
# Certificates are auto-renewed by the cert-manager sidecar.
# asepharyana.my.id is the default certificate for all routes.
tls:
certificates:
# Legacy production layout: asephstech.pem is paired with asephscloud.key.
- certFile: /etc/traefik/certs/asephstech.pem
keyFile: /etc/traefik/certs/asephscloud.key
- certFile: /etc/traefik/certs/asepharyana.my.id.pem
keyFile: /etc/traefik/certs/asepharyana.my.id.key
- certFile: /etc/traefik/certs/asepharyana.web.id.pem
@@ -10,5 +11,5 @@ tls:
stores:
default:
defaultCertificate:
certFile: /etc/traefik/certs/asephstech.pem
keyFile: /etc/traefik/certs/asephscloud.key
certFile: /etc/traefik/certs/asepharyana.my.id.pem
keyFile: /etc/traefik/certs/asepharyana.my.id.key