# syntax=docker/dockerfile:1.7
#
# Go Bananas!! container image.
#
# Design notes
# ------------
# * The Go build is fully static. modernc.org/sqlite is a pure-Go translation of
#   SQLite, so CGO is not needed and the runtime image needs no libc at all. That
#   is what lets the final stage be `scratch`-adjacent: a smaller image, and no
#   shared-library CVEs to patch.
# * The frontend and the SDK are built before the Go binary and copied in, so the
#   image build fails if the frontend fails. A container that starts and then 404s
#   on its own dashboard is worse than one that does not start.
# * Dependencies are installed from a lockfile in their own layer, so a source-only
#   change does not re-download the module cache.
# * BuildKit cache mounts are used for the module and package caches. They are a
#   cache, not an artefact, so they are not part of the image.

# ---------------------------------------------------------------- arguments --

ARG GO_VERSION=1.27.1
ARG NODE_VERSION=24.9.0
ARG ALPINE_VERSION=3.22

# ------------------------------------------------------------- web and sdk ----

FROM node:${NODE_VERSION}-alpine AS assets

# Corepack is enabled so that the pinned package manager in package.json is the one
# that runs, rather than whatever npm happens to ship with the Node release.
RUN corepack enable && corepack prepare pnpm@latest --activate

WORKDIR /src/web
COPY web/package.json web/package-lock.json* ./

# `npm ci` when a lockfile is present, `npm install` otherwise. The fallback keeps
# the image buildable from a source tree that has not installed yet, while CI
# always takes the reproducible path.
RUN --mount=type=cache,target=/root/.npm \
    if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
    else npm install --no-audit --no-fund; fi

COPY web/ ./
RUN npm run build

WORKDIR /src/sdk
COPY sdk/package.json sdk/package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
    if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
    else npm install --no-audit --no-fund; fi

COPY sdk/ ./
RUN npm run build

# ------------------------------------------------------------------- go ------

FROM golang:${GO_VERSION}-alpine AS build

ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_DATE=unknown
ARG TARGETOS=linux
ARG TARGETARCH=amd64

# git is needed for the version stamping below when the build runs outside a
# checkout; ca-certificates for module downloads.
RUN apk add --no-cache git ca-certificates

WORKDIR /src

# Dependencies first, in their own layer. go.sum is copied and verified so that a
# tampered or truncated lockfile fails the build rather than silently resolving
# something different.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download && go mod verify

COPY internal/ ./internal/
COPY cmd/ ./cmd/

ARG CGO_ENABLED=0
ARG GOFLAGS=-trimpath

# -trimpath removes local filesystem paths from the binary, which keeps build paths
# out of any stack trace a user might paste into a bug report.
# -s -w drop the symbol table and DWARF data: neither is useful without a matching
# local binary, and together they are a substantial fraction of the image size.
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=${CGO_ENABLED} GOFLAGS=${GOFLAGS} \
    go build \
      -ldflags "-s -w -X github.com/urpi/go-bananas/internal/app.Version=${VERSION} -X main.version=${VERSION} -X github.com/urpi/go-bananas/internal/app.Commit=${COMMIT} -X github.com/urpi/go-bananas/internal/app.BuildDate=${BUILD_DATE}" \
      -o /out/go-bananas ./cmd/go-bananas

# Verify the binary is genuinely static before shipping it. A build that silently
# picked up cgo would produce an image that fails at run time with a confusing
# "no such file or directory" rather than at build time with a clear one.
RUN if [ "${CGO_ENABLED}" = "0" ]; then \
      if go version -m /out/go-bananas | grep -q 'CGO_ENABLED=1'; then \
        echo "error: binary is dynamically linked but CGO_ENABLED=0" >&2; exit 1; \
      fi; \
    fi

# ---------------------------------------------------------------- runtime ----

# Alpine rather than distroless or scratch: the service needs a shell for a health
# check and a writable directory for SQLite, and Alpine supplies both in ~8 MB. The
# attack surface difference against distroless is small next to the operational
# benefit, and the runtime user is unprivileged either way.
FROM alpine:${ALPINE_VERSION} AS runtime

ARG VERSION=dev

# curl is only here for the container health check. wget from busybox would do, but
# it is already present and curl's exit codes are the ones the check reasons about.
# tzdata is needed because timestamps are rendered in the configured locale.
RUN apk add --no-cache ca-certificates tzdata curl \
    && addgroup -g 65532 -S bananas \
    && adduser -u 65532 -S -G bananas -H -s /sbin/nologin bananas

COPY --from=build /out/go-bananas /usr/local/bin/go-bananas

COPY --from=assets /src/web/dist /srv/web
COPY --from=assets /src/sdk/dist /srv/sdk

# The data directory holds the SQLite file. It is a volume in every real deployment;
# creating it here means an unconfigured local run still starts.
RUN mkdir -p /srv/data && chown -R bananas:bananas /srv

USER 65532:65532
WORKDIR /srv

ENV BANANAS_HTTP_ADDR=:8080 \
    BANANAS_STATIC_DIR=/srv/web \
    BANANAS_SDK_DIR=/srv/sdk \
    BANANAS_DATABASE_PATH=/srv/data/go-bananas.db \
    BANANAS_LOG_FORMAT=json

EXPOSE 8080
VOLUME ["/srv/data"]

# The health check uses the liveness probe rather than readiness: a container that
# is alive but not ready should stay in the load-balancer pool's view of "starting"
# rather than be restarted, and Docker's HEALTHCHECK does not have that concept.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl --fail --silent --show-error http://127.0.0.1:8080/healthz || exit 1

ENTRYPOINT ["/usr/local/bin/go-bananas"]

# --------------------------------------------------------------- final ------

FROM runtime AS final
ARG VERSION=dev
LABEL org.opencontainers.image.title="go-bananas" \
      org.opencontainers.image.description="Runtime design-token service: author themes, serve them to any application at run time." \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://github.com/urpi/go-bananas" \
      org.opencontainers.image.licenses="MIT" \
      org.opencontainers.image.base.name="docker.io/library/alpine:3.22"
