-
Notifications
You must be signed in to change notification settings - Fork 0
feat(frontend): Go web server skeleton + /healthz + Dockerfile #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0cd5273
ff9afaf
c60900e
5bfba55
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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/ |
| 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"] | ||
| 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 | | ||
|
Comment on lines
+23
to
+26
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the Dockerfile comment above — the workflow gap was closed by the rebase onto main. |
||
|
|
||
| ## 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. | ||
| 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) | ||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||
|
Comment on lines
+154
to
+157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The signal notification should be set up before starting the goroutine and the server. This ensures that any signals received during the application's startup phase are correctly captured and handled, avoiding a potential race condition where a signal might be missed if it arrives before
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c60900e. |
||||||||||||||||||||||||||
| 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") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already resolved by the rebase onto main.
.github/workflows/docker-publish.ymlnow passesVERSION/REVISION/BUILD_DATEasbuild-args(the workflow was updated together with the backend container in #34, and the rebase brought those changes into this branch). You can verify by looking at the workflow onfeat/frontend-container— theBuild & push (multi-arch)step includes:Released images will therefore have populated
HUB_VERSION/HUB_REVISION/HUB_BUILD_DATEENV vars and matching OCI labels.