diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1be6fbd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Multi-stage Dockerfile for librenotes. +# +# Stage 1: build the static, CGO-free binary. modernc.org/sqlite +# is pure-Go so we don't need libc; this lets us drop the runtime +# image to gcr.io/distroless/static, which is ~2 MB. +# +# Stage 2: run as non-root (distroless static's "nonroot" user, +# uid 65532) with /data and /var/lib/librenotes mounted from +# named volumes so the database and per-tenant note files survive +# container restarts. + +# ---- build ---- +FROM golang:1.25-bookworm AS build +WORKDIR /src + +# Cache module downloads on a separate layer. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILDTIME +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +RUN go build \ + -trimpath \ + -ldflags "-s -w \ + -X git.librete.ch/public/librenotes/internal/notesium.gitversion=${VERSION} \ + -X git.librete.ch/public/librenotes/internal/notesium.buildtime=${BUILDTIME}" \ + -o /out/librenotes \ + ./cmd/librenotes + +# ---- runtime ---- +FROM gcr.io/distroless/static:nonroot AS runtime +COPY --from=build /out/librenotes /librenotes +USER nonroot:nonroot +EXPOSE 8080 +VOLUME ["/data", "/var/lib/librenotes"] +ENV LIBRENOTES_ADDR=":8080" \ + LIBRENOTES_DATA_DIR="/data" \ + LIBRENOTES_DB="/var/lib/librenotes/librenotes.db" +ENTRYPOINT ["/librenotes", "serve"] diff --git a/cmd/librenotes/healthcheck.go b/cmd/librenotes/healthcheck.go new file mode 100644 index 0000000..84a46f7 --- /dev/null +++ b/cmd/librenotes/healthcheck.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" +) + +// runHealthcheck pings /healthz on the local server and exits 0 +// if the response is 2xx. Useful as a Docker HEALTHCHECK on +// distroless images that don't ship curl/wget. +// +// Defaults to http://127.0.0.1:8080/healthz; override with +// LIBRENOTES_HEALTHCHECK_URL. +func runHealthcheck(_ []string) error { + url := os.Getenv("LIBRENOTES_HEALTHCHECK_URL") + if url == "" { + url = "http://127.0.0.1:8080/healthz" + } + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} diff --git a/cmd/librenotes/main.go b/cmd/librenotes/main.go index 21e2b95..b3d7e9d 100644 --- a/cmd/librenotes/main.go +++ b/cmd/librenotes/main.go @@ -9,12 +9,21 @@ import ( func main() { args := os.Args[1:] - if len(args) > 0 && args[0] == "serve" { - if err := runServe(args[1:]); err != nil { - fmt.Fprintln(os.Stderr, "serve:", err) - os.Exit(1) + if len(args) > 0 { + switch args[0] { + case "serve": + if err := runServe(args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + os.Exit(1) + } + return + case "healthcheck": + if err := runHealthcheck(args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + os.Exit(1) + } + return } - return } notesium.Run() } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0b0968d --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,43 @@ +# docker-compose.prod.yml — production overrides. +# +# Use: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# Inputs (env or .env file): +# LIBRENOTES_IMAGE registry image, e.g. registry.librete.ch/librenotes:1.2.3 +# LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es +# LIBRENOTES_JWT_SECRET secrets manager value, NOT committed +# LIBRENOTES_SMTP_HOST real SMTP host +# LIBRENOTES_SMTP_PORT submission port (587 default) +# LIBRENOTES_SMTP_USER SMTP credential +# LIBRENOTES_SMTP_PASS SMTP credential +# LIBRENOTES_SMTP_FROM envelope sender (e.g. no-reply@librenot.es) +services: + librenotes: + image: ${LIBRENOTES_IMAGE} + build: !reset null + environment: + LIBRENOTES_BASE_URL: ${LIBRENOTES_BASE_URL} + LIBRENOTES_JWT_SECRET: ${LIBRENOTES_JWT_SECRET} + LIBRENOTES_SMTP_HOST: ${LIBRENOTES_SMTP_HOST} + LIBRENOTES_SMTP_PORT: ${LIBRENOTES_SMTP_PORT:-587} + LIBRENOTES_SMTP_USER: ${LIBRENOTES_SMTP_USER} + LIBRENOTES_SMTP_PASS: ${LIBRENOTES_SMTP_PASS} + LIBRENOTES_SMTP_FROM: ${LIBRENOTES_SMTP_FROM} + healthcheck: + test: ["CMD", "/librenotes", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 64M + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0cda183 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# docker-compose.yml — local development. +# +# Brings up librenotes on http://localhost:8080 with the magic-link +# mailer logging links to stdout (no SMTP needed). Data lives in +# named volumes so `docker compose down` does NOT wipe state. +# Use `docker compose down -v` for a clean slate. +services: + librenotes: + build: + context: . + args: + VERSION: dev + image: librenotes:dev + ports: + - "8080:8080" + environment: + LIBRENOTES_BASE_URL: "http://localhost:8080" + # JWT secret must be at least 32 bytes. Override per-host for + # any non-throwaway environment. + LIBRENOTES_JWT_SECRET: "dev-secret-32-bytes-of-keymaterial!!" + volumes: + - notes:/data + - state:/var/lib/librenotes + healthcheck: + test: ["CMD", "/librenotes", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + +volumes: + notes: + state: diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index ca82491..270ce51 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -22,6 +22,10 @@ type Server struct { func (s *Server) Routes() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) mux.HandleFunc("/auth/login", s.Auth.HandleLogin) mux.HandleFunc("/auth/verify", s.Auth.HandleVerify)