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
13 changes: 11 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ jobs:
publish:
name: Build and push (${{ matrix.service }})
runs-on: ubuntu-24.04
if: hashFiles(format('{0}/Dockerfile', matrix.service)) != ''
strategy:
fail-fast: true # if backend fails, don't push frontend mismatched
matrix:
service: [backend, frontend]
steps:
- uses: actions/checkout@v6

- name: Verify Dockerfile exists for ${{ matrix.service }}
run: test -f "${{ matrix.service }}/Dockerfile"

Comment on lines +31 to +33
- name: Set up QEMU
uses: docker/setup-qemu-action@v4

Expand All @@ -42,8 +44,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# `secrets.*` is not allowed in step-level `if:` expressions — Actions
# rejects it as "Unrecognized named-value: 'secrets'". We surface the
# value into the step env first, then condition on env.* which IS
# allowed.
- name: Log in to Docker Hub
if: secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != ''
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
Expand Down
40 changes: 40 additions & 0 deletions frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Keep build context minimal.

# Build output
/bin/
/dist/
/out/
*.exe
*.test
*.prof

# Test artefacts
coverage.out

# IDE
.vscode/
.idea/

# OS
.DS_Store
Thumbs.db

# Local dev
.env
.env.local
*.log

# Secrets — never copy
*.pem
*.key
*.crt
secrets/

# Git
.git/
.gitignore

# Node toolchain (Tailwind build cache — comes in a later PR)
node_modules/
.npm/
.pnpm-store/
104 changes: 104 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# =============================================================================
# Label Printer Hub — frontend container
#
# Multi-stage build:
# 1) builder — Go toolchain compiles a static binary
# 2) runtime — alpine + tini + curl + non-root UID 1000
#
# Build context: the `frontend/` directory.
#
# Tailwind, HTMX, PWA assets and the OpenAPI-generated backend client will
# land in follow-up commits — this skeleton produces a deployable container
# that serves /healthz so the docker-publish workflow has something real to
# push at the next release.
#
# References:
# docs/decisions/0001-two-container-architecture.md (two-container split)
# docs/decisions/0003-go-tailwind-htmx-pwa-frontend.md
# docs/decisions/0007-docker-image-tag-scheme.md (multi-arch publish)
# CLAUDE.md hard rules (non-root UID 1000)
# =============================================================================

# -----------------------------------------------------------------------------
# Stage 1: builder
# -----------------------------------------------------------------------------
FROM golang:1.23-alpine AS builder

WORKDIR /build

# Stage A: dependency download — cached separately from source. Subsequent
# code edits don't re-resolve modules unless go.mod / go.sum change.
COPY go.mod go.sum ./
RUN go mod download

# Stage B: compile. Static binary so we don't need libc in the runtime image.
COPY . ./
# CGO_ENABLED=0 produces a fully static binary. -ldflags strip debug info
# and shrink the binary (~30-40% smaller).
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/server \
./cmd/server

# -----------------------------------------------------------------------------
# Stage 2: runtime
# -----------------------------------------------------------------------------
FROM alpine:3.20 AS runtime

# Build-time arguments — set by the CI workflow (.github/workflows/docker-publish.yml).
# Local builds without --build-arg get the placeholder defaults below.
ARG VERSION=0.0.0-dev
ARG REVISION=unknown
ARG BUILD_DATE=1970-01-01T00:00:00Z

# OCI image labels — visible via `docker inspect`. Keep in sync with the
# backend Dockerfile so both images describe themselves identically.
LABEL org.opencontainers.image.title="label-printer-hub-frontend" \
org.opencontainers.image.description="Self-hosted label printer hub for Brother PT/QL series — frontend container (Go + Tailwind + HTMX + PWA)" \
org.opencontainers.image.url="https://github.com/strausmann/label-printer-hub" \
org.opencontainers.image.source="https://github.com/strausmann/label-printer-hub" \
org.opencontainers.image.documentation="https://github.com/strausmann/label-printer-hub/tree/main/docs" \
org.opencontainers.image.authors="Björn Strausmann <strausmannservices@googlemail.com>" \
org.opencontainers.image.vendor="strausmann (independent open-source project; not affiliated with Brother Industries, Ltd.)" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.base.name="docker.io/library/alpine:3.20"

ENV PORT=8080 \
BACKEND_URL=http://backend:8000 \
HUB_VERSION="${VERSION}" \
HUB_REVISION="${REVISION}" \
HUB_BUILD_DATE="${BUILD_DATE}" \
HUB_REPO_URL="https://github.com/strausmann/label-printer-hub"

# Runtime essentials:
# tini — PID 1 signal handler so SIGTERM propagates cleanly
# curl — HEALTHCHECK probe
# ca-certificates — outbound HTTPS to the backend (when proxying SSE/API)
# Then create the non-root user (UID 1000, matches the backend container).
RUN apk add --no-cache tini curl ca-certificates \
&& addgroup -S -g 1000 hub \
&& adduser -S -u 1000 -G hub -s /sbin/nologin -h /home/hub hub

# Copy the compiled binary from the builder. No source code, no Go toolchain
# in the runtime image.
COPY --from=builder --chown=hub:hub /out/server /usr/local/bin/server

USER hub
WORKDIR /home/hub

# EXPOSE documents the DEFAULT port — overridden by the PORT env var at
# runtime. Adjust the `docker run -p` mapping or compose `ports` to match
# the value of PORT if you change it.
EXPOSE 8080

# Shell-form HEALTHCHECK so $PORT is expanded by the shell.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl --fail --silent --show-error "http://localhost:${PORT:-8080}/healthz" || exit 1

# tini reaps zombies and propagates SIGTERM cleanly. The Go server reads $PORT
# from the environment itself, so we don't need a shell wrapper around it.
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/server"]
34 changes: 34 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# label-printer-hub — frontend

Go web server for the [Label Printer Hub](https://github.com/strausmann/label-printer-hub). Serves the user-facing UI and proxies API + SSE requests to the Python backend (see [ADR 0001](../docs/decisions/0001-two-container-architecture.md)).

Stack: Go + chi router. Tailwind CSS, HTMX, PWA assets, and the OpenAPI-generated backend client land in follow-up commits — this skeleton is the buildable container baseline.

## Local development

```bash
go mod download
go test ./...
go run ./cmd/server
```

The server listens on `:8080` by default (override with `PORT=…`). `/healthz` returns the same JSON shape as the backend's `/healthz` so orchestrator probe configs work for both.

## Configuration

| Env var | Default | Purpose |
|---|---|---|
| `PORT` | `8080` | Internal HTTP port |
| `BACKEND_URL` | `http://backend:8000` | Base URL of the Python backend |
| `HUB_VERSION` | `0.0.0-dev` | Baked in by the Dockerfile from the release tag |
| `HUB_REVISION` | `unknown` | Baked in by the Dockerfile from the git SHA |
| `HUB_BUILD_DATE` | `1970-01-01T00:00:00Z` | Baked in by the Dockerfile at build time |
| `HUB_REPO_URL` | `https://github.com/strausmann/label-printer-hub` | Baked in by the Dockerfile |

## Container

The `Dockerfile` produces `ghcr.io/strausmann/label-printer-hub-frontend` — see the [tag scheme ADR](../docs/decisions/0007-docker-image-tag-scheme.md) for which tags every release publishes.

## License

MIT — see [LICENSE](../LICENSE) in the repository root.
176 changes: 176 additions & 0 deletions frontend/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Package main is the Label Printer Hub frontend entry point.
//
// The frontend container serves the user-facing UI and proxies API + SSE
// requests to the backend (see ADR 0001). This skeleton boots a chi router
// with a single /healthz endpoint so the container becomes deployable and
// release-publishable. Tailwind/HTMX/PWA assets, OpenAPI-generated client,
// and the actual UI routes land in follow-up PRs.
//
// Environment variables (all optional, with safe defaults):
//
// PORT internal HTTP port (default: 8080)
// BACKEND_URL base URL of the Python backend (default: http://backend:8000)
// HUB_VERSION release version — baked in by Dockerfile build arg
// HUB_REVISION git commit SHA — baked in by Dockerfile build arg
// HUB_BUILD_DATE ISO-8601 UTC — baked in by Dockerfile build arg
// HUB_REPO_URL project repo URL — baked in by Dockerfile build arg
package main

import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)

const defaultRepoURL = "https://github.com/strausmann/label-printer-hub"

// BuildInfo is the JSON body returned by /healthz.
//
// It mirrors the shape of the backend's /healthz response so callers can
// treat both endpoints uniformly. Fields are populated from environment
// variables set by the Dockerfile (HUB_VERSION, HUB_REVISION, HUB_BUILD_DATE,
// HUB_REPO_URL); when running outside a built image they fall back to
// placeholders.
type BuildInfo struct {
Status string `json:"status"`
Version string `json:"version"`
Revision string `json:"revision"`
BuildDate string `json:"build_date"`
Repository string `json:"repository"`
}

// buildInfo is captured once at startup so /healthz does not hit os.Getenv
// on every request. The HUB_* values are baked into the image by the
// Dockerfile and never change for a running container — caching them
// removes per-request syscalls on what is meant to be a cheap probe.
var buildInfo BuildInfo

// loadBuildInfo reads HUB_* env vars once and returns the BuildInfo. Kept
// as a separate function so tests can call it explicitly after t.Setenv
// without depending on package-load ordering.
func loadBuildInfo() BuildInfo {
return BuildInfo{
Status: "ok",
Version: envDefault("HUB_VERSION", "0.0.0-dev"),
Revision: envDefault("HUB_REVISION", "unknown"),
BuildDate: envDefault("HUB_BUILD_DATE", "1970-01-01T00:00:00Z"),
Repository: envDefault("HUB_REPO_URL", defaultRepoURL),
}
}

// healthzHandler returns 200 with the cached BuildInfo struct. It performs
// no authentication, has no external dependencies, and never blocks —
// matching the contract of the backend's /healthz so the same orchestrator
// probe configuration works against both containers.
func healthzHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(buildInfo); err != nil {
// Encoding a fixed-shape struct should never fail; if it does the
// connection is dead anyway. Log and return — no further writes.
slog.Error("failed to encode healthz response", "err", err)
}
}

// slogRequestLogger returns a chi middleware that emits one structured log
// line per request using the global slog logger. chi's bundled
// middleware.Logger writes to the legacy stdlib `log` package which bypasses
// our slog handler — that means request lines would not honour the log
// level, format, or destination configured elsewhere. We keep the
// implementation small on purpose; it can grow when we add real routes.
func slogRequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
slog.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
"remote_ip", r.RemoteAddr,
)
})
}

// newRouter builds the chi router. Kept as a separate function so tests can
// exercise it without spinning up an actual HTTP server.
func newRouter() *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The chi middleware.Recoverer uses the standard library's log package to output stack traces, which bypasses the structured slog logger configured for the rest of the application. This leads to inconsistent log formatting and destination (e.g., if slog is configured to output JSON to a file, panics will still go to stderr as plain text). Consider implementing a custom recoverer middleware that leverages slog.Error to maintain consistency.

References
  1. Ensure HTTP middleware uses the same logging framework (e.g., slog) as the rest of the application to maintain consistent log formatting and configuration.

r.Use(slogRequestLogger)

r.Get("/healthz", healthzHandler)
return r
}

func envDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

func main() {
buildInfo = loadBuildInfo()

port := envDefault("PORT", "8080")
addr := ":" + port

r := newRouter()
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// WriteTimeout is intentionally 0 (no deadline). The frontend will
// proxy Server-Sent Events from the backend — a single SSE response
// can stay open for minutes or hours, and any non-zero WriteTimeout
// would tear it down mid-stream. Per-route timeouts will be applied
// to non-SSE routes when they are added.
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
}

// Register signal handler BEFORE starting the listener. If we waited
// until after `go func()` returned its first scheduling slice, a SIGTERM
// arriving during that window would terminate the process by default
// instead of triggering graceful shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)

idleConnsClosed := make(chan struct{})
go func() {
<-sigCh
slog.Info("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("graceful shutdown failed", "err", err)
}
close(idleConnsClosed)
}()

slog.Info("starting frontend",
"addr", addr,
"version", buildInfo.Version,
"revision", buildInfo.Revision)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server stopped with error", "err", err)
os.Exit(1)
}
<-idleConnsClosed
slog.Info("server stopped cleanly")
}
Loading
Loading