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
4 changes: 2 additions & 2 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository_owner }}/labelforge
tags: |
Expand All @@ -54,7 +54,7 @@ jobs:
org.opencontainers.image.description=Self-hosted label designer and printer for Brother QL series
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.licenses=GPL-3.0
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
with:
context: .
push: true
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ jobs:
fi
- uses: docker/setup-buildx-action@v4
if: steps.check.outputs.exists == 'true'
- uses: docker/build-push-action@v6
- uses: docker/build-push-action@v7
if: steps.check.outputs.exists == 'true'
with:
context: .
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ jobs:
echo "skip=$skip" >> $GITHUB_OUTPUT
- uses: actions/checkout@v6
if: steps.gate.outputs.skip != 'true'
- uses: github/codeql-action/init@v3
- uses: github/codeql-action/init@v4
if: steps.gate.outputs.skip != 'true'
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- uses: github/codeql-action/autobuild@v3
- uses: github/codeql-action/autobuild@v4
if: steps.gate.outputs.skip != 'true'
- uses: github/codeql-action/analyze@v3
- uses: github/codeql-action/analyze@v4
if: steps.gate.outputs.skip != 'true'
with:
category: /language:${{ matrix.language }}
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@ All notable changes to labelforge are recorded here. Format follows [Keep a Chan

## [Unreleased]

## [0.1.1] — 2026-06-06

### Added

- **Detailed, fail-fast startup logging** — the container now logs its version and Python
version, the effective (non-secret) configuration, the data directory, whether the database
was created or opened, any schema migrations applied, and a "startup complete" line. Logging
is configured before the config is loaded and sent unbuffered to stdout, so a misconfiguration
is reported clearly instead of crashing silently.
- **Permission preflight on `DATA_DIR`** — startup now write-probes the data directory and, if
it isn't writable by the container's runtime user (uid 1000), aborts with an actionable
CRITICAL message (showing the uid/gid and a `chown` hint) instead of a bare `PermissionError`.

### Fixed

- **No more silent crash-on-start** — required-env-var and configuration errors (e.g. a missing
`PRINTER_HOST` or `API_TOKEN`) previously raised at import time *before* logging was set up,
so a misconfigured deployment failed with no usable output. Configuration now loads behind
logging and reports exactly which variable is missing. The Docker image also sets
`PYTHONUNBUFFERED=1` so logs are never lost to buffering on a fast restart, and creates/owns
`/data` for the runtime user so named-volume deployments work out of the box. The in-app/API
version display also now reflects the real package version instead of a hardcoded `0.0.1`.

### Changed

- **Dependency updates** — rolled in the pending Dependabot bumps: backend `fastapi >=0.136.3`,
`pydantic >=2.13.4`, `python-barcode >=0.16.1`, and dev tools `mypy >=2.1.0` /
`types-PyYAML >=6.0.12`; frontend `fabric 7.4.0`, `vite 8`, `typescript 6`; the Docker base
image to `python:3.14-slim`; and CI actions (`docker/metadata-action@v6`,
`docker/build-push-action@v7`, `github/codeql-action@v4`). Verified locally: backend lint +
mypy 2.x + tests pass, and the frontend type-checks and builds. A `frontend/src/vite-env.d.ts`
(`vite/client` reference) was added because TypeScript 6 now requires ambient types for the
side-effect `import './style.css'`. Fabric 7's serialization was checked to still emit `IText`
and preserve the `labelforge_raw_content` custom property, so existing saved templates and the
server renderer are unaffected. No user-facing behaviour change.

## [0.1.0] — 2026-06-06

### Security
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Owner: crzykidd. Personal homelab project, public open source. Single-user app

## Build Status

- **Last shipped:** v0.1.0 (first release)
- **Last shipped:** v0.1.1
- **Target for next release:** TBD

## Standards
Expand Down
14 changes: 12 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ RUN npm ci
COPY frontend/ ./
RUN npm run build

FROM python:3.12-slim
FROM python:3.14-slim

# Unbuffered stdout/stderr so startup logs appear immediately (critical for
# diagnosing a crash-on-start) and never get lost in a buffer on a fast restart.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

# System fonts required by render/text.py; Pillow wheels include libjpeg/zlib.
RUN apt-get update && apt-get install -y --no-install-recommends \
Expand All @@ -31,7 +36,12 @@ RUN pip install --no-cache-dir -e .
COPY labels.yml /app/labels.yml
COPY --from=frontend /app/frontend/dist /app/frontend/dist

RUN chown -R labelforge:labelforge /app
# Create the data dir and hand it to the runtime user. A *named volume* inherits
# this ownership (uid 1000), so it works out of the box. A *bind mount* keeps the
# host directory's ownership — that host path must be writable by uid 1000, or
# startup will fail with a clear "DATA_DIR not writable" message.
RUN mkdir -p /data && chown -R labelforge:labelforge /app /data

USER labelforge

EXPOSE 8000
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@

Self-hosted web app for designing, saving, and printing labels to Brother QL series printers.

**Status**: First release (v0.1.0) — all v1 features are working and the app is packaged as a single Docker image.
**Status**: Released (v0.1.1) — all v1 features are working and the app is packaged as a single Docker image.

**Version:** 0.1.0
**Version:** 0.1.1

## What's New

### v0.1.1 (2026-06-06)

Deployment reliability. Startup now logs in detail (version, effective config, data directory,
database created/opened, migrations, "startup complete") and **fails fast with a clear message**
instead of crashing silently — a missing `PRINTER_HOST`/`API_TOKEN` or an unwritable `DATA_DIR`
(the container runs as uid 1000) is now reported as a `CRITICAL` log line with the fix. The
image sets `PYTHONUNBUFFERED=1` and creates/owns `/data` so named-volume deploys work out of the
box. Also rolls in the pending dependency updates (fastapi, pydantic, fabric 7, vite 8,
typescript 6, Docker base `python:3.14-slim`, CI actions). See the permissions notes under
**Running it**.

### v0.1.0 (2026-06-06)

First release. The full v1 feature set is here: quick-print, a Fabric.js canvas
Expand Down Expand Up @@ -108,6 +119,21 @@ All config is environment-driven (see `.env.example` for the full list). The ess
Persistent data lives under `$DATA_DIR`; back it with a named volume or bind mount. The
interactive API docs are at `/docs`.

### Permissions

The container runs as a **non-root user, uid 1000** (`labelforge`). Everything it writes lives
under `$DATA_DIR` (default `/data`), so that path must be writable by uid 1000:

- **Named volume** (e.g. the default `docker-compose.yml`): works out of the box — the image
creates `/data` owned by uid 1000 and the volume inherits that ownership.
- **Bind mount** (host directory): the host keeps its own ownership, so make the directory
writable by uid 1000 first: `chown -R 1000:1000 /path/on/host`. Alternatively run the
container with `--user $(id -u):$(id -g)` and ensure that user owns the directory.

If `$DATA_DIR` isn't writable, startup aborts immediately with a `CRITICAL ... DATA_DIR ... is
NOT writable by uid=1000` log line telling you exactly what to fix. Watch `docker logs` on
first start — the app logs its version, config, and database status as it comes up.

## Design docs

See [`docs/PRD.md`](docs/PRD.md) for scope, then [`docs/features/`](docs/features/) for per-feature designs.
Expand Down
8 changes: 7 additions & 1 deletion backend/labelforge/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
__version__ = "0.0.1"
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _version

try:
__version__ = _version("labelforge")
except PackageNotFoundError: # running from a source tree, not pip-installed
__version__ = "unknown"
38 changes: 38 additions & 0 deletions backend/labelforge/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Earliest startup wiring: logging + version.

Imported before anything that can fail (config, routers) so that import-time
errors — a missing required env var, a bad config — are visible in the logs
instead of producing a silent crash. Logging goes to stdout, unbuffered.
"""

import logging
import os
import sys
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version


def configure_logging() -> None:
"""Configure root logging to stdout. Idempotent (``force=True``).

Called once on import (so config/import failures are logged) and again at
lifespan start (so runtime logs survive uvicorn's own logging setup).
"""
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
force=True,
)


configure_logging()

try:
__version__ = _pkg_version("labelforge")
except PackageNotFoundError: # running from a source tree, not pip-installed
__version__ = "unknown"

logging.getLogger("labelforge").info(
"labelforge %s — process starting (python %s)", __version__, sys.version.split()[0]
)
22 changes: 21 additions & 1 deletion backend/labelforge/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import logging
from pathlib import Path

from pydantic import model_validator
from pydantic_settings import BaseSettings

from labelforge.bootstrap import configure_logging

# Configure logging before building Settings() so a missing required env var is
# logged with a clear message instead of crashing silently before logging exists.
configure_logging()

logger = logging.getLogger(__name__)

__all__ = ["Settings", "settings"]


class Settings(BaseSettings):
# When DISABLE_AUTH=true the app runs with no app-level auth (intended for
Expand Down Expand Up @@ -30,4 +41,13 @@ def _require_token_unless_disabled(self) -> "Settings":
return self


settings = Settings()
try:
settings = Settings()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
except Exception:
logger.critical(
"Configuration failed to load. Check required environment variables: "
"PRINTER_HOST (always required) and API_TOKEN (required unless "
"DISABLE_AUTH=true). Full error below.",
exc_info=True,
)
raise
14 changes: 14 additions & 0 deletions backend/labelforge/db.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import logging
import sqlite3
from pathlib import Path

logger = logging.getLogger(__name__)

_SCHEMA = """
CREATE TABLE IF NOT EXISTS print_jobs (
id INTEGER PRIMARY KEY,
Expand Down Expand Up @@ -41,18 +44,29 @@ def get_connection(db_path: Path) -> sqlite3.Connection:
def _migrate_print_jobs(conn: sqlite3.Connection) -> None:
"""Idempotently add columns to print_jobs that post-date the initial schema."""
existing = {row["name"] for row in conn.execute("PRAGMA table_info(print_jobs)")}
added = []
if "field_values" not in existing:
conn.execute("ALTER TABLE print_jobs ADD COLUMN field_values TEXT NULL")
added.append("field_values")
if "batch_id" not in existing:
conn.execute("ALTER TABLE print_jobs ADD COLUMN batch_id TEXT NULL")
added.append("batch_id")
if "reprint_of" not in existing:
conn.execute("ALTER TABLE print_jobs ADD COLUMN reprint_of INTEGER NULL")
added.append("reprint_of")
conn.commit()
if added:
logger.info("Applied print_jobs migrations: added column(s) %s", ", ".join(added))


def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
is_new = not db_path.exists()
conn = get_connection(db_path)
conn.executescript(_SCHEMA)
_migrate_print_jobs(conn)
conn.close()
if is_new:
logger.info("Database created at %s", db_path)
else:
logger.info("Database opened (existing) at %s", db_path)
Loading