diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a636646 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Version control & tooling +.git +.gitignore +.gitattributes + +# Local task runner state & binaries +.task +bin/ +tags + +# Local databases & logs +*.sqlite +*.log + +# Editor / IDE +.zed +.idea +.vscode + +# Docs & examples not needed to build the image +*.md +htmlexamples/ + +# Don't send the docker metadata itself (optional, keeps context lean) +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b39bab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +# ---- Build stage ---- +FROM golang:1.26-alpine AS builder + +ENV CGO_ENABLED=0 GOOS=linux + +ARG VERSION=docker +ARG COMMIT=docker +ARG BUILD_TIME=unknown +ARG PROJECT=git.loyso.art/frx/kurious + +WORKDIR /src + +# Cache module downloads. +COPY go.mod go.sum ./ +RUN go mod download + +# Copy the rest of the source. +# Generated files (templ *_templ.go, mockery mocks) are committed, so no +# generation step is required here. +COPY . . + +# Build the web server and the healthcheck probe. +RUN go build -trimpath \ + -ldflags "-X ${PROJECT}.version=${VERSION} -X ${PROJECT}.commit=${COMMIT} -X ${PROJECT}.buildTime=${BUILD_TIME}" \ + -o /out/kuriweb ./cmd/kuriweb \ + && go build -trimpath -o /out/healthcheck ./cmd/healthcheck + +# Bake a default config into the image (config files are gitignored locally, +# so the image must be self-contained). +RUN echo '{"log":{"level":"info","format":"json"},"http":{"listen_addr":":8080","mount_live":false},"sqlite":{"dsn":"/tmp/kurious.sqlite","shutdown_timeout":"10s"},"db_engine":"sqlite","tracing":{"type":"stdout","show_metrics":false}}' > /out/config.json + +# ---- Final stage ---- +FROM gcr.io/distroless/static-debian12 + +LABEL org.opencontainers.image.title="kuriousweb" \ + org.opencontainers.image.source="git.loyso.art/frx/kurious" + +COPY --from=builder /out/kuriweb /kuriweb +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder /out/config.json /etc/kurious/config.json + +# static-debian12 ships a "nonroot" user (uid 65532). +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/kuriweb"] +CMD ["/etc/kurious/config.json"] + +# Distroless static has no shell/curl, so probing is done via the tiny +# healthcheck binary built above. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/healthcheck", "http://127.0.0.1:8080/healthz"] diff --git a/Taskfile.yml b/Taskfile.yml index f75721f..5111711 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -67,6 +67,16 @@ tasks: - task: build_background - task: build_web + build_docker: + desc: "Build the kuriousweb Docker image locally (no push)" + cmds: + - >- + docker build + --build-arg VERSION={{.GIT_VERSION}} + --build-arg COMMIT={{.GIT_COMMIT}} + --build-arg BUILD_TIME={{.BUILD_TIME}} + -t kuriousweb . + run: deps: [build] cmds: diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..32dda2c --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" +) + +func main() { + url := "http://127.0.0.1:8080/healthz" + if len(os.Args) > 1 { + url = os.Args[1] + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(url) + if err != nil { + fmt.Fprintf(os.Stderr, "healthcheck error: %v\n", err) + os.Exit(1) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + fmt.Fprintf(os.Stderr, "healthcheck failed: status %s\n", resp.Status) + os.Exit(1) + } +} diff --git a/cmd/kuriweb/http.go b/cmd/kuriweb/http.go index 8f3ed42..960c36f 100644 --- a/cmd/kuriweb/http.go +++ b/cmd/kuriweb/http.go @@ -63,6 +63,11 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server middlewareMetrics(), ) + router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }).Methods(http.MethodGet) + setupCoursesHTTP(srv, router, log) if cfg.MountLive {