Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.github
.pytest_cache
.venv
__pycache__
*.py[cod]
*.egg-info
.env
linko-dev.db
dist
build
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
DATABASE_URL=
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
API_PORT=

JWT_SECRET_KEY=
JWT_ALGORITHM=
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
YOUTUBE_API_KEY=

AI_PROVIDER=
GEMINI_API_KEY=
GEMINI_MODEL=
SUPADATA_API_KEY=

CORS_ORIGINS=
110 changes: 110 additions & 0 deletions .github/workflows/deploy-ec2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Deploy to EC2

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

concurrency:
group: deploy-ec2-${{ github.ref }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use one deploy concurrency group for production

The workflow-level concurrency key is scoped to github.ref, but the deploy job can run from pull_request, push, and workflow_dispatch; those events often use different refs while targeting the same EC2 host/path. That allows concurrent production deploys to race on release.tar.gz upload/extract and service restarts, which can produce inconsistent rollout state.

Useful? React with 👍 / 👎.

cancel-in-progress: true

jobs:
test:
name: Test
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip

- name: Install dependencies
run: python -m pip install -e ".[dev]"

- name: Run tests
run: python -m pytest -v

deploy:
name: Deploy
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
environment: production

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Configure SSH
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_PORT: ${{ secrets.EC2_PORT }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_USER: ${{ secrets.EC2_USER }}
EC2_HOST_KEY: ${{ secrets.EC2_HOST_KEY }}
run: |
test -n "$EC2_HOST"
test -n "$EC2_SSH_KEY"
test -n "$EC2_HOST_KEY"
mkdir -p ~/.ssh
printf '%s\n' "$EC2_SSH_KEY" > ~/.ssh/linko-ec2
chmod 600 ~/.ssh/linko-ec2
printf '%s\n' "$EC2_HOST_KEY" >> ~/.ssh/known_hosts
{
echo "Host linko-ec2"
echo " HostName $EC2_HOST"
echo " Port ${EC2_PORT:-22}"
echo " User ${EC2_USER:-ubuntu}"
echo " IdentityFile ~/.ssh/linko-ec2"
echo " StrictHostKeyChecking yes"
} >> ~/.ssh/config

- name: Package application
run: |
tar \
--exclude='.git' \
--exclude='.github' \
--exclude='.pytest_cache' \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.py[cod]' \
--exclude='*.egg-info' \
--exclude='.env' \
-czf /tmp/linko-server.tar.gz .

- name: Upload application
env:
DEPLOY_PATH: ${{ secrets.EC2_DEPLOY_PATH }}
PROD_ENV: ${{ secrets.PROD_ENV }}
run: |
ssh linko-ec2 "mkdir -p '${DEPLOY_PATH:-/opt/linko-server}'"
scp /tmp/linko-server.tar.gz "linko-ec2:${DEPLOY_PATH:-/opt/linko-server}/release.tar.gz"
if [ -n "$PROD_ENV" ]; then
printf '%s\n' "$PROD_ENV" > /tmp/linko.env
scp /tmp/linko.env "linko-ec2:${DEPLOY_PATH:-/opt/linko-server}/.env"
fi

- name: Restart services
env:
DEPLOY_PATH: ${{ secrets.EC2_DEPLOY_PATH }}
run: |
ssh linko-ec2 "DEPLOY_PATH='${DEPLOY_PATH:-/opt/linko-server}' bash -s" <<'REMOTE'
set -euo pipefail
cd "$DEPLOY_PATH"
tar -xzf release.tar.gz

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean deploy directory before extracting release

The restart script untars release.tar.gz directly into the existing deploy path without removing files from previous releases, so deleted or renamed files persist on disk and are still included in subsequent Docker build contexts. This can cause production to run stale code paths even after those files were removed from Git; add a cleanup/sync step (or extract into a fresh release directory and switch) before building.

Useful? React with 👍 / 👎.

rm release.tar.gz

test -f .env
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec -T api python -m alembic upgrade head
docker compose -f docker-compose.prod.yml ps
docker image prune -f
REMOTE
22 changes: 22 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY pyproject.toml ./
COPY alembic.ini ./
COPY alembic ./alembic
COPY app ./app

RUN python -m pip install --upgrade pip \
&& python -m pip install .

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,22 @@ Stop local services:
```sh
docker compose down
```

## EC2 Deployment

The production deployment uses GitHub Actions to copy the app to an EC2 instance
and restart Docker Compose there. Configure these GitHub environment secrets for
the `production` environment:

- `EC2_HOST`: EC2 public host or IP address.
- `EC2_USER`: SSH user, for example `ubuntu`.
- `EC2_SSH_KEY`: private key with SSH access to the instance.
- `EC2_PORT`: optional SSH port, defaults to `22`.
- `EC2_DEPLOY_PATH`: optional deploy directory, defaults to `/opt/linko-server`.
- `PROD_ENV`: full contents of the production `.env` file. Use `.env.example` as
the template, and replace secrets before deploying.
Comment on lines +76 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add missing EC2 host key secret to deployment docs

The workflow now hard-requires EC2_HOST_KEY (test -n "$EC2_HOST_KEY") during SSH setup, but the README secret list omits it, so a user following the documented setup will configure an incomplete environment and the deploy job will fail at runtime. Document this required secret alongside the other production environment secrets.

Useful? React with 👍 / 👎.


On the EC2 instance, install Docker and the Docker Compose plugin first. Then run
the `Deploy to EC2` workflow manually, or merge to `main` to deploy
automatically. The workflow builds the FastAPI image on EC2, starts the API and
PostgreSQL containers, and runs Alembic migrations.
8 changes: 8 additions & 0 deletions app/api/flashcards.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ def get_flashcards_for_lesson(
)
if lesson.flashcards_json is not None:
return lesson.flashcards_json
if lesson.error_code == "flashcard_generation_failed":
raise HTTPException(
status_code=422,
detail={
"code": "flashcard_generation_failed",
"message": lesson.error_message or "Flashcard generation failed.",
},
)

flashcards = get_lesson_flashcards(lesson_id)
if flashcards is None:
Expand Down
65 changes: 47 additions & 18 deletions app/api/lessons.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
LessonStatusResponse,
LessonSummary,
)
from app.services.lesson_artifacts import generate_lesson_artifacts_from_transcript
from app.services.lesson_artifacts import (
build_subtitle_artifacts,
generate_lesson_artifacts_from_transcript,
)
from app.services.transcripts import download_youtube_captions
from app.services.youtube import (
extract_video_id,
Expand All @@ -25,6 +28,7 @@
parse_iso8601_duration_seconds,
parse_published_at,
select_thumbnail_url,
validate_video_item,
)

router = APIRouter(prefix="/lessons", tags=["lessons"])
Expand All @@ -39,8 +43,8 @@ def _lesson_summary(lesson: Lesson) -> LessonSummary:
duration=format_duration(lesson.duration_seconds),
date=lesson.created_at.strftime("%Y.%m.%d") if lesson.created_at else None,
generationStatus=lesson.generation_status,
flashcardDone=False,
subtitleDone=False,
flashcardDone=lesson.flashcards_json is not None,
subtitleDone=lesson.subtitles_json is not None,
errorCode=lesson.error_code,
errorMessage=lesson.error_message,
)
Expand Down Expand Up @@ -72,6 +76,7 @@ def create_lesson(
) from exc

item = fetch_youtube_video_item(youtube_video_id)
validate_video_item(item)
snippet = item["snippet"]
duration_seconds = parse_iso8601_duration_seconds(item["contentDetails"]["duration"])
lesson = Lesson(
Expand Down Expand Up @@ -158,6 +163,7 @@ def get_lesson_subtitles(
)
return {
**lesson.subtitles_json,
"youtubeId": lesson.subtitles_json.get("youtubeId") or lesson.youtube_video_id,
"vocabMap": lesson.watch_vocab_json or {},
"culturalNotes": lesson.cultural_notes_json or [],
}
Expand Down Expand Up @@ -190,14 +196,21 @@ def generate_lesson_artifacts_task(lesson_id: int) -> None:
if lesson is None:
return

end_sec = min(lesson.duration_seconds, 600)
with TemporaryDirectory() as tmp_dir:
transcript = download_youtube_captions(
lesson.youtube_url,
Path(tmp_dir),
lang="ko",
start_sec=0,
end_sec=end_sec,
end_sec=lesson.duration_seconds,
allow_auto=True,
)
english_transcript = download_youtube_captions(
lesson.youtube_url,
Path(tmp_dir),
lang="en",
start_sec=0,
end_sec=lesson.duration_seconds,
allow_auto=True,
)

Expand All @@ -211,13 +224,6 @@ def generate_lesson_artifacts_task(lesson_id: int) -> None:
db.commit()
return

artifacts = generate_lesson_artifacts_from_transcript(
lesson_id=str(lesson.id),
lesson_title=lesson.title,
youtube_id=lesson.youtube_video_id,
duration_seconds=lesson.duration_seconds,
transcript=transcript,
)
lesson.transcript_status = "ready"
lesson.transcript_source = transcript.source
lesson.transcript_text = transcript.text
Expand All @@ -229,13 +235,36 @@ def generate_lesson_artifacts_task(lesson_id: int) -> None:
}
for segment in transcript.segments
]
lesson.flashcards_json = artifacts.flashcards
lesson.subtitles_json = artifacts.subtitles
lesson.watch_vocab_json = artifacts.watch_vocab
lesson.cultural_notes_json = artifacts.cultural_notes
lesson.subtitles_json = build_subtitle_artifacts(
youtube_id=lesson.youtube_video_id,
duration_seconds=lesson.duration_seconds,
transcript=transcript,
english_transcript=english_transcript,
)
lesson.watch_vocab_json = {}
lesson.cultural_notes_json = []
db.commit()

try:
artifacts = generate_lesson_artifacts_from_transcript(
lesson_id=str(lesson.id),
lesson_title=lesson.title,
youtube_id=lesson.youtube_video_id,
duration_seconds=lesson.duration_seconds,
transcript=transcript,
english_transcript=english_transcript,
)
lesson.flashcards_json = artifacts.flashcards
lesson.watch_vocab_json = artifacts.watch_vocab
lesson.cultural_notes_json = artifacts.cultural_notes
lesson.error_code = None
lesson.error_message = None
except Exception as exc:
lesson.flashcards_json = None
lesson.error_code = "flashcard_generation_failed"
lesson.error_message = str(exc)

lesson.generation_status = "ready"
lesson.error_code = None
lesson.error_message = None
db.commit()
except Exception as exc:
db.rollback()
Expand Down
9 changes: 9 additions & 0 deletions app/api/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ def get_preview_flashcards(
"""Return flashcard data for a preview lesson without authentication."""
lesson = _get_ready_preview_lesson(db, lesson_id)
if lesson.flashcards_json is None:
if lesson.error_code == "flashcard_generation_failed":
raise HTTPException(
status_code=422,
detail={
"code": "flashcard_generation_failed",
"message": lesson.error_message or "Flashcard generation failed.",
},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
Expand Down Expand Up @@ -139,6 +147,7 @@ def get_preview_subtitles(
)
return {
**lesson.subtitles_json,
"youtubeId": lesson.subtitles_json.get("youtubeId") or lesson.youtube_video_id,
"vocabMap": lesson.watch_vocab_json or {},
"culturalNotes": lesson.cultural_notes_json or [],
}
Expand Down
3 changes: 2 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ class Settings(BaseSettings):
ai_provider: str = "mock"
gemini_api_key: str = ""
gemini_model: str = "gemini-2.5-flash"
supadata_api_key: str = ""
cors_origins: str = (
"http://localhost:3000,"
"http://127.0.0.1:3000,"
"http://localhost:3001,"
"http://127.0.0.1:3001"
)

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

@property
def cors_origin_list(self) -> list[str]:
Expand Down
Loading
Loading