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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
.venv
__pycache__
.pytest_cache
tests
data
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read
packages: write

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: pytest -q
- run: python -m compileall -q app tests

publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push immutable image
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/team-pinlog/image:sha-${{ github.sha }}
- name: Output digest
run: echo "${{ steps.push.outputs.digest }}"
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
htmlcov/
data/
.env
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATABASE_PATH=/data/app.db \
FILES_DIR=/data/files

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN groupadd --gid 1000 app \
&& useradd --uid 1000 --gid 1000 --no-create-home --shell /usr/sbin/nologin app \
&& mkdir -p /data/files \
&& chown -R 1000:1000 /data

EXPOSE 8000
VOLUME ["/data"]
USER 1000:1000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# GPU cover worker service

A SQLite-backed API for creating six styled book-cover previews and one selected final cover.

```text
SERVICE_URL=https://pin-log.com/image
```

The unauthenticated readiness endpoint is `GET /health` and returns
`{"status":"ok"}` with HTTP 200.

## Run locally

```bash
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
uvicorn app.main:app --reload
```

Configuration:

- `DATABASE_PATH` defaults to `data/app.db`.
- `FILES_DIR` defaults to `data/files`.
- `STALE_JOB_SECONDS` defaults to `1800`. A running job older than this is requeued
when attempts remain, or marked failed after `MAX_ATTEMPTS` claims.
- `PUBLIC_BASE_PATH` defaults to empty. Set it to the externally mounted service
prefix (for example, `/image`) so returned file URLs use
`/image/files/NAME`; the application's internal static route remains `/files/NAME`.
- `WORKER_TOKEN`, when set, protects every `/jobs` endpoint with `Authorization: Bearer TOKEN`.

## Exact workflow

Create all six previews:

```bash
curl -X POST "$SERVICE_URL/api/covers" \
-H 'content-type: application/json' \
-d '{"title":"달빛 고양이","keywords":["숲","달빛"]}'
```

The response is `{"request_id":"...","candidates":[{"style_id":"watercolour","label":"수채화"}, ...]}`.

Poll request state:

```bash
curl "$SERVICE_URL/api/covers/REQUEST_ID"
```

A worker claims work (the default long poll is 25 seconds):

```bash
curl "$SERVICE_URL/jobs/claim?worker_id=gpu-1" \
-H "Authorization: Bearer $WORKER_TOKEN"
```

The claim response contains exactly `id`, `workflow`, and `inputs`. Upload one or more generated images with a JSON-string `meta` field; `image_0` is required:

```bash
curl -X POST "$SERVICE_URL/jobs/JOB_ID/result" \
-H "Authorization: Bearer $WORKER_TOKEN" \
-F 'meta={"renderer":"comfyui"}' \
-F image_0=@preview.webp \
-F image_1=@alternate.webp
```

Report a permanent or retryable failure:

```bash
curl -X POST "$SERVICE_URL/jobs/JOB_ID/fail" \
-H "Authorization: Bearer $WORKER_TOKEN" \
-H 'content-type: application/json' \
-d '{"error":"GPU unavailable","retryable":true}'
```

Select a style to enqueue the full-size final job:

```bash
curl -X POST "$SERVICE_URL/api/covers/REQUEST_ID/select" \
-H 'content-type: application/json' \
-d '{"style_id":"watercolour"}'
```

Previews request 512×768 WebP at quality 82. Final jobs request 1795×2657 WebP at quality 92. Both use generation dimensions 1024×1536 and four steps.

## Verification

```bash
pytest -q
python -m compileall -q app tests
```

## Container and CI

The container runs as the fixed non-root identity `1000:1000`. Its persistent
`/data` directory is owned by that identity, so any mounted volume must also be
writable by UID/GID 1000.

GitHub Actions runs the test suite and Python bytecode compilation for pull
requests and pushes to `main`. A push to `main` also publishes the image as
`ghcr.io/team-pinlog/image:sha-COMMIT_SHA` and reports its registry digest. No
mutable `latest` tag is produced.
87 changes: 87 additions & 0 deletions TDD_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# TDD notes

Tests were replaced first to express the new GPU worker contract before application code changed.

## RED

Initial environment check:

```text
$ pytest -q
/bin/bash: line 1: pytest: command not found
exit 127
```

After `python -m pip install -r requirements-dev.txt`, the untouched old implementation produced the real behavioral RED:

```text
$ pytest -q
8 failed, 5 passed in 1.22s
exit 1
```

Failures covered the obsolete `{title, style}` schema, missing request/candidate response, absence of six queued jobs and the incompatible claim/result/failure APIs.

## GREEN

After implementing the contract, one test incorrectly assumed final jobs receive queue priority. The contract does not specify priority, so that test was corrected to consume older FIFO preview work before claiming the final. The full suite then passed:

```text
$ pytest -q
13 passed in 1.27s
exit 0
```

Final verification:

```text
$ pytest -q && python -m compileall -q app tests && python -m py_compile app/main.py tests/test_app.py tests/test_upload_read.py
13 passed in 1.30s
exit 0
```

Both syntax commands are silent on success. Nothing was deployed, pushed, or committed.

## Stale claims, public base path, and aggregate status

Tests were added before implementation for stale running-job recovery (including
the max-attempt failure case), externally prefixed file URLs, terminal preview
completion without a final, and final-job terminal status overriding previews.

Focused RED against the prior implementation:

```text
$ .venv/bin/pytest -q tests/test_app.py -k 'stale or terminal or final_status_controls or public_base_path'
FFFF [100%]
4 failed, 12 deselected in 0.70s
exit 1
```

Focused GREEN after implementation:

```text
$ .venv/bin/pytest -q tests/test_app.py -k 'stale or terminal or final_status_controls or public_base_path'
.... [100%]
4 passed, 12 deselected in 0.63s
exit 0
```

Full suite GREEN before final compile verification:

```text
$ .venv/bin/pytest -q
................. [100%]
17 passed in 1.51s
exit 0
```

Final requested verification:

```text
$ .venv/bin/pytest -q && .venv/bin/python -m compileall -q app tests && .venv/bin/python -m py_compile app/main.py tests/test_app.py tests/test_upload_read.py
................. [100%]
17 passed in 1.49s
exit 0
```

Both compile checks were silent on success. Nothing was committed, pushed, or deployed.
1 change: 1 addition & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Book cover generation API."""
Loading
Loading