test_workflow
Some checks failed
141
.gitea/workflows/backend-ecr.yaml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Gitea Actions Workflow: Backend ECR Deployment
|
||||||
|
# Place at: .gitea/workflows/backend-ecr.yaml (in repository root)
|
||||||
|
|
||||||
|
name: Backend ECR Deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
paths:
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/backend/**'
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/requirements.txt'
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/deps/implementation/backend_deploy/**'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Build-time / workflow context
|
||||||
|
BACKEND_DIR: workspace/sprint_1_2/CODEBASE/backend
|
||||||
|
ECR_REGISTRY: public.ecr.aws
|
||||||
|
ECR_REPOSITORY: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}/msk-cv-inference-server
|
||||||
|
DOCKERFILE_PATH: deps/implementation/backend_deploy/Dockerfile
|
||||||
|
|
||||||
|
# CV inference server runtime configuration
|
||||||
|
# These are Gitea repository variables (or secrets) that are forwarded
|
||||||
|
# to the container at deploy time. The Docker image itself does NOT bake
|
||||||
|
# them in — the FastAPI app reads them from the environment via pydantic-settings.
|
||||||
|
TRITON_ENDPOINT: ${{ vars.TRITON_ENDPOINT }}
|
||||||
|
CV_INFERENCE_HOST: ${{ vars.CV_INFERENCE_HOST }}
|
||||||
|
CV_INFERENCE_PORT: ${{ vars.CV_INFERENCE_PORT }}
|
||||||
|
BASE_URL: ${{ vars.BASE_URL }}
|
||||||
|
CORS_ORIGINS: ${{ vars.CORS_ORIGINS }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Lint backend code
|
||||||
|
run: |
|
||||||
|
python -m py_compile backend/cv_inference_server.py
|
||||||
|
find backend -name '*.py' -exec python -m py_compile {} +
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR Public
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
with:
|
||||||
|
registry-type: public
|
||||||
|
mask-password: 'true'
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
# Full ECR Public image reference: public.ecr.aws/{alias}/{repo}
|
||||||
|
images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.title=VKIST CV Inference Server
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./${{ env.BACKEND_DIR }}
|
||||||
|
file: ./${{ env.DOCKERFILE_PATH }}
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Print image URI and digest
|
||||||
|
run: |
|
||||||
|
echo "Pushed ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.version }}"
|
||||||
|
echo "Digest: ${{ steps.meta.outputs.tags }}"
|
||||||
|
|
||||||
|
# trigger-triton:
|
||||||
|
# needs: build-and-push
|
||||||
|
# if: success() && github.event_name == 'push'
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# steps:
|
||||||
|
# - name: Trigger Modal Triton deployment
|
||||||
|
# uses: gitea/gitea-actions-workflow-trigger@v1
|
||||||
|
# with:
|
||||||
|
# repository: ${{ github.repository }}
|
||||||
|
# workflow: trigger_modal_triton.yaml
|
||||||
|
# ref: ${{ github.ref }}
|
||||||
|
# inputs: '{}'
|
||||||
|
|
||||||
|
notify-deploy:
|
||||||
|
needs: build-and-push
|
||||||
|
if: success() && github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger deployment webhook
|
||||||
|
if: env.DEPLOY_WEBHOOK_URL != ''
|
||||||
|
env:
|
||||||
|
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
cat <<EOF | curl -X POST "${DEPLOY_WEBHOOK_URL}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: sha256=$(echo -n '@-' | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)" \
|
||||||
|
-d @-
|
||||||
|
{
|
||||||
|
"service": "cv-inference-server",
|
||||||
|
"image": "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.version }}",
|
||||||
|
"env": {
|
||||||
|
"TRITON_ENDPOINT": "${{ vars.TRITON_ENDPOINT }}",
|
||||||
|
"CV_INFERENCE_HOST": "${{ vars.CV_INFERENCE_HOST }}",
|
||||||
|
"CV_INFERENCE_PORT": "${{ vars.CV_INFERENCE_PORT }}",
|
||||||
|
"BASE_URL": "${{ vars.BASE_URL }}",
|
||||||
|
"CORS_ORIGINS": "${{ vars.CORS_ORIGINS }}",
|
||||||
|
"ECR_PUBLIC_REGISTRY_ALIAS": "${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
156
.gitea/workflows/backend-modal.yaml
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Gitea Actions Workflow: Backend Build via Modal (Serverless)
|
||||||
|
# Place at: .gitea/workflows/backend-modal.yaml
|
||||||
|
#
|
||||||
|
# This workflow offloads the heavy Docker build to Modal's serverless infrastructure,
|
||||||
|
# avoiding resource constraints on the 2GB RAM Lightsail VM runner.
|
||||||
|
#
|
||||||
|
# Required Gitea Repository Secrets:
|
||||||
|
# MODAL_TOKEN - Modal API token (from modal.com/settings)
|
||||||
|
# AWS_ACCESS_KEY_ID - For ECR push
|
||||||
|
# AWS_SECRET_ACCESS_KEY
|
||||||
|
# AWS_REGION - e.g., us-east-1
|
||||||
|
#
|
||||||
|
# Required Gitea Repository Variables:
|
||||||
|
# ECR_PUBLIC_REGISTRY_ALIAS - Your public.ecr.aws alias (e.g., vkist-project)
|
||||||
|
# TRITON_ENDPOINT - Modal Triton endpoint for CV inference
|
||||||
|
# CV_INFERENCE_HOST - Bind host (default: 0.0.0.0)
|
||||||
|
# CV_INFERENCE_PORT - Bind port (default: 8001)
|
||||||
|
# BASE_URL - External base URL for routing
|
||||||
|
|
||||||
|
name: Backend Modal Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
paths:
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/backend/**'
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/requirements.txt'
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/deps/implementation/backend_deploy/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag (default: git SHA)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
platform:
|
||||||
|
description: 'Target platform'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'linux/amd64'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
BACKEND_DIR: workspace/sprint_1_2/CODEBASE/backend
|
||||||
|
ECR_REGISTRY: public.ecr.aws
|
||||||
|
ECR_REPOSITORY: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}/msk-cv-inference-server
|
||||||
|
DOCKERFILE_PATH: deps/implementation/backend_deploy/Dockerfile
|
||||||
|
MODAL_SCRIPT: workspace/sprint_1_2/CODEBASE/deps/implementation/backend_deploy/gitea_modal_build.py
|
||||||
|
|
||||||
|
# Runtime config (forwarded to container via deployment)
|
||||||
|
TRITON_ENDPOINT: ${{ vars.TRITON_ENDPOINT }}
|
||||||
|
CV_INFERENCE_HOST: ${{ vars.CV_INFERENCE_HOST }}
|
||||||
|
CV_INFERENCE_PORT: ${{ vars.CV_INFERENCE_PORT }}
|
||||||
|
BASE_URL: ${{ vars.BASE_URL }}
|
||||||
|
CORS_ORIGINS: ${{ vars.CORS_ORIGINS }}
|
||||||
|
ECR_PUBLIC_REGISTRY_ALIAS: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-via-modal:
|
||||||
|
name: Build & Push via Modal
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install Modal CLI
|
||||||
|
run: pip install modal
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: ${{ env.AWS_REGION || 'us-east-1' }}
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR Public
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
with:
|
||||||
|
registry-type: public
|
||||||
|
mask-password: 'true'
|
||||||
|
|
||||||
|
- name: Determine image tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||||
|
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
|
echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push via Modal
|
||||||
|
id: modal-build
|
||||||
|
env:
|
||||||
|
MODAL_TOKEN: ${{ secrets.MODAL_TOKEN }}
|
||||||
|
run: |
|
||||||
|
modal run ${{ env.MODAL_SCRIPT }} \
|
||||||
|
--registry ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }} \
|
||||||
|
--image-name "" \
|
||||||
|
--tag ${{ steps.tag.outputs.tag }} \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--push
|
||||||
|
|
||||||
|
- name: Print image URI
|
||||||
|
run: |
|
||||||
|
echo "Successfully pushed: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.tag }}"
|
||||||
|
echo "image=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Optional: Trigger dependent deployments
|
||||||
|
# trigger-triton:
|
||||||
|
# needs: build-via-modal
|
||||||
|
# if: success() && github.event_name == 'push'
|
||||||
|
# uses: ./.gitea/workflows/trigger_modal_triton.yaml
|
||||||
|
# secrets: inherit
|
||||||
|
|
||||||
|
notify-deploy:
|
||||||
|
needs: build-via-modal
|
||||||
|
if: success() && github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger deployment webhook
|
||||||
|
if: env.DEPLOY_WEBHOOK_URL != ''
|
||||||
|
env:
|
||||||
|
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
cat <<EOF | curl -X POST "${DEPLOY_WEBHOOK_URL}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: sha256=$(echo -n '@-' | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)" \
|
||||||
|
-d @-
|
||||||
|
{
|
||||||
|
"service": "cv-inference-server",
|
||||||
|
"image": "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.tag }}",
|
||||||
|
"env": {
|
||||||
|
"TRITON_ENDPOINT": "${{ vars.TRITON_ENDPOINT }}",
|
||||||
|
"CV_INFERENCE_HOST": "${{ vars.CV_INFERENCE_HOST }}",
|
||||||
|
"CV_INFERENCE_PORT": "${{ vars.CV_INFERENCE_PORT }}",
|
||||||
|
"BASE_URL": "${{ vars.BASE_URL }}",
|
||||||
|
"CORS_ORIGINS": "${{ vars.CORS_ORIGINS }}",
|
||||||
|
"ECR_PUBLIC_REGISTRY_ALIAS": "${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
110
.gitea/workflows/not_done_frontend.yaml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Gitea Actions Workflow: Frontend CI/CD to Amazon ECR Public
|
||||||
|
# Place at: .gitea/workflows/frontend.yaml (in repository root)
|
||||||
|
|
||||||
|
name: Frontend CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FRONTEND_DIR: workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
ECR_REGISTRY: public.ecr.aws
|
||||||
|
ECR_REPOSITORY: ${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}/lumina-frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR Public
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
with:
|
||||||
|
registry-type: public
|
||||||
|
mask-password: 'true'
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: ${{ env.FRONTEND_DIR }}/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./${{ env.FRONTEND_DIR }}
|
||||||
|
|
||||||
|
- name: Build frontend (verify)
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./${{ env.FRONTEND_DIR }}
|
||||||
|
|
||||||
|
- name: Extract metadata for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.title=Lumina MSK Frontend
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./${{ env.FRONTEND_DIR }}
|
||||||
|
file: ./${{ env.FRONTEND_DIR }}/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: echo "Pushed ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tags }}"
|
||||||
|
|
||||||
|
notify-deploy:
|
||||||
|
needs: build-and-push
|
||||||
|
if: success() && github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Lightsail deployment webhook
|
||||||
|
if: env.DEPLOY_WEBHOOK_URL != ''
|
||||||
|
env:
|
||||||
|
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
cat <<EOF | curl -X POST "${DEPLOY_WEBHOOK_URL}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: sha256=$(echo -n '@-' | openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | cut -d' ' -f2)" \
|
||||||
|
-d @-
|
||||||
|
{
|
||||||
|
"service": "lumina-frontend",
|
||||||
|
"image": "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.version }}",
|
||||||
|
"env": {
|
||||||
|
"ECR_PUBLIC_REGISTRY_ALIAS": "${{ vars.ECR_PUBLIC_REGISTRY_ALIAS }}",
|
||||||
|
"CORS_ORIGINS": "${{ vars.CORS_ORIGINS }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# What is the purpose of this workflows
|
# What is the purpose of this workflows
|
||||||
# for a modal GPU instance that hosting the CV model's on Nvidia Triton
|
# for a modal GPU instance that hosting the CV model's on Nvidia Triton
|
||||||
name: Triton Modal Trigger
|
name: Triton Modal Trigger
|
||||||
on: [push]
|
on: workflow_dispatch # set to on [push] for check after each code push what happens
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-to-modal:
|
deploy-to-modal:
|
||||||
35
workspace/sprint_1_2/CODEBASE/.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
.tox
|
||||||
|
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
frontend
|
||||||
|
infra
|
||||||
|
knowledge
|
||||||
|
ml
|
||||||
|
docs
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
tests
|
||||||
|
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yaml
|
||||||
|
*.md
|
||||||
0
workspace/sprint_1_2/CODEBASE/backend/__init__.py
Normal file
@@ -12,36 +12,38 @@ Or the backward-compatible launcher:
|
|||||||
Default: http://127.0.0.1:8001 — point the frontend Vite proxy here (see .env.development).
|
Default: http://127.0.0.1:8001 — point the frontend Vite proxy here (see .env.development).
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL
|
TRITON_ENDPOINT Modal Triton KServe v2 HTTP URL (required)
|
||||||
CV_INFERENCE_HOST bind host (default 127.0.0.1)
|
CV_INFERENCE_HOST bind host (default 127.0.0.1)
|
||||||
CV_INFERENCE_PORT bind port (default 8001)
|
CV_INFERENCE_PORT bind port (default 8001)
|
||||||
ANGLE_MODEL / INFLAMMATION_MODEL / SEGMENT_MODEL optional overrides
|
CORS_ORIGINS comma-separated allowed origins
|
||||||
CV_PIPELINE_VERSION cache invalidation fingerprint (default poc-v2-spec-cv)
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
# Must run before backend imports — config reads TRITON_ENDPOINT at import time.
|
|
||||||
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
|
||||||
os.environ.setdefault("TRITON_ENDPOINT", DEFAULT_TRITON_ENDPOINT)
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from backend.implementation.config import settings
|
||||||
|
from backend.logging.logging_config import setup_logging
|
||||||
from backend.routers import cv_inference
|
from backend.routers import cv_inference
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# Initialise logging as early as possible so any import-time or
|
||||||
|
# startup logs are captured consistently in both local and Docker runs.
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
DEFAULT_TRITON_ENDPOINT = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting CV inference service on Triton: %s", os.getenv("TRITON_ENDPOINT"))
|
|
||||||
|
if not settings.triton_endpoint:
|
||||||
|
raise RuntimeError("TRITON_ENDPOINT is not set. Set it via environment variable.")
|
||||||
|
logger.info("Starting CV inference service on Triton: %s", settings.triton_endpoint)
|
||||||
from backend.services.triton_warmup import warmup_triton_models
|
from backend.services.triton_warmup import warmup_triton_models
|
||||||
|
|
||||||
warmup_task = asyncio.create_task(warmup_triton_models())
|
warmup_task = asyncio.create_task(warmup_triton_models())
|
||||||
@@ -62,10 +64,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=os.getenv(
|
allow_origins=settings.cors_origins,
|
||||||
"CORS_ORIGINS",
|
|
||||||
"http://localhost:3000,http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
|
|
||||||
).split(","),
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -79,11 +78,18 @@ app = create_app()
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logging.basicConfig(level=logging.INFO)
|
"""Entrypoint used by Docker ENTRYPOINT and local development."""
|
||||||
host = os.getenv("CV_INFERENCE_HOST", os.getenv("SEGMENT_TEST_HOST", "127.0.0.1"))
|
logger.info(
|
||||||
port = int(os.getenv("CV_INFERENCE_PORT", os.getenv("SEGMENT_TEST_PORT", "8001")))
|
"CV inference service listening on %s:%s",
|
||||||
logger.info("CV inference service listening on %s:%s", host, port)
|
settings.cv_inference_host,
|
||||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
settings.cv_inference_port,
|
||||||
|
)
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=settings.cv_inference_host,
|
||||||
|
port=settings.cv_inference_port,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,47 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from typing import Dict, List, Optional, Tuple
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
SECRETS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "secrets"
|
from pydantic import Field, HttpUrl, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
def _load_secret(name: str, filename: str) -> str:
|
|
||||||
file_path = SECRETS_DIR / filename
|
def _require_env(name: str) -> str:
|
||||||
env_file = os.getenv(f"{name}_FILE")
|
"""Require a secret from environment variable only.
|
||||||
if env_file:
|
|
||||||
resolved = Path(env_file)
|
In production/Gitea Actions, this comes from repository secrets.
|
||||||
if resolved.exists():
|
No file fallback to avoid accidental secret leakage into the repo.
|
||||||
with open(resolved, "r", encoding="utf-8") as f:
|
"""
|
||||||
return f.read().strip()
|
value = os.getenv(name)
|
||||||
if file_path.exists():
|
if not value:
|
||||||
with open(file_path, "r", encoding="utf-8") as f:
|
|
||||||
return f.read().strip()
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Required secret {name} not found at {file_path} or via {name}_FILE env var"
|
f"Required secret {name} not found. Set {name} environment variable. "
|
||||||
|
f"In Gitea Actions, add it as a repository secret."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_CORS_ORIGINS_DEFAULT = ",".join(
|
||||||
|
[
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cors_origins(value: Optional[str]) -> List[str]:
|
||||||
|
if not value:
|
||||||
|
return [o.strip() for o in _CORS_ORIGINS_DEFAULT.split(",") if o.strip()]
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Endpoints (environment-provided, no hardcoded fallback for production)
|
# Triton
|
||||||
|
triton_endpoint: Optional[HttpUrl] = Field(default=None, validation_alias="TRITON_ENDPOINT")
|
||||||
|
|
||||||
|
# Server
|
||||||
|
cv_inference_host: str = Field(default="127.0.0.1", validation_alias="CV_INFERENCE_HOST")
|
||||||
|
cv_inference_port: int = Field(
|
||||||
|
default=8001, ge=1, le=65535, validation_alias="CV_INFERENCE_PORT"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS - keep raw string to avoid pydantic-settings JSON-list parsing pitfalls
|
||||||
|
cors_origins_raw: str = Field(
|
||||||
|
default=_CORS_ORIGINS_DEFAULT,
|
||||||
|
validation_alias="CORS_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins(self) -> List[str]:
|
||||||
|
return _parse_cors_origins(self.cors_origins_raw)
|
||||||
|
|
||||||
|
# Domain
|
||||||
|
# external_host: Optional[HttpUrl] = Field(default=None, validation_alias="EXTERNAL_HOST") # currently deprecated for no use
|
||||||
|
base_url: Optional[HttpUrl] = Field(default=None, validation_alias="BASE_URL") # can use for routing toward other API later
|
||||||
|
|
||||||
|
# Other settings
|
||||||
|
project_id: str = Field(default="vkist-project", validation_alias="VERTEX_AI_PROJECT")
|
||||||
|
location: str = Field(default="asia-southeast1", validation_alias="VERTEX_AI_LOCATION")
|
||||||
|
temp_dir: str = Field(default="/tmp/analysis_jobs", validation_alias="TEMP_DIR")
|
||||||
|
vertex_ai_model: str = Field(default="medgemma", validation_alias="VERTEX_AI_MODEL")
|
||||||
|
redis_host: str = Field(default="localhost", validation_alias="REDIS_HOST")
|
||||||
|
redis_port: int = Field(default=6379, validation_alias="REDIS_PORT")
|
||||||
|
redis_db: int = Field(default=0, validation_alias="REDIS_DB")
|
||||||
|
clahe_clip_limit: float = Field(default=2.0, validation_alias="CLAHE_CLIP_LIMIT")
|
||||||
|
clahe_tile_size: Tuple[int, int] = Field(default=(8, 8), validation_alias="CLAHE_TILE_SIZE")
|
||||||
|
|
||||||
|
@field_validator("clahe_tile_size", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_tile_size(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
parts = v.split(",")
|
||||||
|
if len(parts) == 2:
|
||||||
|
return (int(parts[0].strip()), int(parts[1].strip()))
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
MODAL_MEDGEMMA_ENDPOINT = os.getenv("MODAL_MEDGEMMA_ENDPOINT")
|
MODAL_MEDGEMMA_ENDPOINT = os.getenv("MODAL_MEDGEMMA_ENDPOINT")
|
||||||
VERTEX_AI_GEMINI_ENDPOINT = os.getenv("VERTEX_AI_GEMINI_ENDPOINT")
|
VERTEX_AI_GEMINI_ENDPOINT = os.getenv("VERTEX_AI_GEMINI_ENDPOINT")
|
||||||
|
|
||||||
# Secrets (must be present in PILOT_PROJECT/secrets or env)
|
# Secrets - must come from environment variables only
|
||||||
GCP_ACCESS_TOKEN = _load_secret("GCP_ACCESS_TOKEN", "gcp_access_token.txt")
|
# In Gitea Actions, these are set via repository secrets
|
||||||
MEDGEMMA_API_KEY = _load_secret("MEDGEMMA_API_KEY", "modal_api_key.txt")
|
# In local development, set via .env or shell environment
|
||||||
|
GCP_ACCESS_TOKEN = os.getenv("GCP_ACCESS_TOKEN")
|
||||||
|
MEDGEMMA_API_KEY = os.getenv("MEDGEMMA_API_KEY")
|
||||||
|
|
||||||
PROJECT_ID = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
|
# Legacy module-level constants for backward compatibility.
|
||||||
LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
|
# These now derive from the validated settings model instead of raw os.getenv().
|
||||||
|
PROJECT_ID = settings.project_id
|
||||||
|
LOCATION = settings.location
|
||||||
|
|
||||||
TRITON_ENDPOINT = os.getenv("TRITON_ENDPOINT", "http://localhost:8000")
|
TRITON_ENDPOINT = (
|
||||||
TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/analysis_jobs")
|
str(settings.triton_endpoint).rstrip("/") if settings.triton_endpoint else None
|
||||||
|
)
|
||||||
|
|
||||||
# LLM Configuration
|
TEMP_DIR = settings.temp_dir
|
||||||
VERTEX_AI_PROJECT = os.getenv("VERTEX_AI_PROJECT", "vkist-project")
|
|
||||||
VERTEX_AI_LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
|
|
||||||
VERTEX_AI_MODEL = os.getenv("VERTEX_AI_MODEL", "medgemma")
|
|
||||||
|
|
||||||
# Redis Configuration
|
VERTEX_AI_PROJECT = settings.project_id
|
||||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
VERTEX_AI_LOCATION = settings.location
|
||||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
VERTEX_AI_MODEL = settings.vertex_ai_model
|
||||||
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
|
|
||||||
|
REDIS_HOST = settings.redis_host
|
||||||
|
REDIS_PORT = settings.redis_port
|
||||||
|
REDIS_DB = settings.redis_db
|
||||||
|
|
||||||
DEFAULT_MODEL_VERSIONS = {
|
DEFAULT_MODEL_VERSIONS = {
|
||||||
"angle": "angle_classify_convnext_tiny",
|
"angle": "angle_classify_convnext_tiny",
|
||||||
@@ -50,8 +134,8 @@ DEFAULT_MODEL_VERSIONS = {
|
|||||||
"segmentation_post": "segmentation_model_post_deeplabv3_resnet101",
|
"segmentation_post": "segmentation_model_post_deeplabv3_resnet101",
|
||||||
}
|
}
|
||||||
|
|
||||||
CLAHE_CLIP_LIMIT = float(os.getenv("CLAHE_CLIP_LIMIT", "2.0"))
|
CLAHE_CLIP_LIMIT = settings.clahe_clip_limit
|
||||||
CLAHE_TILE_SIZE = tuple(int(x) for x in os.getenv("CLAHE_TILE_SIZE", "8,8").split(","))
|
CLAHE_TILE_SIZE = settings.clahe_tile_size
|
||||||
|
|
||||||
|
|
||||||
def get_model_name(task: str, model_versions: Dict[str, str] | None = None) -> str:
|
def get_model_name(task: str, model_versions: Dict[str, str] | None = None) -> str:
|
||||||
@@ -72,4 +156,3 @@ def get_segmentation_model(angle_class: str, model_versions: Dict[str, str] | No
|
|||||||
angle_type = get_angle_type(angle_class)
|
angle_type = get_angle_type(angle_class)
|
||||||
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
|
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
|
||||||
return get_model_name(task, model_versions)
|
return get_model_name(task, model_versions)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import httpx
|
|||||||
import json
|
import json
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
from backend.implementation.adapters.redis_adapter import get_redis_client
|
from backend.implementation.adapters.redis_adapter import get_redis_client
|
||||||
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
|
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
|
||||||
from backend.implementation.config import (
|
from backend.implementation.config import (
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Stage 1: Wheelhouse Builder
|
||||||
|
# Pre-compiles all Python dependencies into .whl files into /wheels.
|
||||||
|
# This layer is cached independently and only rebuilds when requirements.txt
|
||||||
|
# changes. Heavy packages (torch, opencv, pycocotools, etc.) are compiled once
|
||||||
|
# per platform (amd64/arm64) and reused by all subsequent builds.
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.12-slim-bookworm AS wheelhouse
|
||||||
|
|
||||||
|
WORKDIR /wheels
|
||||||
|
|
||||||
|
# Install build tools required for packages with C extensions
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libxcb1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libsm6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first so this layer is cached unless deps change
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Build wheel files for all dependencies AND their transitive dependencies.
|
||||||
|
# --wheel-dir=/wheels: write .whl files to the wheelhouse directory
|
||||||
|
# (no --no-deps: we want the full dependency tree pre-compiled)
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 2: Application Builder
|
||||||
|
# Creates a venv and installs dependencies from the local wheelhouse.
|
||||||
|
# Because pip installs from /wheels (not PyPI), this layer stays cached as
|
||||||
|
# long as the wheelhouse is unchanged — application code changes do NOT
|
||||||
|
# trigger re-downloading or recompiling any dependency.
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.12-slim-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build tools (kept for safety; wheels are pre-compiled but some
|
||||||
|
# packages may still invoke build steps during install)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libxcb1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libsm6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create virtual environment so we can copy a single tree to runtime
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH
|
||||||
|
|
||||||
|
# Copy requirements for pip install validation
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Copy pre-built wheels from wheelhouse stage (platform-specific)
|
||||||
|
COPY --from=wheelhouse /wheels /wheels
|
||||||
|
|
||||||
|
# Install dependencies FROM LOCAL WHEELS ONLY (no network access needed)
|
||||||
|
# --no-index: do not query PyPI or any external index
|
||||||
|
# --find-links: use only the local wheelhouse at /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Stage 3: Runtime
|
||||||
|
# Minimal image containing only the venv and application code.
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.12-slim-bookworm AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
# RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /sbin/nologin appuser
|
||||||
|
|
||||||
|
# # Copy the entire venv from builder
|
||||||
|
# COPY --from=builder --chown=appuser:appgroup /opt/venv /opt/venv
|
||||||
|
# Step 2: Assign an explicit numeric ID (like 999) when creating the user/group
|
||||||
|
RUN groupadd -g 999 appgroup && \
|
||||||
|
useradd -r -u 999 -g appgroup -d /app -s /sbin/nologin appuser
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libxcb1 \
|
||||||
|
libx11-6 \
|
||||||
|
libxext6 \
|
||||||
|
libsm6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
# Step 3: Use the exact matching numeric IDs for --chown
|
||||||
|
COPY --from=builder --chown=999:999 /opt/venv /opt/venv
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY --chown=999:999 backend ./backend
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" || exit 1
|
||||||
|
|
||||||
|
# Run the CV inference server
|
||||||
|
ENTRYPOINT ["python", "-m", "backend.cv_inference_server"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
cv-inference-server:
|
||||||
|
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/msk-cv-inference-server:latest
|
||||||
|
container_name: cv-inference-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
# Environment variables passed from shell (injected by webhook listener from Gitea vars)
|
||||||
|
# No .env file needed — all config comes from Gitea repo variables via webhook payload
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: pilot-network
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
Modal script to build and push VKIST backend Docker image to container registry.
|
||||||
|
|
||||||
|
Run from Gitea runner on Lightsail (2GB RAM) - offloads heavy build to Modal serverless.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
modal run gitea_modal_build.py \
|
||||||
|
--registry public.ecr.aws/vkist-project \
|
||||||
|
--image-name vkist-backend \
|
||||||
|
--tag v1.2.3 \
|
||||||
|
--push
|
||||||
|
|
||||||
|
# Or deploy as reusable Modal app:
|
||||||
|
modal deploy gitea_modal_build.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import modal
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Configuration - Dynamic paths relative to this script
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Get the directory where this script actually lives on the machine running it
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Automatically resolve the root of the repository (climbing up 3 levels)
|
||||||
|
# From: deps/implementation/backend_deploy/gitea_modal_build.py
|
||||||
|
# To: CODEBASE/
|
||||||
|
PROJECT_ROOT = SCRIPT_DIR.parents[2]
|
||||||
|
|
||||||
|
# Explicitly find your local Dockerfile
|
||||||
|
DOCKERFILE_PATH = SCRIPT_DIR / "Dockerfile"
|
||||||
|
|
||||||
|
print(f"--- Environment Verification ---")
|
||||||
|
print(f"Script location: {SCRIPT_DIR}")
|
||||||
|
print(f"Resolved PROJECT_ROOT: {PROJECT_ROOT} (Exists: {PROJECT_ROOT.exists()})")
|
||||||
|
print(f"Resolved DOCKERFILE_PATH: {DOCKERFILE_PATH} (Exists: {DOCKERFILE_PATH.exists()})")
|
||||||
|
print(f"----------------------------------------")
|
||||||
|
|
||||||
|
# Project layout variables
|
||||||
|
BACKEND_SOURCE = "backend"
|
||||||
|
REQUIREMENTS_FILE = "requirements.txt"
|
||||||
|
|
||||||
|
# Default registry/image settings
|
||||||
|
DEFAULT_REGISTRY = "public.ecr.aws/i9a4e3f6/msk-cv-inference-server"
|
||||||
|
DEFAULT_IMAGE_NAME = "msk-lumina-backend"
|
||||||
|
DEFAULT_TAG = "latest"
|
||||||
|
|
||||||
|
# Modal app configuration
|
||||||
|
APP_NAME = "msk-lumina-backend-builder"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Modal Image Definition - uses the project's Dockerfile for consistency
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Build the image using the existing multi-stage Dockerfile
|
||||||
|
# We set context_dir to PROJECT_ROOT so the Dockerfile can see ../../../../backend
|
||||||
|
backend_image = modal.Image.from_dockerfile(
|
||||||
|
str(DOCKERFILE_PATH),
|
||||||
|
context_dir=str(PROJECT_ROOT),
|
||||||
|
).pip_install(
|
||||||
|
"docker", # for test_image function
|
||||||
|
"boto3", # for ECR auth if needed
|
||||||
|
)
|
||||||
|
|
||||||
|
app = modal.App(APP_NAME, image=backend_image)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Build and Push Function
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.function(
|
||||||
|
timeout=900, # 15 min for heavy ML deps (torch, transformers, etc.)
|
||||||
|
cpu=4,
|
||||||
|
memory=8192, # 8GB RAM - plenty for wheel building
|
||||||
|
secrets=[
|
||||||
|
modal.Secret.from_name("aws-secrets"), # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
|
||||||
|
# modal.Secret.from_name("dockerhub-credentials"), # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
||||||
|
# modal.Secret.from_name("gcp-artifact-registry"), # GCP credentials (optional)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def build_and_push(
|
||||||
|
registry: str = DEFAULT_REGISTRY,
|
||||||
|
image_name: str = DEFAULT_IMAGE_NAME,
|
||||||
|
tag: str = DEFAULT_TAG,
|
||||||
|
push: bool = True,
|
||||||
|
platform: str = "linux/amd64",
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Build the backend Docker image and push to registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
registry: Container registry hostname (e.g., public.ecr.aws/vkist-project)
|
||||||
|
image_name: Image name (e.g., vkist-backend)
|
||||||
|
tag: Image tag (e.g., v1.2.3, latest, git-sha)
|
||||||
|
push: Whether to push to registry
|
||||||
|
platform: Target platform (linux/amd64, linux/arm64)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with image reference, digest, and size info
|
||||||
|
"""
|
||||||
|
import docker
|
||||||
|
|
||||||
|
full_image = f"{registry}/{image_name}:{tag}"
|
||||||
|
print(f"Building {full_image} for {platform}")
|
||||||
|
|
||||||
|
# Initialize Docker client (uses Modal's container runtime)
|
||||||
|
client = docker.from_env()
|
||||||
|
|
||||||
|
# Build using the Dockerfile from project root context
|
||||||
|
print(f"Build context: {PROJECT_ROOT}")
|
||||||
|
print(f"Dockerfile: {DOCKERFILE_PATH}")
|
||||||
|
|
||||||
|
# Build the image
|
||||||
|
image, build_logs = client.images.build(
|
||||||
|
path=PROJECT_ROOT,
|
||||||
|
dockerfile=DOCKERFILE_PATH,
|
||||||
|
tag=full_image,
|
||||||
|
platform=platform,
|
||||||
|
rm=True,
|
||||||
|
pull=True,
|
||||||
|
buildargs={
|
||||||
|
"BUILDKIT_INLINE_CACHE": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream build logs
|
||||||
|
for chunk in build_logs:
|
||||||
|
if "stream" in chunk:
|
||||||
|
print(chunk["stream"].strip())
|
||||||
|
elif "error" in chunk:
|
||||||
|
raise RuntimeError(f"Build failed: {chunk['error']}")
|
||||||
|
|
||||||
|
print(f"Build complete: {image.id[:12]}")
|
||||||
|
|
||||||
|
# Get image details
|
||||||
|
image_info = client.images.get(full_image)
|
||||||
|
size_bytes = image_info.attrs["Size"]
|
||||||
|
digest = image_info.attrs.get("RepoDigests", ["unknown"])[0]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"image": full_image,
|
||||||
|
"digest": digest,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"image_id": image.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if push:
|
||||||
|
print(f"Pushing to {registry}...")
|
||||||
|
|
||||||
|
# Authenticate with registry based on hostname
|
||||||
|
_authenticate_registry(registry, client)
|
||||||
|
|
||||||
|
# Push image
|
||||||
|
push_logs = client.images.push(
|
||||||
|
repository=f"{registry}/{image_name}",
|
||||||
|
tag=tag,
|
||||||
|
stream=True,
|
||||||
|
decode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for chunk in push_logs:
|
||||||
|
if "status" in chunk:
|
||||||
|
print(f" {chunk['status']}: {chunk.get('progress', '')}")
|
||||||
|
if "error" in chunk:
|
||||||
|
raise RuntimeError(f"Push failed: {chunk['error']}")
|
||||||
|
|
||||||
|
print(f"Push complete: {full_image}")
|
||||||
|
|
||||||
|
# Get digest after push
|
||||||
|
pushed_image = client.images.get(full_image)
|
||||||
|
result["digest"] = pushed_image.attrs.get("RepoDigests", ["unknown"])[0]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticate_registry(registry: str, client):
|
||||||
|
"""Authenticate Docker client with the target registry."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
if "ecr.aws" in registry or "ecr." in registry:
|
||||||
|
# AWS ECR (public or private)
|
||||||
|
region = os.getenv("AWS_REGION", "us-east-1")
|
||||||
|
print(f"Authenticating with AWS ECR ({region})...")
|
||||||
|
subprocess.run([
|
||||||
|
"aws", "ecr", "get-login-password", "--region", region
|
||||||
|
], check=True, capture_output=True)
|
||||||
|
# Note: Modal's aws-ecr-credentials secret should handle this via env vars
|
||||||
|
|
||||||
|
elif "docker.io" in registry or "index.docker.io" in registry or "/" not in registry.split("/")[0]:
|
||||||
|
# Docker Hub
|
||||||
|
username = os.getenv("DOCKERHUB_USERNAME")
|
||||||
|
password = os.getenv("DOCKERHUB_TOKEN")
|
||||||
|
if username and password:
|
||||||
|
print("Authenticating with Docker Hub...")
|
||||||
|
client.login(username=username, password=password)
|
||||||
|
|
||||||
|
elif "gcr.io" in registry or "pkg.dev" in registry:
|
||||||
|
# Google Container Registry / Artifact Registry
|
||||||
|
print("Authenticating with GCP Artifact Registry...")
|
||||||
|
subprocess.run(["gcloud", "auth", "configure-docker", "--quiet"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.function(
|
||||||
|
image=backend_image,
|
||||||
|
timeout=120,
|
||||||
|
secrets=[modal.Secret.from_name("aws-secrets")],
|
||||||
|
)
|
||||||
|
def test_image(registry: str, image_name: str, tag: str) -> dict:
|
||||||
|
"""Test the built image by running a quick health check."""
|
||||||
|
import docker
|
||||||
|
|
||||||
|
full_image = f"{registry}/{image_name}:{tag}"
|
||||||
|
client = docker.from_env()
|
||||||
|
|
||||||
|
print(f"Pulling {full_image} for testing...")
|
||||||
|
client.images.pull(full_image)
|
||||||
|
|
||||||
|
# print("Running health check...")
|
||||||
|
# output = client.containers.run(
|
||||||
|
# full_image,
|
||||||
|
# command=["python", "-c", "import backend.cv_inference_server; print('Import OK')"],
|
||||||
|
# remove=True,
|
||||||
|
# detach=False,
|
||||||
|
# )
|
||||||
|
|
||||||
|
print("Running basic smoke test...")
|
||||||
|
output = client.containers.run(
|
||||||
|
full_image,
|
||||||
|
command=[
|
||||||
|
"python", "-c",
|
||||||
|
"import sys; print('Python', sys.version); "
|
||||||
|
"import cv2; print('OpenCV', cv2.__version__); "
|
||||||
|
# "import backend.cv_inference_server; print('Import OK')",
|
||||||
|
],
|
||||||
|
remove=True,
|
||||||
|
detach=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"output": output.decode().strip(),
|
||||||
|
"image": full_image,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLI Entry Point
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.local_entrypoint()
|
||||||
|
def main(
|
||||||
|
registry: str = DEFAULT_REGISTRY,
|
||||||
|
image_name: str = DEFAULT_IMAGE_NAME,
|
||||||
|
tag: str = DEFAULT_TAG,
|
||||||
|
push: bool = True,
|
||||||
|
platform: str = "linux/amd64",
|
||||||
|
test: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Build and push backend Docker image via Modal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
registry: Container registry (default: public.ecr.aws/vkist-project)
|
||||||
|
image_name: Image name (default: vkist-backend)
|
||||||
|
tag: Image tag (default: latest)
|
||||||
|
push: Push to registry (default: true)
|
||||||
|
platform: Target platform (default: linux/amd64)
|
||||||
|
test: Run health check after build (default: false)
|
||||||
|
"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("VKIST Backend Docker Build on Modal")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Registry: {registry}")
|
||||||
|
print(f"Image: {image_name}")
|
||||||
|
print(f"Tag: {tag}")
|
||||||
|
print(f"Push: {push}")
|
||||||
|
print(f"Platform: {platform}")
|
||||||
|
print(f"Test: {test}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Build and push
|
||||||
|
result = build_and_push.remote(
|
||||||
|
registry=registry,
|
||||||
|
image_name=image_name,
|
||||||
|
tag=tag,
|
||||||
|
push=push,
|
||||||
|
platform=platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("BUILD COMPLETE")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Image: {result['image']}")
|
||||||
|
print(f"Digest: {result['digest']}")
|
||||||
|
print(f"Size: {result['size_bytes'] / 1024 / 1024:.1f} MB")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Output for CI/CD parsing (GitHub Actions / Gitea Actions format)
|
||||||
|
print(f"::set-output name=image::{result['image']}")
|
||||||
|
print(f"::set-output name=digest::{result['digest']}")
|
||||||
|
print(f"::set-output name=size_mb::{result['size_bytes'] / 1024 / 1024:.1f}")
|
||||||
|
|
||||||
|
# Optional test
|
||||||
|
if test and push:
|
||||||
|
print("\nRunning health check...")
|
||||||
|
test_result = test_image.remote(registry, image_name, tag)
|
||||||
|
print(f"Health check: {test_result['status']}")
|
||||||
|
print(f"Output: {test_result['output']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Allow direct execution for debugging
|
||||||
|
import sys
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
|
||||||
|
print(__doc__)
|
||||||
|
print("\nExamples:")
|
||||||
|
print(" modal run gitea_modal_build.py --tag v1.0.0")
|
||||||
|
print(" modal run gitea_modal_build.py --registry docker.io/myuser --image-name myapp --tag latest")
|
||||||
|
print(" modal deploy gitea_modal_build.py")
|
||||||
|
else:
|
||||||
|
print("Use: modal run gitea_modal_build.py [options]")
|
||||||
|
print("Or: modal deploy gitea_modal_build.py")
|
||||||
110
workspace/sprint_1_2/CODEBASE/deps/implementation/backend_deploy/modal_build.sh
Executable file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Wrapper script for Gitea runner to invoke Modal build.
|
||||||
|
# Runs on Lightsail VM (2GB RAM) - offloads Docker build to Modal serverless.
|
||||||
|
#
|
||||||
|
# Usage: ./modal_build.sh [TAG] [REGISTRY] [IMAGE_NAME] [PLATFORM]
|
||||||
|
# TAG - Image tag (default: git SHA or 'latest')
|
||||||
|
# REGISTRY - Container registry (default: public.ecr.aws/vkist-project)
|
||||||
|
# IMAGE_NAME - Image name (default: vkist-backend)
|
||||||
|
# PLATFORM - Target platform (default: linux/amd64)
|
||||||
|
#
|
||||||
|
# Environment variables (set as Gitea repository secrets):
|
||||||
|
# MODAL_TOKEN - Modal API token (from modal.com/settings)
|
||||||
|
# AWS_ACCESS_KEY_ID - For ECR push
|
||||||
|
# AWS_SECRET_ACCESS_KEY
|
||||||
|
# AWS_REGION - Default: us-east-1
|
||||||
|
# DOCKERHUB_USERNAME - Optional, for Docker Hub
|
||||||
|
# DOCKERHUB_TOKEN - Optional, for Docker Hub
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ./modal_build.sh v1.2.3
|
||||||
|
# ./modal_build.sh latest public.ecr.aws/my-project my-app linux/arm64
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MODAL_SCRIPT="${SCRIPT_DIR}/gitea_modal_build.py"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_REGISTRY="public.ecr.aws/vkist-project"
|
||||||
|
DEFAULT_IMAGE_NAME="vkist-backend"
|
||||||
|
DEFAULT_PLATFORM="linux/amd64"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
TAG="${1:-${GITEA_SHA:-latest}}"
|
||||||
|
REGISTRY="${2:-${DEFAULT_REGISTRY}}"
|
||||||
|
IMAGE_NAME="${3:-${DEFAULT_IMAGE_NAME}}"
|
||||||
|
PLATFORM="${4:-${DEFAULT_PLATFORM}}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
check_prereqs() {
|
||||||
|
log_info "Checking prerequisites..."
|
||||||
|
|
||||||
|
if ! command -v modal &> /dev/null; then
|
||||||
|
log_error "Modal CLI not found. Install with: pip install modal"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$MODAL_SCRIPT" ]; then
|
||||||
|
log_error "Modal script not found at: $MODAL_SCRIPT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Modal auth
|
||||||
|
if [ -z "${MODAL_TOKEN:-}" ]; then
|
||||||
|
log_warn "MODAL_TOKEN not set. Ensure 'modal setup' was run or token is in environment."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Prerequisites OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build via Modal
|
||||||
|
build_via_modal() {
|
||||||
|
log_info "Starting Modal build..."
|
||||||
|
log_info "Registry: $REGISTRY"
|
||||||
|
log_info "Image: $IMAGE_NAME"
|
||||||
|
log_info "Tag: $TAG"
|
||||||
|
log_info "Platform: $PLATFORM"
|
||||||
|
|
||||||
|
# Run modal build
|
||||||
|
modal run "$MODAL_SCRIPT" \
|
||||||
|
--registry "$REGISTRY" \
|
||||||
|
--image-name "$IMAGE_NAME" \
|
||||||
|
--tag "$TAG" \
|
||||||
|
--platform "$PLATFORM" \
|
||||||
|
--push
|
||||||
|
|
||||||
|
local exit_code=$?
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
log_info "Build and push completed successfully!"
|
||||||
|
log_info "Image: $REGISTRY/$IMAGE_NAME:$TAG"
|
||||||
|
else
|
||||||
|
log_error "Modal build failed with exit code $exit_code"
|
||||||
|
exit $exit_code
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
main() {
|
||||||
|
log_info "=== VKIST Modal Docker Build Wrapper ==="
|
||||||
|
|
||||||
|
check_prereqs
|
||||||
|
build_via_modal
|
||||||
|
|
||||||
|
log_info "=== Done ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy script for VKIST CV Inference Server (Backend)
|
||||||
|
# Triggered by Gitea webhook — env vars come from webhook payload, NOT .env file
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_DIR="/opt/pilot-project"
|
||||||
|
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.backend.yaml"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "Starting CV inference server deployment"
|
||||||
|
|
||||||
|
# Verify compose file exists
|
||||||
|
if [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||||
|
log "ERROR: Compose file not found at ${COMPOSE_FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${PROJECT_DIR}"
|
||||||
|
|
||||||
|
# Pull latest compose file (in case it was updated)
|
||||||
|
log "Pulling latest compose file from git"
|
||||||
|
git pull --quiet origin main || log "WARNING: git pull failed, using local compose file"
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
# ECR_PUBLIC_REGISTRY_ALIAS comes from webhook env payload
|
||||||
|
log "Pulling latest image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/msk-cv-inference-server:latest"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" pull cv-inference-server
|
||||||
|
|
||||||
|
# Deploy with zero-downtime recreation
|
||||||
|
# All required env vars (TRITON_ENDPOINT, CV_INFERENCE_HOST, etc.) are already
|
||||||
|
# in the shell environment, passed from webhook payload
|
||||||
|
log "Recreating cv-inference-server container"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate cv-inference-server
|
||||||
|
|
||||||
|
# Clean up old images
|
||||||
|
log "Pruning unused images"
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# Verify health
|
||||||
|
log "Waiting for health check..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if docker compose -f "${COMPOSE_FILE}" exec -T cv-inference-server \
|
||||||
|
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')" 2>/dev/null; then
|
||||||
|
log "Health check passed"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
log "ERROR: Health check failed after 30 attempts"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" logs cv-inference-server
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Deployment completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# /opt/pilot-project/Caddyfile
|
||||||
|
# Replace app.example.com with your actual domain
|
||||||
|
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
lumina-frontend:
|
||||||
|
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest
|
||||||
|
container_name: lumina-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128m
|
||||||
|
cpus: '0.5'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: pilot-network
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Lightsail Production Environment
|
||||||
|
# Copy to /opt/pilot-project/.env on Lightsail VM
|
||||||
|
# chmod 600 /opt/pilot-project/.env
|
||||||
|
|
||||||
|
# ECR Public Registry Alias (e.g., vkist-pilot, your-account-alias)
|
||||||
|
# Get from: AWS Console > ECR Public > Registries > [your registry] > Alias
|
||||||
|
ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
|
||||||
|
|
||||||
|
# Webhook Secret (generate with: openssl rand -hex 32)
|
||||||
|
# Must match Gitea webhook secret configuration
|
||||||
|
WEBHOOK_SECRET=your-generated-secret-here
|
||||||
|
|
||||||
|
# Optional: Backend API URL for nginx proxy (if different from frontend)
|
||||||
|
# BACKEND_API_URL=http://localhost:8001
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gitea Webhook Listener for Lumina Frontend Deploy
|
||||||
|
After=network.target docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
Group=ubuntu
|
||||||
|
WorkingDirectory=/opt/pilot-project
|
||||||
|
EnvironmentFile=/opt/pilot-project/.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/pilot-project/webhook-listener.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/pilot-project
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy script for Lumina MSK Frontend
|
||||||
|
# Triggered by Gitea webhook - receives env vars from Gitea repo variables
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT_DIR="/opt/pilot-project"
|
||||||
|
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.frontend.yaml"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Required env vars (passed from webhook / Gitea variables)
|
||||||
|
REQUIRED_VARS=(
|
||||||
|
"ECR_PUBLIC_REGISTRY_ALIAS"
|
||||||
|
"CORS_ORIGINS"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write .env from passed environment variables (so docker compose env_file works)
|
||||||
|
write_env_file() {
|
||||||
|
local env_file="${PROJECT_DIR}/.env"
|
||||||
|
log "Writing .env from passed environment variables"
|
||||||
|
for var in "${REQUIRED_VARS[@]}"; do
|
||||||
|
if [[ -n "${!var:-}" ]]; then
|
||||||
|
echo "${var}=${!var}" >> "${env_file}"
|
||||||
|
else
|
||||||
|
log "WARNING: ${var} not set in environment"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "Starting frontend deployment"
|
||||||
|
|
||||||
|
# Verify compose file exists
|
||||||
|
if [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||||
|
log "ERROR: Compose file not found at ${COMPOSE_FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${PROJECT_DIR}"
|
||||||
|
|
||||||
|
# Pull latest compose file (in case it was updated)
|
||||||
|
log "Pulling latest compose file from git"
|
||||||
|
git pull --quiet origin main || log "WARNING: git pull failed, using local compose file"
|
||||||
|
|
||||||
|
# Write .env from passed env vars
|
||||||
|
write_env_file
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
log "Pulling latest image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" pull lumina-frontend
|
||||||
|
|
||||||
|
# Deploy with zero-downtime recreation
|
||||||
|
log "Recreating frontend container"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate lumina-frontend
|
||||||
|
|
||||||
|
# Clean up old images
|
||||||
|
log "Pruning unused images"
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
# Verify health
|
||||||
|
log "Waiting for health check..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if docker compose -f "${COMPOSE_FILE}" exec -T lumina-frontend wget --quiet --tries=1 --spider http://localhost/ 2>/dev/null; then
|
||||||
|
log "Health check passed"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
log "ERROR: Health check failed after 30 attempts"
|
||||||
|
docker compose -f "${COMPOSE_FILE}" logs lumina-frontend
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Deployment completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Gitea Webhook Listener for Lumina Deployment
|
||||||
|
Runs on Lightsail VM, receives webhook from Gitea, triggers the appropriate
|
||||||
|
deploy script based on which workflow completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
|
||||||
|
DEPLOY_SCRIPTS = {
|
||||||
|
'Frontend CI/CD': '/opt/pilot-project/webhook-deploy.sh',
|
||||||
|
'Backend ECR Deployment': '/opt/pilot-project/webhook-deploy-backend.sh',
|
||||||
|
'Backend Modal Build': '/opt/pilot-project/webhook-deploy-backend.sh',
|
||||||
|
}
|
||||||
|
LISTEN_HOST = '127.0.0.1'
|
||||||
|
LISTEN_PORT = 3333
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
# Verify path
|
||||||
|
if self.path != '/deploy':
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read body
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
|
||||||
|
# Verify signature
|
||||||
|
signature = self.headers.get('X-Gitea-Signature', '')
|
||||||
|
if not self.verify_signature(body, signature):
|
||||||
|
logger.warning("Invalid webhook signature")
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Invalid signature')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse JSON payload
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
payload = json.loads(body.decode('utf-8'))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Invalid JSON payload")
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if workflow completed successfully
|
||||||
|
deploy_info = self.should_deploy(payload)
|
||||||
|
if not deploy_info:
|
||||||
|
logger.info("Event does not trigger deploy: %s", payload.get('action', 'unknown'))
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Event ignored')
|
||||||
|
return
|
||||||
|
|
||||||
|
deploy_script = deploy_info['script']
|
||||||
|
deploy_env = deploy_info.get('env', {})
|
||||||
|
|
||||||
|
# Trigger deployment with env vars from payload
|
||||||
|
logger.info("Triggering deployment with script: %s", deploy_script)
|
||||||
|
logger.debug("Passing env vars: %s", list(deploy_env.keys()))
|
||||||
|
try:
|
||||||
|
# Merge payload env vars with current process env
|
||||||
|
proc_env = os.environ.copy()
|
||||||
|
proc_env.update(deploy_env)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[deploy_script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
env=proc_env
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info("Deployment successful")
|
||||||
|
logger.debug("Deploy output: %s", result.stdout)
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Deployment triggered successfully')
|
||||||
|
else:
|
||||||
|
logger.error("Deployment failed: %s", result.stderr)
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f'Deployment failed: {result.stderr}'.encode())
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Deployment timed out")
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'Deployment timed out')
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Deployment error")
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f'Deployment error: {str(e)}'.encode())
|
||||||
|
|
||||||
|
def verify_signature(self, body: bytes, signature: str) -> bool:
|
||||||
|
"""Verify Gitea HMAC-SHA256 signature"""
|
||||||
|
if not WEBHOOK_SECRET:
|
||||||
|
logger.error("WEBHOOK_SECRET not configured")
|
||||||
|
return False
|
||||||
|
expected = 'sha256=' + hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode(),
|
||||||
|
body,
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
def should_deploy(self, payload: dict) -> dict | None:
|
||||||
|
"""Determine if this webhook event should trigger a deploy.
|
||||||
|
Returns dict with 'script' and optional 'env' if yes, None if no."""
|
||||||
|
# Gitea workflow_run event
|
||||||
|
if payload.get('action') == 'completed':
|
||||||
|
workflow = payload.get('workflow', {})
|
||||||
|
workflow_name = workflow.get('name', '')
|
||||||
|
conclusion = payload.get('conclusion')
|
||||||
|
if conclusion == 'success' and workflow_name in DEPLOY_SCRIPTS:
|
||||||
|
return {
|
||||||
|
'script': DEPLOY_SCRIPTS[workflow_name],
|
||||||
|
'env': payload.get('env', {})
|
||||||
|
}
|
||||||
|
# Direct webhook payload (for manual triggers or extended format)
|
||||||
|
if 'service' in payload:
|
||||||
|
service = payload.get('service')
|
||||||
|
if service == 'cv-inference-server':
|
||||||
|
return {
|
||||||
|
'script': DEPLOY_SCRIPTS['Backend ECR Deployment'],
|
||||||
|
'env': payload.get('env', {})
|
||||||
|
}
|
||||||
|
if service == 'lumina-frontend':
|
||||||
|
return {
|
||||||
|
'script': DEPLOY_SCRIPTS['Frontend CI/CD'],
|
||||||
|
'env': payload.get('env', {})
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
logger.info("%s - %s", self.address_string(), format % args)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not WEBHOOK_SECRET:
|
||||||
|
logger.error("WEBHOOK_SECRET environment variable not set")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Verify deploy scripts exist
|
||||||
|
for name, path in DEPLOY_SCRIPTS.items():
|
||||||
|
if not os.path.exists(path):
|
||||||
|
logger.warning("Deploy script not found for %s at %s", name, path)
|
||||||
|
|
||||||
|
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler)
|
||||||
|
logger.info("Webhook listener starting on %s:%s", LISTEN_HOST, LISTEN_PORT)
|
||||||
|
logger.info("Watching workflows: %s", ', '.join(DEPLOY_SCRIPTS.keys()))
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down")
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# ECR Public CI/CD Setup Guide (with Caddy Reverse Proxy)
|
||||||
|
|
||||||
|
Complete setup for Lumina MSK Frontend: Gitea CI → Amazon ECR Public → Lightsail Deploy with Caddy (auto-HTTPS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Requirement | Version/Details |
|
||||||
|
|-------------|-----------------|
|
||||||
|
| AWS Account | With ECR Public access |
|
||||||
|
| Lightsail VM | 2 GB, Ubuntu 22.04+, Docker + Docker Compose installed |
|
||||||
|
| Gitea Server | Running on Lightsail (or accessible), Actions enabled |
|
||||||
|
| Domain | For HTTPS via Let's Encrypt (Caddy handles this automatically) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Amazon ECR Public Setup
|
||||||
|
|
||||||
|
### 1.1 Create Public Registry
|
||||||
|
```bash
|
||||||
|
# AWS Console → ECR Public → Create registry
|
||||||
|
# Note the Registry Alias (e.g., vkist-pilot)
|
||||||
|
# Region: us-east-1 (only region for ECR Public)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Create Repository
|
||||||
|
```bash
|
||||||
|
aws ecr-public create-repository \
|
||||||
|
--repository-name lumina-frontend \
|
||||||
|
--region us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Create IAM User for CI
|
||||||
|
```bash
|
||||||
|
aws iam create-user --user-name gitea-ci-ecr-public
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Attach Policy
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"ecr-public:GetAuthorizationToken",
|
||||||
|
"ecr-public:InitiateLayerUpload",
|
||||||
|
"ecr-public:UploadLayerPart",
|
||||||
|
"ecr-public:CompleteLayerUpload",
|
||||||
|
"ecr-public:PutImage"
|
||||||
|
],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
aws iam put-user-policy \
|
||||||
|
--user-name gitea-ci-ecr-public \
|
||||||
|
--policy-name ECRPublicPush \
|
||||||
|
--policy-document file://ecr-public-push-policy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Create Access Keys
|
||||||
|
```bash
|
||||||
|
aws iam create-access-key --user-name gitea-ci-ecr-public
|
||||||
|
# Save: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gitea Secrets Configuration
|
||||||
|
|
||||||
|
Go to: **Repository → Settings → Secrets → Actions**
|
||||||
|
|
||||||
|
| Secret Name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `AWS_ACCESS_KEY_ID` | From step 1.5 |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | From step 1.5 |
|
||||||
|
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your registry alias (e.g., `vkist-pilot`) |
|
||||||
|
| `WEBHOOK_SECRET` | `openssl rand -hex 32` |
|
||||||
|
| `DEPLOY_WEBHOOK_URL` | `http://<lightsail-ip>:3333/deploy` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lightsail VM Setup
|
||||||
|
|
||||||
|
### 3.1 Install Docker + Compose
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y docker.io docker-compose-plugin python3
|
||||||
|
sudo usermod -aG docker ubuntu
|
||||||
|
newgrp docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Deploy Project Files
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
sudo git clone <your-gitea-repo-url> pilot-project
|
||||||
|
sudo chown -R ubuntu:ubuntu pilot-project
|
||||||
|
cd pilot-project
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Configure Environment
|
||||||
|
```bash
|
||||||
|
cp workspace/sprint_1_2/CODEBASE/deps/spec/env.example .env
|
||||||
|
# Edit .env with your values:
|
||||||
|
# ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
|
||||||
|
# WEBHOOK_SECRET=<same-as-gitea-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Deploy Compose File
|
||||||
|
```bash
|
||||||
|
cp workspace/sprint_1_2/CODEBASE/deps/spec/docker-compose.frontend.yaml .
|
||||||
|
# Create external network
|
||||||
|
docker network create pilot-network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Install Deploy Script + Webhook Listener
|
||||||
|
```bash
|
||||||
|
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-deploy.sh .
|
||||||
|
cp workspace/sprint_1_2/CODEBASE/deps/spec/webhook-listener.py .
|
||||||
|
chmod +x webhook-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Install Systemd Service
|
||||||
|
```bash
|
||||||
|
sudo cp workspace/sprint_1_2/CODEBASE/deps/spec/gitea-webhook-listener.service \
|
||||||
|
/etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable gitea-webhook-listener
|
||||||
|
sudo systemctl start gitea-webhook-listener
|
||||||
|
sudo systemctl status gitea-webhook-listener
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 Verify Webhook Listener
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3333/deploy \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: sha256=$(echo -n '{}' | openssl dgst -sha256 -hmac "$(grep WEBHOOK_SECRET .env | cut -d= -f2)" | cut -d' ' -f2)" \
|
||||||
|
-d '{}'
|
||||||
|
# Should return "Event ignored" (no workflow completed)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gitea Webhook Configuration
|
||||||
|
|
||||||
|
### 4.1 Add Webhook
|
||||||
|
**Repository → Settings → Webhooks → Add Webhook**
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Target URL | `http://<lightsail-ip>:3333/deploy` |
|
||||||
|
| Secret | `<same WEBHOOK_SECRET from step 2>` |
|
||||||
|
| Events | ✅ Workflow Run |
|
||||||
|
| Active | ✅ |
|
||||||
|
|
||||||
|
### 4.2 Test Webhook
|
||||||
|
Use Gitea's "Test Delivery" button → should return 200 with "Event ignored"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Caddy Reverse Proxy (Auto-HTTPS)
|
||||||
|
|
||||||
|
### 5.1 Install Caddy
|
||||||
|
```bash
|
||||||
|
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Configure Caddyfile
|
||||||
|
```bash
|
||||||
|
# Copy Caddyfile template
|
||||||
|
cp workspace/sprint_1_2/CODEBASE/deps/spec/Caddyfile.example /opt/pilot-project/Caddyfile
|
||||||
|
# Edit with your domain
|
||||||
|
sudo vim /opt/pilot-project/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Caddyfile Content
|
||||||
|
```text
|
||||||
|
# /opt/pilot-project/Caddyfile
|
||||||
|
# Replace app.example.com with your actual domain
|
||||||
|
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Run Caddy (Docker Compose - Recommended)
|
||||||
|
Add to your `docker-compose.frontend.yaml` or create separate `docker-compose.caddy.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.caddy.yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- /opt/pilot-project/Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- pilot-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pilot-network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start Caddy
|
||||||
|
docker compose -f docker-compose.caddy.yaml up -d
|
||||||
|
|
||||||
|
# Or run directly on host (if not using Docker)
|
||||||
|
# sudo systemctl enable --now caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Verify HTTPS
|
||||||
|
```bash
|
||||||
|
# Wait ~30s for Let's Encrypt cert issuance
|
||||||
|
curl -I https://app.example.com/health
|
||||||
|
# Should return 200 with strict-transport-security header
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. First Deployment Test
|
||||||
|
|
||||||
|
### 6.1 Trigger CI
|
||||||
|
```bash
|
||||||
|
cd <local-repo>
|
||||||
|
# Make a small change
|
||||||
|
echo "# test" >> workspace/sprint_1_2/CODEBASE/frontend/implementation/README.md
|
||||||
|
git add . && git commit -m "test: trigger CI" && git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Monitor
|
||||||
|
- **Gitea Actions tab** → Watch workflow run
|
||||||
|
- **Lightsail** → `journalctl -u gitea-webhook-listener -f`
|
||||||
|
- **ECR Public Console** → Verify image pushed
|
||||||
|
|
||||||
|
### 6.3 Verify
|
||||||
|
```bash
|
||||||
|
# Via Caddy (HTTPS)
|
||||||
|
curl https://app.example.com/health
|
||||||
|
# Should return "healthy"
|
||||||
|
|
||||||
|
# Direct to frontend (HTTP, for debugging)
|
||||||
|
curl http://<lightsail-ip>:8080/health
|
||||||
|
# Should return "healthy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rollback Procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Lightsail
|
||||||
|
docker images public.ecr.aws/vkist-pilot/lumina-frontend
|
||||||
|
|
||||||
|
# Tag previous SHA as latest
|
||||||
|
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> \
|
||||||
|
public.ecr.aws/vkist-pilot/lumina-frontend:latest
|
||||||
|
|
||||||
|
# Redeploy
|
||||||
|
cd /opt/pilot-project
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cost Estimate (Monthly)
|
||||||
|
|
||||||
|
| Component | Cost |
|
||||||
|
|-----------|------|
|
||||||
|
| Lightsail 2 GB | $10 |
|
||||||
|
| ECR Public storage (1 GB) | ~$0.10 |
|
||||||
|
| ECR Public data transfer | Free (public pulls) |
|
||||||
|
| **Total** | **~$10.10/month** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Check |
|
||||||
|
|-------|-------|
|
||||||
|
| CI fails at ECR login | AWS credentials in Gitea secrets, IAM policy |
|
||||||
|
| Image not found on pull | Registry alias correct, image pushed to ECR |
|
||||||
|
| Webhook 401 | `WEBHOOK_SECRET` matches in .env and Gitea |
|
||||||
|
| Container unhealthy | `docker logs lumina-frontend`, check nginx config |
|
||||||
|
| Caddy cert not issued | Port 80/443 open on Lightsail firewall, DNS points to Lightsail IP |
|
||||||
|
| OOM on 2 GB VM | Reduce `memory: 128m` in compose, add swap |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. File Inventory
|
||||||
|
|
||||||
|
| File | Location | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow |
|
||||||
|
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
|
||||||
|
| `nginx.conf` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | SPA routing + caching (inside container) |
|
||||||
|
| `docker-compose.frontend.yaml` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Production runtime |
|
||||||
|
| `webhook-deploy.sh` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Deploy script |
|
||||||
|
| `webhook-listener.py` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Webhook HTTP server |
|
||||||
|
| `gitea-webhook-listener.service` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Systemd unit |
|
||||||
|
| `env.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Environment template |
|
||||||
|
| `Caddyfile.example` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Caddy config template |
|
||||||
|
| `ci_cd_docker_registry_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | Full documentation |
|
||||||
|
| `ci_cd_ecr_public_flow.md` | `workspace/sprint_1_2/CODEBASE/deps/spec/` | ECR Public specific docs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated for VKIST Pilot Project — MSK Ultrasound Stack*
|
||||||
|
*Compatible with: Gitea 1.21+, Docker 24+, Node 20, AWS ECR Public, Caddy 2*
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
# CI/CD Flow: Docker Registry-Based Deployment (with Caddy)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to a container registry, and the Lightsail VM pulls and deploys the image via Docker Compose. **Caddy** handles automatic HTTPS termination and reverse proxying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor Dev as Developer
|
||||||
|
participant Git as Local Git
|
||||||
|
participant Gitea as Gitea (Lightsail VM)
|
||||||
|
participant CI as Gitea Actions Runner
|
||||||
|
participant Registry as Container Registry
|
||||||
|
participant Lightsail as Lightsail VM (Production)
|
||||||
|
participant Caddy as Caddy (HTTPS + Reverse Proxy)
|
||||||
|
|
||||||
|
Dev->>Git: git push origin main
|
||||||
|
Git->>Gitea: Push commits
|
||||||
|
Note over Gitea: Receives push event
|
||||||
|
Gitea->>CI: Dispatch workflow job
|
||||||
|
|
||||||
|
par CI Build Stage
|
||||||
|
CI->>CI: Checkout code (actions/checkout@v4)
|
||||||
|
CI->>CI: Setup Node.js 26 (actions/setup-node@v4)
|
||||||
|
CI->>CI: npm ci (install deps)
|
||||||
|
CI->>CI: npm run build (verify build)
|
||||||
|
CI->>CI: Login to registry (docker/login-action)
|
||||||
|
CI->>CI: Build Docker image (docker/build-push-action)
|
||||||
|
CI->>Registry: Push image (tagged with SHA + latest)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over Registry: Image stored: registry.example.com/lumina-frontend:sha123
|
||||||
|
|
||||||
|
par Deploy Stage
|
||||||
|
Lightsail->>Registry: Pull latest image
|
||||||
|
Lightsail->>Lightsail: docker compose pull
|
||||||
|
Lightsail->>Lightsail: docker compose up -d --force-recreate
|
||||||
|
Lightsail->>Caddy: Automatic HTTPS (no reload needed)
|
||||||
|
end
|
||||||
|
|
||||||
|
Dev->>Caddy: Access https://app.example.com
|
||||||
|
Caddy->>Lightsail: Proxy to frontend container (localhost:8080)
|
||||||
|
Lightsail->>Dev: Serves frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Diagram (C4)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Container
|
||||||
|
title Container Diagram — Docker Registry CI/CD with Caddy
|
||||||
|
|
||||||
|
Person(dev, "Developer", "Writes code, pushes to Gitea")
|
||||||
|
|
||||||
|
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
|
||||||
|
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
|
||||||
|
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Boundary(registry_boundary, "Container Registry") {
|
||||||
|
Container(registry, "Registry", "Docker Hub / GCR / ECR / Harbor", "Stores built images")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
|
||||||
|
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
|
||||||
|
Container(frontend, "Frontend Container", "nginx:alpine", "Serves Lumina MSK static assets")
|
||||||
|
Container(caddy, "Caddy", "HTTPS + Reverse Proxy", "Auto-TLS via Let's Encrypt, proxies to frontend")
|
||||||
|
}
|
||||||
|
|
||||||
|
Rel(dev, gitea, "git push", "HTTPS/SSH")
|
||||||
|
Rel(gitea, runner, "Dispatches job", "Internal API")
|
||||||
|
Rel(runner, registry, "docker push", "HTTPS")
|
||||||
|
Rel(compose, registry, "docker pull", "HTTPS")
|
||||||
|
Rel(compose, frontend, "Manages container", "Docker API")
|
||||||
|
Rel(caddy, frontend, "Reverse proxy", "HTTP (localhost:8080)")
|
||||||
|
Rel(dev, caddy, "Accesses app", "HTTPS (auto-TLS)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Description
|
||||||
|
|
||||||
|
### 1. Trigger Phase
|
||||||
|
|
||||||
|
| Event | Condition | Workflow File |
|
||||||
|
|-------|-----------|---------------|
|
||||||
|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
|
||||||
|
| Manual dispatch | Via Gitea UI | Same workflow |
|
||||||
|
|
||||||
|
### 2. CI Build Stage (Runs on Gitea Actions Runner)
|
||||||
|
|
||||||
|
| Step | Tool | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| Checkout | `actions/checkout@v4` | Clone repository |
|
||||||
|
| Setup Node | `actions/setup-node@v4` | Node.js 26 + cache `node_modules` |
|
||||||
|
| Install deps | `npm ci` | Clean, reproducible install |
|
||||||
|
| Verify build | `npm run build` | Runs `sync-wasm` → `tsc -b` → `vite build`; fails if TypeScript errors |
|
||||||
|
| Login registry | `docker/login-action@v3` | Authenticate to registry using secrets |
|
||||||
|
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
|
||||||
|
|
||||||
|
**Image tags pushed:**
|
||||||
|
- `registry.example.com/lumina-frontend:<git-sha>` — immutable, traceable
|
||||||
|
- `registry.example.com/lumina-frontend:latest` — rolling pointer for deploy
|
||||||
|
|
||||||
|
### 3. Registry
|
||||||
|
|
||||||
|
Supported registries (pick one):
|
||||||
|
- **Docker Hub** — public/private, generous free tier
|
||||||
|
- **GitHub Container Registry (GHCR)** — integrated with GitHub, but Gitea can push
|
||||||
|
- **Google Container Registry (GCR)** — if on GCP
|
||||||
|
- **Amazon ECR Public** — free public images, authenticated pushes
|
||||||
|
- **Harbor / self-hosted** — full control, on Lightsail or separate VM
|
||||||
|
|
||||||
|
**Required secrets in Gitea:**
|
||||||
|
| Secret Name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `REGISTRY_URL` | e.g., `docker.io` or `ghcr.io` |
|
||||||
|
| `REGISTRY_USER` | Registry username |
|
||||||
|
| `REGISTRY_PASS` | Registry password / token |
|
||||||
|
|
||||||
|
### 4. Deploy Stage (Runs on Lightsail VM)
|
||||||
|
|
||||||
|
Two sub-options:
|
||||||
|
|
||||||
|
#### 4a. Polling / Cron (Simplest)
|
||||||
|
```bash
|
||||||
|
# /etc/cron.hourly/deploy-frontend
|
||||||
|
#!/bin/bash
|
||||||
|
cd /opt/pilot-project
|
||||||
|
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4b. Webhook Listener (Event-driven)
|
||||||
|
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# webhook-deploy.sh (triggered by webhook)
|
||||||
|
cd /opt/pilot-project
|
||||||
|
git pull # optional: ensure compose file is current
|
||||||
|
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea webhook config:
|
||||||
|
- **URL**: `http://<lightsail-ip>:3333/deploy`
|
||||||
|
- **Events**: Workflow run completed (success)
|
||||||
|
- **Secret**: Shared HMAC secret
|
||||||
|
|
||||||
|
### 5. Runtime on Lightsail (with Caddy)
|
||||||
|
|
||||||
|
#### Docker Compose
|
||||||
|
```yaml
|
||||||
|
# docker-compose.frontend.yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
lumina-frontend:
|
||||||
|
image: registry.example.com/lumina-frontend:latest
|
||||||
|
container_name: lumina-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80" # Internal only, Caddy proxies to this
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128m
|
||||||
|
cpus: '0.5'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- lumina-frontend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: pilot-network
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Caddyfile (Auto-HTTPS)
|
||||||
|
```text
|
||||||
|
# Caddyfile
|
||||||
|
# Replace with your actual domain
|
||||||
|
app.example.com {
|
||||||
|
# Reverse proxy to frontend container
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
# Optional: Security headers
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key benefits of Caddy:**
|
||||||
|
- **Automatic HTTPS** — No manual cert management, Let's Encrypt handled automatically
|
||||||
|
- **HTTP/2 & HTTP/3** — Enabled by default
|
||||||
|
- **Zero-downtime reloads** — Config changes don't drop connections
|
||||||
|
- **No external ACME client needed** — Built-in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts to Create
|
||||||
|
|
||||||
|
| File | Location | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
|
||||||
|
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
|
||||||
|
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
|
||||||
|
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy config with domain |
|
||||||
|
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
|
||||||
|
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
|
||||||
|
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | Registry credentials |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Management
|
||||||
|
|
||||||
|
| Secret | Where | Scope |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `REGISTRY_URL` | Gitea repo secrets | CI build step |
|
||||||
|
| `REGISTRY_USER` | Gitea repo secrets | CI build step |
|
||||||
|
| `REGISTRY_PASS` | Gitea repo secrets | CI build step |
|
||||||
|
| `WEBHOOK_SECRET` | Gitea webhook config + Lightsail env | Deploy trigger auth |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Scenarios & Mitigations
|
||||||
|
|
||||||
|
| Scenario | Detection | Mitigation |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
|
||||||
|
| Registry push fails | CI step fails | Check credentials, quota |
|
||||||
|
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
|
||||||
|
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
|
||||||
|
| Caddy cert failure | Caddy logs (`docker logs caddy`) | Check domain DNS, port 80/443 reachable |
|
||||||
|
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Lightsail: list available tags
|
||||||
|
docker images registry.example.com/lumina-frontend
|
||||||
|
|
||||||
|
# Rollback to specific SHA
|
||||||
|
docker tag registry.example.com/lumina-frontend:<old-sha> registry.example.com/lumina-frontend:latest
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Estimate (Monthly)
|
||||||
|
|
||||||
|
| Component | Estimate |
|
||||||
|
|-----------|----------|
|
||||||
|
| Lightsail 2 GB instance | $10–12 |
|
||||||
|
| Registry (Docker Hub free / GHCR) | $0–5 |
|
||||||
|
| Data transfer | < $1 |
|
||||||
|
| **Total** | **~$10–18 / month** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Choose a container registry and create repo/namespace
|
||||||
|
2. Add registry secrets to Gitea
|
||||||
|
3. Create `.gitea/workflows/frontend.yaml` (see appendix)
|
||||||
|
4. Create `Dockerfile` in frontend implementation dir
|
||||||
|
5. Deploy `docker-compose.frontend.yaml` + `Caddyfile` to Lightsail
|
||||||
|
6. Point domain DNS to Lightsail public IP
|
||||||
|
7. Test end-to-end: push to `main` → verify CI → verify deploy → verify HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Minimal Workflow File
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/frontend.yaml
|
||||||
|
name: Frontend CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
|
||||||
|
- name: Build frontend (verify)
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/lumina-frontend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Minimal Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Custom nginx config for SPA + caching
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: nginx.conf (for Frontend Container)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Cache static assets aggressively
|
||||||
|
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Caddyfile (for Lightsail)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# /opt/pilot-project/Caddyfile
|
||||||
|
# Replace app.example.com with your actual domain
|
||||||
|
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document version: 2.0 (Caddy integration)*
|
||||||
|
*Last updated: 2026-07-17*
|
||||||
|
*Owner: Platform Engineering / VKIST Team*
|
||||||
@@ -0,0 +1,756 @@
|
|||||||
|
# CI/CD Flow: Docker Registry-Based Deployment (Amazon ECR Public)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document specifies the complete CI/CD flow where the Gitea CI pipeline builds the frontend Docker image, pushes it to **Amazon ECR Public**, and the Lightsail VM pulls and deploys the image via Docker Compose with **Caddy** as the reverse proxy (auto-HTTPS via Let's Encrypt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor Dev as Developer
|
||||||
|
participant Git as Local Git
|
||||||
|
participant Gitea as Gitea (Lightsail VM)
|
||||||
|
participant CI as Gitea Actions Runner
|
||||||
|
participant ECR as Amazon ECR Public
|
||||||
|
participant Lightsail as Lightsail VM (Production)
|
||||||
|
participant Caddy as Caddy (Reverse Proxy + TLS)
|
||||||
|
|
||||||
|
Dev->>Git: git push origin main
|
||||||
|
Git->>Gitea: Push commits
|
||||||
|
Note over Gitea: Receives push event
|
||||||
|
Gitea->>CI: Dispatch workflow job
|
||||||
|
|
||||||
|
par CI Build Stage
|
||||||
|
CI->>CI: Checkout code (actions/checkout@v4)
|
||||||
|
CI->>CI: Configure AWS credentials (aws-actions/configure-aws-credentials)
|
||||||
|
CI->>CI: Login to ECR Public (aws-actions/amazon-ecr-login)
|
||||||
|
CI->>CI: Setup Node.js 20 (actions/setup-node@v4)
|
||||||
|
CI->>CI: npm ci (install deps)
|
||||||
|
CI->>CI: npm run build (verify build)
|
||||||
|
CI->>CI: Build Docker image (docker/build-push-action)
|
||||||
|
CI->>ECR: Push image (tagged with SHA + latest)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over ECR: Image stored: public.ecr.aws/<alias>/lumina-frontend:sha123
|
||||||
|
|
||||||
|
par Deploy Stage
|
||||||
|
Lightsail->>ECR: Pull latest image (public, no auth needed for pull)
|
||||||
|
Lightsail->>Lightsail: docker compose pull
|
||||||
|
Lightsail->>Lightsail: docker compose up -d --force-recreate
|
||||||
|
Lightsail->>Caddy: Reload config (if needed)
|
||||||
|
end
|
||||||
|
|
||||||
|
Dev->>Lightsail: Access https://app.example.com
|
||||||
|
Lightsail->>Dev: Serves frontend (auto-HTTPS)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Diagram (C4)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
C4Container
|
||||||
|
title Container Diagram — ECR Public CI/CD with Caddy
|
||||||
|
|
||||||
|
Person(dev, "Developer", "Writes code, pushes to Gitea")
|
||||||
|
|
||||||
|
System_Boundary(gitea_boundary, "Gitea Server (Lightsail VM)") {
|
||||||
|
Container(gitea, "Gitea", "Git + CI/CD", "Hosts repos, runs Actions workflows")
|
||||||
|
Container(runner, "Gitea Actions Runner", "Ephemeral", "Executes CI jobs in containers")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Boundary(aws_boundary, "AWS Cloud") {
|
||||||
|
Container(ecr, "Amazon ECR Public", "public.ecr.aws", "Stores public container images")
|
||||||
|
}
|
||||||
|
|
||||||
|
System_Boundary(prod_boundary, "Production (Lightsail VM)") {
|
||||||
|
Container(compose, "Docker Compose", "Orchestrator", "Manages frontend container lifecycle")
|
||||||
|
Container(caddy, "Caddy", "Reverse Proxy + TLS", "Auto-HTTPS via Let's Encrypt, serves Lumina MSK frontend")
|
||||||
|
}
|
||||||
|
|
||||||
|
Rel(dev, gitea, "git push", "HTTPS/SSH")
|
||||||
|
Rel(gitea, runner, "Dispatches job", "Internal API")
|
||||||
|
Rel(runner, ecr, "docker push (authenticated)", "HTTPS + AWS SigV4")
|
||||||
|
Rel(compose, ecr, "docker pull (public)", "HTTPS")
|
||||||
|
Rel(compose, caddy, "Proxies to frontend", "Docker network")
|
||||||
|
Rel(dev, caddy, "Accesses app", "HTTPS (auto)")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Description
|
||||||
|
|
||||||
|
### 1. Trigger Phase
|
||||||
|
|
||||||
|
| Event | Condition | Workflow File |
|
||||||
|
|-------|-----------|---------------|
|
||||||
|
| Push to `main` | Changes under `workspace/sprint_1_2/CODEBASE/frontend/implementation/**` | `.gitea/workflows/frontend.yaml` |
|
||||||
|
| Manual dispatch | Via Gitea UI | Same workflow |
|
||||||
|
|
||||||
|
### 2. CI Build Stage (Runs on Gitea Actions Runner)
|
||||||
|
|
||||||
|
| Step | Tool | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| Checkout | `actions/checkout@v4` | Clone repository |
|
||||||
|
| Configure AWS | `aws-actions/configure-aws-credentials@v4` | Set AWS credentials for ECR access |
|
||||||
|
| Login to ECR Public | `aws-actions/amazon-ecr-login@v2` | Authenticate Docker to ECR Public |
|
||||||
|
| Setup Node | `actions/setup-node@v4` | Node.js 20 + cache `node_modules` |
|
||||||
|
| Install deps | `npm ci` | Clean, reproducible install |
|
||||||
|
| Verify build | `npm run build` | Runs `sync-wasm` → `tsc -b` → `vite build`; fails if TypeScript errors |
|
||||||
|
| Build & push | `docker/build-push-action@v5` | Multi-stage build, push with SHA + `latest` tags |
|
||||||
|
|
||||||
|
**Image tags pushed:**
|
||||||
|
- `public.ecr.aws/<registry-alias>/lumina-frontend:<git-sha>` — immutable, traceable
|
||||||
|
- `public.ecr.aws/<registry-alias>/lumina-frontend:latest` — rolling pointer for deploy
|
||||||
|
|
||||||
|
### 3. Amazon ECR Public
|
||||||
|
|
||||||
|
**Repository URI format:**
|
||||||
|
```
|
||||||
|
public.ecr.aws/<registry-alias>/lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key properties:**
|
||||||
|
- **Public pulls** — No authentication required for `docker pull`
|
||||||
|
- **Authenticated pushes** — Requires AWS credentials with `ecr-public:PutImage`, `ecr-public:InitiateLayerUpload`, `ecr-public:UploadLayerPart`, `ecr-public:CompleteLayerUpload`
|
||||||
|
- **Registry alias** — Created once per AWS account (e.g., `vkist-pilot`)
|
||||||
|
- **Region** — `us-east-1` (ECR Public is only in us-east-1)
|
||||||
|
|
||||||
|
**Required IAM permissions for CI user/role:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"ecr-public:GetAuthorizationToken",
|
||||||
|
"ecr-public:InitiateLayerUpload",
|
||||||
|
"ecr-public:UploadLayerPart",
|
||||||
|
"ecr-public:CompleteLayerUpload",
|
||||||
|
"ecr-public:PutImage"
|
||||||
|
],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required secrets in Gitea:**
|
||||||
|
| Secret Name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `AWS_ACCESS_KEY_ID` | IAM user access key with ECR Public permissions |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | Corresponding secret key |
|
||||||
|
| `AWS_ECR_REGION` | `us-east-1` (ECR Public region) |
|
||||||
|
| `ECR_PUBLIC_REGISTRY_ALIAS` | Your ECR Public registry alias (e.g., `vkist-pilot`) |
|
||||||
|
|
||||||
|
### 4. Deploy Stage (Runs on Lightsail VM)
|
||||||
|
|
||||||
|
Since ECR Public images are publicly pullable, **no AWS credentials needed on Lightsail** for deployment.
|
||||||
|
|
||||||
|
Two sub-options:
|
||||||
|
|
||||||
|
#### 4a. Polling / Cron (Simplest)
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /etc/cron.hourly/deploy-frontend
|
||||||
|
cd /opt/pilot-project
|
||||||
|
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4b. Webhook Listener (Event-driven)
|
||||||
|
A small HTTP server on Lightsail receives a webhook from Gitea after CI succeeds and triggers deploy immediately.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# /opt/pilot-project/webhook-deploy.sh
|
||||||
|
set -e
|
||||||
|
cd /opt/pilot-project
|
||||||
|
git pull # optional: ensure compose file is current
|
||||||
|
docker compose -f docker-compose.frontend.yaml pull lumina-frontend
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Gitea Webhook Listener - secrets from AWS SSM Parameter Store
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: boto3 required. Install: pip install boto3")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SSM_PREFIX = "/lumina"
|
||||||
|
AWS_ECR_REGION = os.environ.get("AWS_ECR_REGION", "us-east-1")
|
||||||
|
LISTEN_HOST = "127.0.0.1"
|
||||||
|
LISTEN_PORT = 3333
|
||||||
|
DEPLOY_SCRIPT = Path(__file__).parent / "webhook-deploy.sh"
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_secrets_from_ssm() -> dict:
|
||||||
|
"""Fetch all parameters under /lumina prefix from SSM Parameter Store."""
|
||||||
|
client = boto3.client("ssm", region_name=AWS_ECR_REGION)
|
||||||
|
response = client.get_parameters_by_path(
|
||||||
|
Path=SSM_PREFIX,
|
||||||
|
WithDecryption=True,
|
||||||
|
Recursive=True
|
||||||
|
)
|
||||||
|
secrets = {}
|
||||||
|
for param in response.get("Parameters", []):
|
||||||
|
key = param["Name"].split("/")[-1].upper()
|
||||||
|
secrets[key] = param["Value"]
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
|
||||||
|
SECRETS = load_secrets_from_ssm()
|
||||||
|
WEBHOOK_SECRET = SECRETS.get("WEBHOOK_SECRET", "")
|
||||||
|
ECR_REGISTRY_ALIAS = SECRETS.get("ECR_REGISTRY_ALIAS", "")
|
||||||
|
|
||||||
|
if not WEBHOOK_SECRET:
|
||||||
|
logger.error("WEBHOOK_SECRET not found in SSM at %s/webhook-secret", SSM_PREFIX)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(payload: bytes, signature_header: str) -> bool:
|
||||||
|
if not signature_header or not signature_header.startswith("sha256="):
|
||||||
|
return False
|
||||||
|
expected = "sha256=" + hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature_header)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/deploy":
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
payload = self.rfile.read(content_length)
|
||||||
|
|
||||||
|
signature = self.headers.get("X-Gitea-Signature", "")
|
||||||
|
if not verify_signature(payload, signature):
|
||||||
|
logger.warning("Invalid signature from %s", self.client_address[0])
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Invalid signature")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only deploy on successful frontend workflow
|
||||||
|
if event.get("workflow_run", {}).get("conclusion") != "success":
|
||||||
|
logger.info("Workflow not successful, ignoring")
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Ignored")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Triggering deployment")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(DEPLOY_SCRIPT)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
env={**os.environ, "ECR_PUBLIC_REGISTRY_ALIAS": ECR_REGISTRY_ALIAS}
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Deployed")
|
||||||
|
else:
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"Failed: {result.stderr}".encode())
|
||||||
|
except Exception as e:
|
||||||
|
self.send_response(500)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"Error: {e}".encode())
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
logger.info("%s - %s", self.client_address[0], format % args)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not DEPLOY_SCRIPT.exists():
|
||||||
|
logger.error("Deploy script not found: %s", DEPLOY_SCRIPT)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
os.chmod(DEPLOY_SCRIPT, 0o755)
|
||||||
|
|
||||||
|
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler)
|
||||||
|
logger.info("Listener on %s:%d (registry: %s)", LISTEN_HOST, LISTEN_PORT, ECR_REGISTRY_ALIAS)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**systemd unit** (`/etc/systemd/system/gitea-webhook-listener.service`):
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Webhook Listener (Lumina Frontend)
|
||||||
|
After=network.target docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
WorkingDirectory=/opt/pilot-project
|
||||||
|
ExecStart=/usr/bin/python3 /opt/pilot-project/webhook-listener.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Security
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/opt/pilot-project
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gitea webhook config:**
|
||||||
|
- **URL**: `http://<lightsail-public-ip>:3333/deploy` (or via Caddy proxy)
|
||||||
|
- **Events**: Workflow Run → Completed
|
||||||
|
- **Secret**: Same `WEBHOOK_SECRET` value (stored in SSM)
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
|
||||||
|
### 5. Runtime on Lightsail with Caddy
|
||||||
|
|
||||||
|
**Docker Compose** (`docker-compose.frontend.yaml`):
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
lumina-frontend:
|
||||||
|
image: public.ecr.aws/${ECR_PUBLIC_REGISTRY_ALIAS}/lumina-frontend:latest
|
||||||
|
container_name: lumina-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128m
|
||||||
|
cpus: '0.5'
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- lumina-frontend
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: pilot-network
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caddyfile** (`/opt/pilot-project/Caddyfile`):
|
||||||
|
```text
|
||||||
|
# Replace app.example.com with your actual domain
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment file** (`/opt/pilot-project/.env`):
|
||||||
|
```bash
|
||||||
|
ECR_PUBLIC_REGISTRY_ALIAS=vkist-pilot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts to Create
|
||||||
|
|
||||||
|
| File | Location | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `frontend.yaml` | `.gitea/workflows/` | CI workflow definition |
|
||||||
|
| `Dockerfile` | `workspace/sprint_1_2/CODEBASE/frontend/implementation/` | Multi-stage build |
|
||||||
|
| `docker-compose.frontend.yaml` | `/opt/pilot-project/` on Lightsail | Production runtime (frontend + Caddy) |
|
||||||
|
| `Caddyfile` | `/opt/pilot-project/` on Lightsail | Caddy reverse proxy config |
|
||||||
|
| `webhook-deploy.sh` (optional) | `/opt/pilot-project/` on Lightsail | Deploy script |
|
||||||
|
| `webhook-listener.py` (optional) | `/opt/pilot-project/` on Lightsail | Webhook server (secrets from SSM) |
|
||||||
|
| systemd unit (optional) | `/etc/systemd/system/` on Lightsail | Run webhook listener |
|
||||||
|
| `.env` | `/opt/pilot-project/` on Lightsail | Registry alias variable |
|
||||||
|
| Gitea secrets | Gitea UI → Repo → Settings → Secrets | AWS credentials + alias |
|
||||||
|
| SSM Parameters | AWS Console → Systems Manager → Parameter Store | `WEBHOOK_SECRET`, `ECR_REGISTRY_ALIAS` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Management
|
||||||
|
|
||||||
|
| Secret | Where | Scope |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| `AWS_ACCESS_KEY_ID` | Gitea repo secrets | CI: ECR authentication |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | Gitea repo secrets | CI: ECR authentication |
|
||||||
|
| `AWS_ECR_REGION` | Gitea repo secrets | CI: `us-east-1` |
|
||||||
|
| `ECR_PUBLIC_REGISTRY_ALIAS` | Gitea repo secrets + Lightsail `.env` | CI tag + Compose image ref |
|
||||||
|
| `WEBHOOK_SECRET` | **AWS SSM SecureString** (`/lumina/webhook-secret`) | Deploy trigger auth |
|
||||||
|
| `ECR_REGISTRY_ALIAS` | **AWS SSM String** (`/lumina/ecr-registry-alias`) | Deploy script env var |
|
||||||
|
|
||||||
|
**Lightsail IAM Instance Profile** must have:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["ssm:GetParameter", "ssm:GetParametersByPath"],
|
||||||
|
"Resource": "arn:aws:ssm:us-east-1:<account-id>:parameter/lumina/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ECR Public Setup Steps
|
||||||
|
|
||||||
|
### 1. Create ECR Public Repository
|
||||||
|
```bash
|
||||||
|
aws ecr-public create-repository \
|
||||||
|
--repository-name lumina-frontend \
|
||||||
|
--region us-east-1
|
||||||
|
```
|
||||||
|
Note the `registryAlias` from output (e.g., `vkist-pilot`).
|
||||||
|
|
||||||
|
### 2. Create IAM User for CI
|
||||||
|
```bash
|
||||||
|
aws iam create-user --user-name gitea-ci-ecr-public
|
||||||
|
aws iam attach-user-policy \
|
||||||
|
--user-name gitea-ci-ecr-public \
|
||||||
|
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPublicFullAccess
|
||||||
|
# Or use custom least-privilege policy above
|
||||||
|
aws iam create-access-key --user-name gitea-ci-ecr-public
|
||||||
|
```
|
||||||
|
Save the Access Key ID and Secret Access Key.
|
||||||
|
|
||||||
|
### 3. Add Secrets to Gitea
|
||||||
|
Gitea UI → Repository → Settings → Secrets → Add:
|
||||||
|
- `AWS_ACCESS_KEY_ID`
|
||||||
|
- `AWS_SECRET_ACCESS_KEY`
|
||||||
|
- `AWS_ECR_REGION` = `us-east-1`
|
||||||
|
- `ECR_PUBLIC_REGISTRY_ALIAS` = your alias (e.g., `vkist-pilot`)
|
||||||
|
|
||||||
|
### 4. Store Secrets in SSM Parameter Store
|
||||||
|
```bash
|
||||||
|
# Webhook secret (generate once)
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "/lumina/webhook-secret" \
|
||||||
|
--value "$(openssl rand -hex 32)" \
|
||||||
|
--type "SecureString" \
|
||||||
|
--region us-east-1
|
||||||
|
|
||||||
|
# Registry alias (not sensitive, but convenient)
|
||||||
|
aws ssm put-parameter \
|
||||||
|
--name "/lumina/ecr-registry-alias" \
|
||||||
|
--value "vkist-pilot" \
|
||||||
|
--type "String" \
|
||||||
|
--region us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Attach IAM Instance Profile to Lightsail
|
||||||
|
AWS Console → Lightsail → Your Instance → Networking → Attach IAM instance profile → Select profile with SSM read access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Scenarios & Mitigations
|
||||||
|
|
||||||
|
| Scenario | Detection | Mitigation |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| CI build fails | Workflow red in Gitea | Fix code, re-push |
|
||||||
|
| ECR login fails | CI step fails | Check AWS credentials, permissions |
|
||||||
|
| Push fails (throttling) | CI step fails | Retry, check ECR Public rate limits |
|
||||||
|
| Pull fails on Lightsail | Deploy script logs | Check network, image tag exists |
|
||||||
|
| Container crash loop | Healthcheck fails | `docker logs lumina-frontend` |
|
||||||
|
| OOM on 2 GB VM | `free -h`, `docker stats` | Reduce `memory` limit, add swap |
|
||||||
|
| Caddy TLS fails | Caddy logs | Check DNS, port 80/443 reachable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On Lightsail: list available tags (requires AWS CLI for ECR Public)
|
||||||
|
aws ecr-public describe-images \
|
||||||
|
--repository-name lumina-frontend \
|
||||||
|
--region us-east-1 \
|
||||||
|
--query 'imageDetails[*].imageTags' \
|
||||||
|
--output text
|
||||||
|
|
||||||
|
# Rollback to specific SHA
|
||||||
|
docker tag public.ecr.aws/vkist-pilot/lumina-frontend:<old-sha> public.ecr.aws/vkist-pilot/lumina-frontend:latest
|
||||||
|
docker compose -f docker-compose.frontend.yaml up -d --force-recreate lumina-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Estimate (Monthly)
|
||||||
|
|
||||||
|
| Component | Estimate |
|
||||||
|
|-----------|----------|
|
||||||
|
| Lightsail 2 GB instance | $10–12 |
|
||||||
|
| ECR Public storage (first 50 GB) | Free |
|
||||||
|
| ECR Public data transfer (first 5 TB/mo to internet) | Free |
|
||||||
|
| Data transfer (Lightsail ↔ ECR Public) | < $1 |
|
||||||
|
| **Total** | **~$10–13 / month** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create ECR Public repository `lumina-frontend` in `us-east-1`
|
||||||
|
2. Create IAM user `gitea-ci-ecr-public` with ECR Public permissions
|
||||||
|
3. Add AWS credentials + registry alias to Gitea secrets
|
||||||
|
4. Store `WEBHOOK_SECRET` and `ECR_REGISTRY_ALIAS` in AWS SSM Parameter Store
|
||||||
|
5. Create `.gitea/workflows/frontend.yaml` (see appendix)
|
||||||
|
6. Create `Dockerfile` in frontend implementation dir
|
||||||
|
7. Deploy `docker-compose.frontend.yaml`, `Caddyfile`, `.env` to Lightsail
|
||||||
|
8. Attach IAM instance profile with SSM read access to Lightsail
|
||||||
|
9. Test end-to-end: push to `main` → verify CI → verify deploy + HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Workflow File for ECR Public
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/frontend.yaml
|
||||||
|
name: Frontend CI/CD (ECR Public)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'workspace/sprint_1_2/CODEBASE/frontend/implementation/**'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure AWS credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
aws-region: ${{ secrets.AWS_ECR_REGION }}
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR Public
|
||||||
|
id: login-ecr
|
||||||
|
uses: aws-actions/amazon-ecr-login@v2
|
||||||
|
with:
|
||||||
|
registry-type: public
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: workspace/sprint_1_2/CODEBASE/frontend/implementation/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
|
||||||
|
- name: Build frontend (verify)
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: public.ecr.aws/${{ secrets.ECR_PUBLIC_REGISTRY_ALIAS }}/lumina-frontend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
file: ./workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Minimal Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# workspace/sprint_1_2/CODEBASE/frontend/implementation/Dockerfile
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: nginx.conf for SPA (inside container)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# workspace/sprint_1_2/CODEBASE/frontend/implementation/nginx.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Cache static assets aggressively
|
||||||
|
location ~* \.(js|css|wasm|png|jpg|jpeg|svg|woff2|ico|map)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main location - SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/wasm;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Caddyfile (Lightsail)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# /opt/pilot-project/Caddyfile
|
||||||
|
# Replace app.example.com with your actual domain
|
||||||
|
|
||||||
|
app.example.com {
|
||||||
|
reverse_proxy lumina-frontend:80
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.wasm *.png *.jpg *.svg *.woff2 *.ico *.map
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document version: 2.0 (Caddy + SSM integration)*
|
||||||
|
*Last updated: 2026-07-17*
|
||||||
|
*Registry: Amazon ECR Public (us-east-1)*
|
||||||
|
*Reverse Proxy: Caddy (auto-HTTPS via Let's Encrypt)*
|
||||||
|
*Secrets: AWS SSM Parameter Store*
|
||||||
|
*Owner: Platform Engineering / VKIST Team*
|
||||||
4
workspace/sprint_1_2/CODEBASE/frontend/build_script.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
cd workspace/sprint_1_2/CODEBASE/frontend/implementation
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="heat" cx="55%" cy="42%" r="35%">
|
||||||
|
<stop offset="0%" stop-color="#ff4444" stop-opacity="0.8"/>
|
||||||
|
<stop offset="50%" stop-color="#ffaa00" stop-opacity="0.4"/>
|
||||||
|
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="heat2" cx="38%" cy="55%" r="25%">
|
||||||
|
<stop offset="0%" stop-color="#ff6600" stop-opacity="0.5"/>
|
||||||
|
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="350" cy="210" rx="120" ry="80" fill="url(#heat)"/>
|
||||||
|
<ellipse cx="250" cy="270" rx="70" ry="50" fill="url(#heat2)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Figma MSK_Workspace_UIX-Design node 244:2415 -->
|
||||||
|
<path
|
||||||
|
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
|
||||||
|
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
|
||||||
|
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
|
||||||
|
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 702 B |
@@ -0,0 +1 @@
|
|||||||
|
:root{--color-primary: #056b58;--color-on-primary: #ffffff;--color-primary-container: #76c8b1;--color-on-primary-container: #005344;--color-secondary: #23686c;--color-on-secondary: #ffffff;--color-secondary-container: #adeef2;--color-tertiary: #5f5983;--color-tertiary-container: #bab3e2;--color-error: #ba1a1a;--color-error-container: #ffdad6;--color-surface: #f5faf9;--color-surface-container: #eaefee;--color-surface-container-high: #e4e9e8;--color-on-surface: #171d1c;--color-on-surface-variant: #3e4945;--color-outline: #6f7975;--color-outline-variant: #bec9c4;--glass-bg: rgba(255, 255, 255, .55);--glass-border: rgba(5, 107, 88, .18);--glass-shadow: 0 10px 30px rgba(77, 141, 145, .08);--blur-glass: blur(12px);--radius-sm: .25rem;--radius-md: .75rem;--radius-lg: 1rem;--radius-full: 9999px;--font-headline: "Manrope", system-ui, sans-serif;--font-body: "Work Sans", system-ui, sans-serif;--space-sm: 8px;--space-md: 16px;--space-lg: 24px;--topbar-h: 56px;--bottombar-h: 40px}*,*:before,*:after{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{font-family:var(--font-body);font-size:16px;line-height:1.5;color:var(--color-on-surface);background:radial-gradient(ellipse at 20% 0%,rgba(118,200,177,.25),transparent 50%),radial-gradient(ellipse at 80% 100%,rgba(173,238,242,.2),transparent 45%),var(--color-surface);-webkit-font-smoothing:antialiased}h1,h2,h3,h4{font-family:var(--font-headline);margin:0}button,input,textarea,select{font:inherit}a{color:inherit;text-decoration:none}.glass{background:var(--glass-bg);backdrop-filter:var(--blur-glass);-webkit-backdrop-filter:var(--blur-glass);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow)}.glass-elevated{background:#ffffffb8;backdrop-filter:var(--blur-glass);-webkit-backdrop-filter:var(--blur-glass);border:1px solid rgba(5,107,88,.22);box-shadow:0 16px 40px #4d8d911f}.tnum{font-variant-numeric:tabular-nums}.app-shell{display:flex;flex-direction:column;min-height:100%}.app-main{flex:1;display:flex;flex-direction:column;min-height:0}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 18px;border-radius:var(--radius-md);border:none;cursor:pointer;font-weight:600;font-size:14px;transition:transform .15s,box-shadow .15s,opacity .15s}.btn:active{transform:scale(.98)}.btn-primary{background:var(--color-secondary);color:var(--color-on-secondary);box-shadow:inset 0 1px #fff3}.btn-primary:hover{background:#1a5559}.btn-secondary{background:var(--glass-bg);-webkit-backdrop-filter:var(--blur-glass);backdrop-filter:var(--blur-glass);color:var(--color-primary);border:1px solid var(--color-primary)}.btn-ghost{background:transparent;color:var(--color-on-surface-variant);border:1px solid var(--color-outline-variant)}.chip{display:inline-flex;align-items:center;padding:4px 10px;border-radius:var(--radius-full);font-size:12px;font-weight:600;letter-spacing:.02em}.chip-urgent{background:var(--color-error-container);color:var(--color-error)}.chip-processing{background:var(--color-secondary-container);color:var(--color-secondary)}.chip-followup{background:var(--color-tertiary-container);color:var(--color-tertiary)}.chip-success{background:var(--color-primary-container);color:var(--color-on-primary-container)}.icon-btn{width:40px;height:40px;border-radius:var(--radius-full);border:1px solid var(--glass-border);background:var(--glass-bg);-webkit-backdrop-filter:var(--blur-glass);backdrop-filter:var(--blur-glass);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--color-secondary)}.icon-btn:hover{border-color:var(--color-primary)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
|
||||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
import{j as a}from"./index-BCa3bqPy.js";function m({title:e="Lumina MSK",subtitle:t,showBack:n,onBack:r,actions:o,phase:s}){return a.jsxs("header",{className:"topbar glass",children:[a.jsxs("div",{className:"topbar__left",children:[n&&a.jsx("button",{type:"button",className:"icon-btn",onClick:r,"aria-label":"Quay lại",children:"←"}),a.jsxs("div",{className:"topbar__brand",children:[a.jsx("span",{className:"topbar__logo",children:"◈"}),a.jsxs("div",{children:[a.jsx("h1",{className:"topbar__title",children:e}),t&&a.jsx("p",{className:"topbar__subtitle",children:t})]})]})]}),a.jsx("div",{className:"topbar__center",children:s&&a.jsx("span",{className:"topbar__phase chip chip-success",children:s})}),a.jsx("div",{className:"topbar__actions",children:o}),a.jsx("style",{children:`
|
||||||
|
.topbar {
|
||||||
|
height: var(--topbar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
gap: var(--space-md);
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.topbar__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar__logo {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.topbar__title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.topbar__subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.topbar__center {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.topbar__phase {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`})]})}function b(){return a.jsxs("footer",{className:"bottombar glass",children:[a.jsx("span",{className:"bottombar__copyright",children:"© 2026, by VKIST"}),a.jsx("style",{children:`
|
||||||
|
.bottombar {
|
||||||
|
height: var(--bottombar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bottombar__copyright {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
`})]})}function N({children:e,topBar:t}){return a.jsxs("div",{className:"app-shell",children:[a.jsx(m,{...t}),a.jsx("main",{className:"app-main",children:e}),a.jsx(b,{})]})}const d="poc-v2-spec-cv-seg-norm",h=1440*60*1e3;function x(e,t,n,r=d){return`${e}|${t}|${n}|${r}`}function S(e,t,n=d){const r=[...t].sort().join(",");return`${e}|${n}|analyze:${r}`}async function E(e){const t=await fetch(e);if(!t.ok)throw new Error(`Failed to load image for hash (${t.status})`);const n=await t.arrayBuffer(),r=await crypto.subtle.digest("SHA-256",n);return Array.from(new Uint8Array(r)).map(o=>o.toString(16).padStart(2,"0")).join("")}const y="lumina-msk-ml",_=1,i="frame-inference-cache";function p(){return new Promise((e,t)=>{const n=indexedDB.open(y,_);n.onerror=()=>t(n.error??new Error("Failed to open ML cache IndexedDB")),n.onsuccess=()=>e(n.result),n.onupgradeneeded=()=>{const r=n.result;if(!r.objectStoreNames.contains(i)){const o=r.createObjectStore(i,{keyPath:"cacheKey"});o.createIndex("patientMrn","patientMrn",{unique:!1}),o.createIndex("frameId","frameId",{unique:!1})}}})}function l(e){return new Promise((t,n)=>{e.onsuccess=()=>t(e.result),e.onerror=()=>n(e.error??new Error("IndexedDB request failed"))})}function f(e){return e.pipelineVersion!==d?!1:Date.now()-e.cachedAt<h}async function g(e){try{const t=await p(),n=await l(t.transaction(i,"readonly").objectStore(i).get(e));return t.close(),!n||!f(n)?null:n}catch{return null}}async function I(e,t,n,r){try{const o=x(e,t,n),s=await g(o),c={cacheKey:o,patientMrn:e,frameId:t,contentHash:n,pipelineVersion:d,cachedAt:Date.now(),segmentation:r.segmentation!==void 0?r.segmentation:(s==null?void 0:s.segmentation)??null,angle:r.angle!==void 0?r.angle:(s==null?void 0:s.angle)??null},u=await p();await l(u.transaction(i,"readwrite").objectStore(i).put(c)),u.close()}catch{}}function v(e){const t=e==null?void 0:e.severity;if(!t||typeof t!="object")return null;const n=t.level;return typeof n!="number"||!Number.isFinite(n)?null:Math.min(3,Math.max(0,Math.round(n)))}async function w(e){var t;try{const n=await p(),r=await l(n.transaction(i,"readonly").objectStore(i).index("patientMrn").getAll(e));n.close();let o=null;for(const s of r){if(!f(s))continue;const c=v((t=s.segmentation)==null?void 0:t.raw);c!=null&&(o===null||c>o)&&(o=c)}return o}catch{return null}}async function M(e){const t=[...new Set(e)],n=await Promise.all(t.map(async r=>[r,await w(r)]));return Object.fromEntries(n)}async function A(e){try{const t=await p(),r=t.transaction(i,"readwrite").objectStore(i),o=r.index("patientMrn"),s=await l(o.getAllKeys(e));for(const c of s)await l(r.delete(c));t.close()}catch{}}export{N as A,x as a,S as b,g as c,A as d,E as h,M as l,I as m};
|
||||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
|
||||||
|
<path
|
||||||
|
d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#056b58"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-dasharray="8 4"
|
||||||
|
opacity="0.85"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="340"
|
||||||
|
cy="230"
|
||||||
|
rx="55"
|
||||||
|
ry="35"
|
||||||
|
fill="#76c8b1"
|
||||||
|
opacity="0.35"
|
||||||
|
stroke="#056b58"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<text x="310" y="235" fill="#ffffff" font-family="sans-serif" font-size="11" font-weight="600">M<EFBFBD>ng H</text>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Stitch Project Assets
|
||||||
|
|
||||||
|
**Project:** MSK Collaborative Workspace
|
||||||
|
**Project ID:** `17628337625105266704`
|
||||||
|
|
||||||
|
| Screen | Screen ID | Route mapping |
|
||||||
|
|--------|-----------|---------------|
|
||||||
|
| Danh sách công việc - Lumina MSK | `9d638112797c4b8d8a100d526bab43f2` | `/` (Patient Worklist) |
|
||||||
|
| Chế độ Kiểm tra Lâm sàng - Lumina MSK (VN) | `fa207d6a4f684f32a3b99706275daad9` | `/workspace/:id` (Clinical Review) |
|
||||||
|
| Không gian chẩn đoán - AI Review (VN) | `03c138643dd9427aaa59a6e16d089793` | Safety overlay state |
|
||||||
|
| Chế độ siêu âm gốc - Metadata Tinh gọn (Refined Raw View) | `85ece6c4ed6f4c179042b59813237d44` | Segmentation legend bubble on `DiagnosticCanvas` |
|
||||||
|
| Annotation Workspace with AI Hub | `3bdfcf6ba964447d88b98c8b9b0df9f1` | Floating `AnnotationRibbon` on `DiagnosticCanvas` |
|
||||||
|
|
||||||
|
## Fetching assets
|
||||||
|
|
||||||
|
Stitch API requires OAuth2 (`STITCH_ACCESS_TOKEN` + `GOOGLE_CLOUD_PROJECT`), not a generic API key.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using @google/stitch-sdk (after OAuth setup)
|
||||||
|
npm install @google/stitch-sdk
|
||||||
|
STITCH_ACCESS_TOKEN=... GOOGLE_CLOUD_PROJECT=... node scripts/fetch-stitch.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Stitch MCP `get_screen` with `projectId` and `screenId`, then download:
|
||||||
|
- `htmlCode.downloadUrl` → save to `public/assets/stitch/<screen>/index.html`
|
||||||
|
- `screenshot.downloadUrl` → save to `public/assets/stitch/<screen>/screenshot.png`
|
||||||
|
|
||||||
|
## Local placeholders
|
||||||
|
|
||||||
|
This mockup uses handcrafted SVG assets in `public/assets/` aligned with `spec/DESIGN.md` tokens, since Stitch OAuth was unavailable during build.
|
||||||
|
|
||||||
|
Reference thumbnails (design intent):
|
||||||
|
- `worklist-reference.svg`
|
||||||
|
- `clinical-review-reference.svg`
|
||||||
|
- `ai-review-reference.svg`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||||
|
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)"/>
|
||||||
|
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" opacity="0.45"/>
|
||||||
|
<rect x="60" y="80" width="280" height="180" fill="rgba(255,255,255,0.85)" stroke="#ba1a1a" stroke-width="2" rx="12"/>
|
||||||
|
<text x="80" y="110" fill="#ba1a1a" font-family="sans-serif" font-size="12" font-weight="700">Ch<EFBFBD> <11> Leo thang An to<74>n</text>
|
||||||
|
<text x="80" y="140" fill="#3e4945" font-family="sans-serif" font-size="11"><10>i tho<68>i Socratic</text>
|
||||||
|
<rect x="80" y="155" width="240" height="40" fill="#f0f5f4" rx="6" stroke="#bec9c4"/>
|
||||||
|
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)"/>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||||
|
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)"/>
|
||||||
|
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" stroke="rgba(5,107,88,0.18)"/>
|
||||||
|
<rect x="268" y="52" width="116" height="232" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.22)" rx="12"/>
|
||||||
|
<text x="280" y="80" fill="#23686c" font-family="sans-serif" font-size="10" font-weight="600"><10> XU<58>T AI</text>
|
||||||
|
<circle cx="326" cy="110" r="20" fill="#e8a838"/>
|
||||||
|
<text x="320" y="115" fill="#fff" font-family="sans-serif" font-size="16" font-weight="700">2</text>
|
||||||
|
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 827 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||||
|
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
|
||||||
|
<rect x="16" y="16" width="368" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="8"/>
|
||||||
|
<text x="32" y="42" fill="#23686c" font-family="sans-serif" font-size="14" font-weight="700">Danh s<>ch c<>ng vi<76>c</text>
|
||||||
|
<rect x="16" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||||
|
<rect x="136" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||||
|
<rect x="256" y="68" width="128" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||||
|
<rect x="16" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||||
|
<rect x="204" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
|
||||||
|
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
|
||||||
|
<text x="16" y="292" fill="#6f7975" font-family="sans-serif" font-size="10">NFR <20> EMR</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1 @@
|
|||||||
|
function w(n){return[...n].sort((t,e)=>t.relTime!==e.relTime?t.relTime-e.relTime:t.timestamp-e.timestamp)}const f="UIX_Agent_Recorder",a="pending_chunks",p=1,I=1440*60*1e3;function i(n){return new Promise((t,e)=>{n.onsuccess=()=>t(n.result),n.onerror=()=>e(n.error??new Error("IndexedDB request failed"))})}function d(){return new Promise((n,t)=>{const e=indexedDB.open(f,p);e.onerror=()=>t(e.error??new Error("Failed to open UIX WAL IndexedDB")),e.onupgradeneeded=()=>{const o=e.result;if(!o.objectStoreNames.contains(a)){const r=o.createObjectStore(a,{keyPath:"chunkId"});r.createIndex("patientId","patientId",{unique:!1}),r.createIndex("sessionId","sessionId",{unique:!1}),r.createIndex("createdAt","createdAt",{unique:!1})}},e.onsuccess=()=>n(e.result)})}async function k(n){const t=await d();try{const e=t.transaction(a,"readwrite");await i(e.objectStore(a).put(n)),await new Promise((o,r)=>{e.oncomplete=()=>o(),e.onerror=()=>r(e.error??new Error("WAL put transaction failed"))})}finally{t.close()}}async function y(n){const t=await d();try{const e=t.transaction(a,"readwrite");await i(e.objectStore(a).delete(n)),await new Promise((o,r)=>{e.oncomplete=()=>o(),e.onerror=()=>r(e.error??new Error("WAL delete transaction failed"))})}finally{t.close()}}async function _(n){const t=await d();try{const e=t.transaction(a,"readonly"),o=e.objectStore(a).index("patientId"),r=await i(o.getAll(n));return await new Promise((c,s)=>{e.oncomplete=()=>c(),e.onerror=()=>s(e.error??new Error("WAL read transaction failed"))}),r.sort((c,s)=>c.createdAt-s.createdAt)}finally{t.close()}}async function h(){const n=Date.now()-I,t=await d();let e=0;try{const o=t.transaction(a,"readwrite"),r=o.objectStore(a),c=await i(r.getAll());for(const s of c)s.createdAt<n&&(await i(r.delete(s.chunkId)),e+=1);await new Promise((s,l)=>{o.oncomplete=()=>s(),o.onerror=()=>l(o.error??new Error("WAL purge transaction failed"))})}finally{t.close()}return e}async function m(n){return!0}async function u(n){try{await m(n)&&await y(n.chunkId)}catch{}}async function A(n){const t=w(n.chunk.events),e={...n.chunk,events:t};await k(e),self.postMessage({type:"wal_committed",requestId:n.requestId,chunkId:e.chunkId,events:t}),u(e)}async function g(n){const t=await _(n.patientId);for(const e of t)u(e);self.postMessage({type:"wal_recovered",requestId:n.requestId,patientId:n.patientId,chunks:t})}async function b(n){const t=await h();self.postMessage({type:"wal_purge_stale_done",requestId:n.requestId,removedCount:t})}self.onmessage=n=>{const t=n.data;if(!(t!=null&&t.requestId))return;const e=r=>{self.postMessage({type:"wal_error",requestId:t.requestId,message:r})};(async()=>{switch(t.type){case"wal_flush":await A(t);break;case"wal_recover_patient":await g(t);break;case"wal_purge_stale":await b(t);break;default:e("Unknown WAL worker message type")}})().catch(r=>{e(r instanceof Error?r.message:"WAL worker operation failed")})};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="us-bg" cx="50%" cy="45%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="#2a3533"/>
|
||||||
|
<stop offset="100%" stop-color="#0a0f0e"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="noise">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/>
|
||||||
|
<feColorMatrix type="saturate" values="0"/>
|
||||||
|
<feBlend in="SourceGraphic" mode="multiply"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="640" height="480" fill="url(#us-bg)"/>
|
||||||
|
<ellipse cx="320" cy="240" rx="220" ry="170" fill="#1a2422" opacity="0.6"/>
|
||||||
|
<path d="M180 200 Q260 120 400 180 T520 280 Q420 360 280 340 T160 260 Z" fill="#3d4a47" opacity="0.5" filter="url(#noise)"/>
|
||||||
|
<path d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z" fill="#5a6b66" opacity="0.35"/>
|
||||||
|
<ellipse cx="340" cy="230" rx="45" ry="28" fill="#8a9a94" opacity="0.25"/>
|
||||||
|
<text x="20" y="30" fill="#76c8b1" font-family="monospace" font-size="14">SI<EFBFBD>U <20>M <20> KH<4B>P G<>I</text>
|
||||||
|
<text x="20" y="460" fill="#6f7975" font-family="monospace" font-size="12">12.5 MHz <20> Gain 62</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lumina MSK — Không gian Lâm sàng</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Manrope:wght@600;700&family=Work+Sans:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BCa3bqPy.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BWW3uBCb.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-LQC2ade3.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Stage 1: Build
|
||||||
|
FROM node:26-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install scripts first (better cache)
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime (nginx)
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Remove default nginx static files
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# Copy built assets from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Custom nginx config for SPA routing + caching
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
# Frontend application configuration
|
# Frontend application configuration
|
||||||
# This is the single source of truth for frontend feature flags and URLs.
|
# This is the single source of truth for frontend feature flags and URLs.
|
||||||
# Edit this file directly instead of using .env / .env.development.
|
# Edit this file directly instead of using .env / .env.development.
|
||||||
|
#
|
||||||
|
# Deployment note: the backend servers (main.py, cv_inference_server.py) enforce
|
||||||
|
# CORS via the CORS_ORIGINS environment variable. Make sure the origins listed here
|
||||||
|
# (especially the production frontend origin) are included in that env var.
|
||||||
|
|
||||||
VITE_USE_BACKEND_SEGMENTATION: "true"
|
VITE_USE_BACKEND_SEGMENTATION: "true"
|
||||||
VITE_SEGMENT_API_BASE: ""
|
# Leave empty in dev — Vite proxy handles /api/* → backend.
|
||||||
|
# Set to your production API origin (e.g. "https://api.your-domain.com") for builds.
|
||||||
|
VITE_API_BASE_URL: "https://api.lumina-msk.io.vn"
|
||||||
|
# Leave empty in dev — Vite proxy handles /api/test/* → CV inference server.
|
||||||
|
# Set to your production CV inference origin (e.g. "https://cv.your-domain.com") for builds.
|
||||||
|
VITE_SEGMENT_API_BASE: "https://cv.lumina-msk.io.vn"
|
||||||
|
# Comma-separated list of origins the frontend may be served from.
|
||||||
|
# Used by deployment scripts to populate backend CORS_ORIGINS env var.
|
||||||
|
VITE_ALLOWED_ORIGINS: "https://lumina-msk.io.vn,https://www.lumina-msk.io.vn"
|
||||||
VITE_USE_CV_CELERY: "false"
|
VITE_USE_CV_CELERY: "false"
|
||||||
VITE_API_BASE_URL: ""
|
|
||||||
VITE_CLINICAL_CHAT_USE_LLM: "true"
|
VITE_CLINICAL_CHAT_USE_LLM: "true"
|
||||||
VITE_CLINICAL_CHAT_MOCK_TOOLS: "true"
|
VITE_CLINICAL_CHAT_MOCK_TOOLS: "true"
|
||||||
VITE_OLLAMA_CHAT_URL: "/api/ollama-chat/api/chat"
|
VITE_OLLAMA_CHAT_URL: "/api/ollama-chat/api/chat"
|
||||||
VITE_OLLAMA_MODEL: "gemma4:e4b"
|
VITE_OLLAMA_MODEL: "gemma4:e4b"
|
||||||
VITE_MODAL_OLLAMA_TARGET: "https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run"
|
VITE_MODAL_OLLAMA_TARGET: "https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run"
|
||||||
|
VITE_DISTRIBUTE_DIRRECTORY: "workspace/sprint_1_2/CODEBASE/frontend/distribute_asset"
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { PatientStoreProvider } from './lib/patientStore';
|
import { PatientStoreProvider } from './lib/patientStore';
|
||||||
import PatientWorklistPage from './pages/PatientWorklistPage';
|
|
||||||
import ClinicalWorkspacePage from './pages/ClinicalWorkspacePage';
|
const PatientWorklistPage = React.lazy(() => import('./pages/PatientWorklistPage'));
|
||||||
|
const ClinicalWorkspacePage = React.lazy(() => import('./pages/ClinicalWorkspacePage'));
|
||||||
|
|
||||||
|
function LoadingFallback() {
|
||||||
|
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<PatientStoreProvider>
|
<PatientStoreProvider>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PatientWorklistPage />} />
|
<Route path="/" element={<PatientWorklistPage />} />
|
||||||
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
|
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</PatientStoreProvider>
|
</PatientStoreProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ export async function probeCapabilities(): Promise<CapabilityReport> {
|
|||||||
if (!secureContext) {
|
if (!secureContext) {
|
||||||
notes.push('Not a secure context. Use https:// or http://localhost.');
|
notes.push('Not a secure context. Use https:// or http://localhost.');
|
||||||
}
|
}
|
||||||
if (location.hostname === '127.0.0.1') {
|
if (opfs && location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||||
notes.push('OPFS is origin-scoped: 127.0.0.1 and localhost use separate storage.');
|
notes.push(
|
||||||
|
'OPFS is origin-scoped: caches are isolated per scheme+host+port. ' +
|
||||||
|
'Models downloaded on one origin (e.g. localhost, 127.0.0.1, preview domains) ' +
|
||||||
|
'are not available on another. Expect a full re-download when the origin changes.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = webgpu && opfs && worker && secureContext;
|
const ready = webgpu && opfs && worker && secureContext;
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export const BOOTSTRAP_DECODE: DecodeParams = {
|
|||||||
maxTokens: MIN_COT_MAX_TOKENS,
|
maxTokens: MIN_COT_MAX_TOKENS,
|
||||||
};
|
};
|
||||||
|
|
||||||
function envFlag(name: string, defaultValue: boolean): boolean {
|
// function envFlag(name: string, defaultValue: boolean): boolean {
|
||||||
const raw = import.meta.env[name];
|
// const raw = import.meta.env[name];
|
||||||
if (raw === undefined || raw === '') {
|
// if (raw === undefined || raw === '') {
|
||||||
return defaultValue;
|
// return defaultValue;
|
||||||
}
|
// }
|
||||||
return raw === '1' || raw.toLowerCase() === 'true';
|
// return raw === '1' || raw.toLowerCase() === 'true';
|
||||||
}
|
// }
|
||||||
|
|
||||||
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
|
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
|
||||||
export function useLocalLlmWhenAvailable(): boolean {
|
export function useLocalLlmWhenAvailable(): boolean {
|
||||||
@@ -33,7 +33,19 @@ export function useMockAgentTools(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clinicalChatBffBaseUrl(): string {
|
export function clinicalChatBffBaseUrl(): string {
|
||||||
return import.meta.env.VITE_API_BASE_URL ?? '';
|
const raw = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const trimmed = raw != null ? String(raw).trim() : '';
|
||||||
|
if (trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
'[clinicalChatConfig] VITE_API_BASE_URL is not set. ' +
|
||||||
|
'Clinical chat BFF calls will fail. Set it in config/frontend.config.yaml for production builds.',
|
||||||
|
);
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OLLAMA_CHAT_URL = '/api/ollama-chat/api/chat';
|
const DEFAULT_OLLAMA_CHAT_URL = '/api/ollama-chat/api/chat';
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export async function runDirectChatTurn(
|
|||||||
promptOptions,
|
promptOptions,
|
||||||
decode,
|
decode,
|
||||||
input.onToken,
|
input.onToken,
|
||||||
input.onSegmentStart,
|
// input.onSegmentStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export interface SegmentationApiResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Resolve API base: dev uses Vite proxy (empty); preview/build targets local test server. */
|
/** Resolve API base: dev uses Vite proxy (empty); production uses configured URL. */
|
||||||
export function getSegmentApiBase(): string {
|
export function getSegmentApiBase(): string {
|
||||||
const configured = import.meta.env.VITE_SEGMENT_API_BASE;
|
const configured = import.meta.env.VITE_SEGMENT_API_BASE;
|
||||||
if (configured != null && String(configured).trim() !== '') {
|
if (configured != null && String(configured).trim() !== '') {
|
||||||
@@ -80,7 +80,11 @@ export function getSegmentApiBase(): string {
|
|||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return 'http://127.0.0.1:8001';
|
console.warn(
|
||||||
|
'[segmentationApi] VITE_SEGMENT_API_BASE is not set. ' +
|
||||||
|
'Segmentation API calls will fail. Set it in config/frontend.config.yaml for production builds.',
|
||||||
|
);
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ML inference always uses the test proxy / production API — mock path detached. */
|
/** ML inference always uses the test proxy / production API — mock path detached. */
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ const MODAL_OLLAMA_TARGET =
|
|||||||
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
|
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
|
||||||
'https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run';
|
'https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run';
|
||||||
|
|
||||||
|
// API base URLs from YAML — empty in dev so Vite proxy handles them.
|
||||||
|
const apiBaseUrl = (frontendConfig.VITE_API_BASE_URL ?? '').trim();
|
||||||
|
const segmentApiBase = (frontendConfig.VITE_SEGMENT_API_BASE ?? '').trim();
|
||||||
|
|
||||||
|
// Resolve build output directory from YAML config, falling back to 'dist'
|
||||||
|
function findProjectRoot(startDir: string): string {
|
||||||
|
let current = startDir;
|
||||||
|
while (current !== path.parse(current).root) {
|
||||||
|
if (fs.existsSync(path.join(current, '.git'))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
return startDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = findProjectRoot(__dirname);
|
||||||
|
const configuredOutDir = frontendConfig.VITE_DISTRIBUTE_DIRRECTORY;
|
||||||
|
const buildOutDir = configuredOutDir
|
||||||
|
? path.isAbsolute(configuredOutDir)
|
||||||
|
? configuredOutDir
|
||||||
|
: path.resolve(projectRoot, configuredOutDir)
|
||||||
|
: path.resolve(__dirname, 'dist');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
worker: {
|
worker: {
|
||||||
@@ -33,7 +57,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@vkist/agent-runtime': path.resolve(
|
'@vkist/agent-runtime': path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../../ml/implementation/nlp/agent_runtime/src/index.ts',
|
'../../ml/implementation/nlp/agent_runtime/src'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -41,7 +65,7 @@ export default defineConfig({
|
|||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
// Keep HMR WebSocket on the same host/port as the page (avoids localhost vs 127.0.0.1 mismatch).
|
// Keep HMR WebSocket consistent (avoid localhost vs 127.0.0.1 mismatch).
|
||||||
hmr: {
|
hmr: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
@@ -58,18 +82,31 @@ export default defineConfig({
|
|||||||
proxyTimeout: 300_000,
|
proxyTimeout: 300_000,
|
||||||
},
|
},
|
||||||
'/api/test': {
|
'/api/test': {
|
||||||
target: 'http://127.0.0.1:8001',
|
target: segmentApiBase || 'http://127.0.0.1:8001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
proxyTimeout: 300_000,
|
proxyTimeout: 300_000,
|
||||||
},
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8001',
|
target: apiBaseUrl || 'http://127.0.0.1:8001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
proxyTimeout: 300_000,
|
proxyTimeout: 300_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
outDir: buildOutDir,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'vendor-ml': ['@litert-lm/core', '@mediapipe/tasks-genai'],
|
||||||
|
'agent-runtime': ['@vkist/agent-runtime'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 250,
|
||||||
|
},
|
||||||
define: defineVars,
|
define: defineVars,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Automatically generated by https://github.com/damnever/pigar.
|
# Generated by pigar — manually cleaned to remove false positives.
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||||
|
torch==2.12.1
|
||||||
|
torchvision==0.27.1
|
||||||
|
torchinfo==1.8.0
|
||||||
aiosqlite==0.22.1
|
aiosqlite==0.22.1
|
||||||
docling==2.70.0
|
docling==2.70.0
|
||||||
fastapi==0.135.1
|
fastapi==0.135.1
|
||||||
@@ -7,14 +10,13 @@ fastembed==0.8.0
|
|||||||
gliner==0.2.27
|
gliner==0.2.27
|
||||||
grpcio==1.81.1
|
grpcio==1.81.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
ingestion==0.0.42
|
|
||||||
langchain==1.3.7
|
langchain==1.3.7
|
||||||
langchain-text-splitters==1.1.2
|
langchain-text-splitters==1.1.2
|
||||||
modal==1.5.0
|
modal==1.5.0
|
||||||
numpy==2.1.3
|
numpy==2.1.3
|
||||||
opencv-python==4.13.0.92
|
opencv-python==4.13.0.92
|
||||||
pgvector==0.4.2
|
pgvector==0.4.2
|
||||||
pillow==12.3.0
|
pillow>=10.0.0,<12.0.0
|
||||||
psycopg2-binary==2.9.12
|
psycopg2-binary==2.9.12
|
||||||
pycocotools==2.0.11
|
pycocotools==2.0.11
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
@@ -28,76 +30,11 @@ requests==2.34.2
|
|||||||
starlette==1.2.1
|
starlette==1.2.1
|
||||||
supabase==2.31.0
|
supabase==2.31.0
|
||||||
timm==1.0.25
|
timm==1.0.25
|
||||||
torch==2.12.1
|
|
||||||
torchinfo==1.8.0
|
|
||||||
torchvision==0.27.1
|
|
||||||
transformers==4.57.6
|
transformers==4.57.6
|
||||||
tritonclient==2.69.0
|
tritonclient==2.69.0
|
||||||
uvicorn==0.41.0
|
uvicorn==0.41.0
|
||||||
|
|
||||||
# WARNING(pigar): some manual fixes might be required as pigar has detected duplicate requirements for the same import name (possibly for different submodules).
|
|
||||||
# WARNING(pigar): the following duplicate requirements are for the import name: backend
|
|
||||||
agentbeats==1.2.6
|
|
||||||
AlgoVision-Quant-Research==0.0.2
|
|
||||||
alles-apin==0.0.1
|
|
||||||
asamba==1.0.8
|
|
||||||
backend==0.2.4.1
|
|
||||||
backendcatraca-tektek==0.0.7
|
|
||||||
BackupBackup==0.0.6
|
|
||||||
cinder-ml==1.8.5
|
|
||||||
cmbagent==0.0.1.post63
|
|
||||||
conting-researcher==0.14.0
|
|
||||||
cwyod-base==0.0.2
|
|
||||||
deepread==0.0.3
|
|
||||||
devshare==1.0.0
|
|
||||||
django-nextcloud-storage==2024.5.20
|
|
||||||
doccano==1.8.5
|
|
||||||
doccano-multi-label==1.8.4.2
|
|
||||||
docforge-ai==2.0.3
|
|
||||||
equation-phase-portrait-tool==0.1.0
|
|
||||||
excellxgene==2.9.6
|
|
||||||
flaskreactapp==1.0.21
|
|
||||||
flet-devtools==0.0.0
|
|
||||||
food-fighters==1.0.1
|
|
||||||
Fred-Frechet==1.14.5
|
|
||||||
gpt-researcher==0.15.1
|
|
||||||
hermes-revision-system==0.1.0
|
|
||||||
hotel-spider==1.1.5
|
|
||||||
latch-excellxgene==1.0.0
|
|
||||||
leap-backend==0.1.0
|
|
||||||
lemon-auto-saver==1.0.1
|
|
||||||
LPP0D==0.1.1
|
|
||||||
mcp-feedback-pipe==3.0.15
|
|
||||||
mmsbm==1.0.7
|
|
||||||
nia-cli==0.1.1
|
|
||||||
nmuwd==0.10.3
|
|
||||||
omnbot==2017.5
|
|
||||||
personal-site-msilvasy==1.0.5
|
|
||||||
pipebio==5.1.0
|
|
||||||
prastut-ai==1.0.0
|
|
||||||
productiongraph==0.2.0
|
|
||||||
pyapptest==1.0.0
|
|
||||||
python-backend==0.0.1
|
|
||||||
pyWikiCMS==0.5.0
|
|
||||||
quantum-finance==0.2.0
|
|
||||||
qutritium==1.5.2
|
|
||||||
rasa-storyteller==0.2.1
|
|
||||||
reapply-workflows==0.1.0
|
|
||||||
recall-core==0.1.1
|
|
||||||
Researcher==0.4.3
|
|
||||||
reusing-intent==0.1.2
|
|
||||||
sparseprop==0.1.14
|
|
||||||
terminal-note==0.10.0
|
|
||||||
traiNER-loop==0.1.0
|
|
||||||
typernexrad-cli==0.1.0
|
|
||||||
viasp==2.3.0.post3
|
|
||||||
vibe-reader==0.2.1
|
|
||||||
work-with-database==0.0.1
|
|
||||||
x-lib==0.0.27
|
|
||||||
# WARNING(pigar): the following duplicate requirements are for the import name: langchain_google_vertexai
|
|
||||||
langchain-google-vertexai==3.2.4
|
langchain-google-vertexai==3.2.4
|
||||||
# WARNING(pigar): the following duplicate requirements are for the import name: optimum
|
|
||||||
optimum==2.1.0
|
optimum==2.1.0
|
||||||
optimum-onnx==0.1.0
|
optimum-onnx==0.1.0
|
||||||
Celery==5.6.3
|
celery==5.6.3
|
||||||
redis==8.0.1
|
python-multipart==0.0.22
|
||||||