test_workflow
Some checks failed
Backend ECR Deployment / build-and-push (push) Has been cancelled
Backend ECR Deployment / notify-deploy (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
Backend Modal Build / Build & Push via Modal (push) Has been cancelled

This commit is contained in:
DatTT127
2026-07-18 17:48:19 +07:00
parent 7d5c583475
commit 57a8bac1be
87 changed files with 36291 additions and 155 deletions

View 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

View 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

View 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

View File

@@ -1,7 +1,8 @@
# What is the purpose of this workflows
# for a modal GPU instance that hosting the CV model's on Nvidia Triton
name: Triton Modal Trigger
on: [push]
on: workflow_dispatch # set to on [push] for check after each code push what happens
jobs:
deploy-to-modal:

View 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

View 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).
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_PORT bind port (default 8001)
ANGLE_MODEL / INFLAMMATION_MODEL / SEGMENT_MODEL optional overrides
CV_PIPELINE_VERSION cache invalidation fingerprint (default poc-v2-spec-cv)
CORS_ORIGINS comma-separated allowed origins
"""
from __future__ import annotations
import logging
import os
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
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import asyncio
from backend.implementation.config import settings
from backend.logging.logging_config import setup_logging
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
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
warmup_task = asyncio.create_task(warmup_triton_models())
@@ -62,10 +64,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:4173,http://127.0.0.1:5173",
).split(","),
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -79,11 +78,18 @@ app = create_app()
def main() -> None:
logging.basicConfig(level=logging.INFO)
host = os.getenv("CV_INFERENCE_HOST", os.getenv("SEGMENT_TEST_HOST", "127.0.0.1"))
port = int(os.getenv("CV_INFERENCE_PORT", os.getenv("SEGMENT_TEST_PORT", "8001")))
logger.info("CV inference service listening on %s:%s", host, port)
uvicorn.run(app, host=host, port=port, log_level="info")
"""Entrypoint used by Docker ENTRYPOINT and local development."""
logger.info(
"CV inference service listening on %s:%s",
settings.cv_inference_host,
settings.cv_inference_port,
)
uvicorn.run(
app,
host=settings.cv_inference_host,
port=settings.cv_inference_port,
log_level="info",
)
if __name__ == "__main__":

View File

@@ -1,47 +1,131 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Dict
from typing import Dict, List, Optional, Tuple
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
env_file = os.getenv(f"{name}_FILE")
if env_file:
resolved = Path(env_file)
if resolved.exists():
with open(resolved, "r", encoding="utf-8") as f:
return f.read().strip()
if file_path.exists():
with open(file_path, "r", encoding="utf-8") as f:
return f.read().strip()
raise RuntimeError(
f"Required secret {name} not found at {file_path} or via {name}_FILE env var"
def _require_env(name: str) -> str:
"""Require a secret from environment variable only.
In production/Gitea Actions, this comes from repository secrets.
No file fallback to avoid accidental secret leakage into the repo.
"""
value = os.getenv(name)
if not value:
raise RuntimeError(
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")
VERTEX_AI_GEMINI_ENDPOINT = os.getenv("VERTEX_AI_GEMINI_ENDPOINT")
# Secrets (must be present in PILOT_PROJECT/secrets or env)
GCP_ACCESS_TOKEN = _load_secret("GCP_ACCESS_TOKEN", "gcp_access_token.txt")
MEDGEMMA_API_KEY = _load_secret("MEDGEMMA_API_KEY", "modal_api_key.txt")
# Secrets - must come from environment variables only
# In Gitea Actions, these are set via repository secrets
# 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")
LOCATION = os.getenv("VERTEX_AI_LOCATION", "asia-southeast1")
# Legacy module-level constants for backward compatibility.
# 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")
TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/analysis_jobs")
TRITON_ENDPOINT = (
str(settings.triton_endpoint).rstrip("/") if settings.triton_endpoint else None
)
# LLM Configuration
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")
TEMP_DIR = settings.temp_dir
# Redis Configuration
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
VERTEX_AI_PROJECT = settings.project_id
VERTEX_AI_LOCATION = settings.location
VERTEX_AI_MODEL = settings.vertex_ai_model
REDIS_HOST = settings.redis_host
REDIS_PORT = settings.redis_port
REDIS_DB = settings.redis_db
DEFAULT_MODEL_VERSIONS = {
"angle": "angle_classify_convnext_tiny",
@@ -50,8 +134,8 @@ DEFAULT_MODEL_VERSIONS = {
"segmentation_post": "segmentation_model_post_deeplabv3_resnet101",
}
CLAHE_CLIP_LIMIT = float(os.getenv("CLAHE_CLIP_LIMIT", "2.0"))
CLAHE_TILE_SIZE = tuple(int(x) for x in os.getenv("CLAHE_TILE_SIZE", "8,8").split(","))
CLAHE_CLIP_LIMIT = settings.clahe_clip_limit
CLAHE_TILE_SIZE = settings.clahe_tile_size
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)
task = "segmentation_sup" if angle_type == "sup" else "segmentation_post"
return get_model_name(task, model_versions)

View File

@@ -3,7 +3,7 @@ import httpx
import json
from typing import AsyncGenerator
from datetime import datetime
import asyncio
from backend.implementation.adapters.redis_adapter import get_redis_client
from backend.implementation.adapters.llm_adapter import get_llm_adapter, AuditCallbackHandler
from backend.implementation.config import (

View File

@@ -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"]

View File

@@ -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

View File

@@ -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")

View 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 "$@"

View File

@@ -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 "$@"

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 "$@"

View File

@@ -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()

View File

@@ -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*

View File

@@ -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 | $1012 |
| Registry (Docker Hub free / GHCR) | $05 |
| Data transfer | < $1 |
| **Total** | **~$1018 / 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*

View File

@@ -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 | $1012 |
| 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** | **~$1013 / 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*

View File

@@ -0,0 +1,4 @@
cd workspace/sprint_1_2/CODEBASE/frontend/implementation
npm install
npm run build
npm run preview

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -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};

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -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>

View File

@@ -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`

View File

@@ -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>

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -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")})};

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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;"]

View File

@@ -1,13 +1,25 @@
# Frontend application configuration
# This is the single source of truth for frontend feature flags and URLs.
# 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_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_API_BASE_URL: ""
VITE_CLINICAL_CHAT_USE_LLM: "true"
VITE_CLINICAL_CHAT_MOCK_TOOLS: "true"
VITE_OLLAMA_CHAT_URL: "/api/ollama-chat/api/chat"
VITE_OLLAMA_MODEL: "gemma4:e4b"
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"

View File

@@ -1,16 +1,24 @@
import React, { Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
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() {
return (
<PatientStoreProvider>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</PatientStoreProvider>
);
}

View File

@@ -19,8 +19,12 @@ export async function probeCapabilities(): Promise<CapabilityReport> {
if (!secureContext) {
notes.push('Not a secure context. Use https:// or http://localhost.');
}
if (location.hostname === '127.0.0.1') {
notes.push('OPFS is origin-scoped: 127.0.0.1 and localhost use separate storage.');
if (opfs && location.protocol !== 'https:' && location.hostname !== 'localhost') {
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;

View File

@@ -14,13 +14,13 @@ export const BOOTSTRAP_DECODE: DecodeParams = {
maxTokens: MIN_COT_MAX_TOKENS,
};
function envFlag(name: string, defaultValue: boolean): boolean {
const raw = import.meta.env[name];
if (raw === undefined || raw === '') {
return defaultValue;
}
return raw === '1' || raw.toLowerCase() === 'true';
}
// function envFlag(name: string, defaultValue: boolean): boolean {
// const raw = import.meta.env[name];
// if (raw === undefined || raw === '') {
// return defaultValue;
// }
// return raw === '1' || raw.toLowerCase() === 'true';
// }
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
export function useLocalLlmWhenAvailable(): boolean {
@@ -33,7 +33,19 @@ export function useMockAgentTools(): boolean {
}
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';

View File

@@ -216,7 +216,7 @@ export async function runDirectChatTurn(
promptOptions,
decode,
input.onToken,
input.onSegmentStart,
// input.onSegmentStart,
);
if (signal?.aborted) {

View File

@@ -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 {
const configured = import.meta.env.VITE_SEGMENT_API_BASE;
if (configured != null && String(configured).trim() !== '') {
@@ -80,7 +80,11 @@ export function getSegmentApiBase(): string {
if (import.meta.env.DEV) {
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. */

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,30 @@ const MODAL_OLLAMA_TARGET =
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
'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({
plugins: [react()],
worker: {
@@ -33,7 +57,7 @@ export default defineConfig({
alias: {
'@vkist/agent-runtime': path.resolve(
__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',
port: 5173,
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: {
host: '127.0.0.1',
port: 5173,
@@ -58,18 +82,31 @@ export default defineConfig({
proxyTimeout: 300_000,
},
'/api/test': {
target: 'http://127.0.0.1:8001',
target: segmentApiBase || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 300_000,
proxyTimeout: 300_000,
},
'/api': {
target: 'http://127.0.0.1:8001',
target: apiBaseUrl || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 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,
});

View File

@@ -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
docling==2.70.0
fastapi==0.135.1
@@ -7,14 +10,13 @@ fastembed==0.8.0
gliner==0.2.27
grpcio==1.81.1
httpx==0.28.1
ingestion==0.0.42
langchain==1.3.7
langchain-text-splitters==1.1.2
modal==1.5.0
numpy==2.1.3
opencv-python==4.13.0.92
pgvector==0.4.2
pillow==12.3.0
pillow>=10.0.0,<12.0.0
psycopg2-binary==2.9.12
pycocotools==2.0.11
pydantic==2.13.4
@@ -28,76 +30,11 @@ requests==2.34.2
starlette==1.2.1
supabase==2.31.0
timm==1.0.25
torch==2.12.1
torchinfo==1.8.0
torchvision==0.27.1
transformers==4.57.6
tritonclient==2.69.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
# WARNING(pigar): the following duplicate requirements are for the import name: optimum
optimum==2.1.0
optimum-onnx==0.1.0
Celery==5.6.3
redis==8.0.1
celery==5.6.3
python-multipart==0.0.22