From 48d952c3536493bf76eea2ab0163963d6e0e9ca6 Mon Sep 17 00:00:00 2001 From: Bodo Schulz Date: Thu, 12 Mar 2026 11:10:26 +0100 Subject: [PATCH 1/3] update files --- .github/workflows/release.yml | 76 +++++++++++++++++++++ Dockerfile | 36 ++++++---- Makefile | 14 ++-- README.md | 1 + cmd/{server => probe-service}/main.go | 95 ++++++++++++++++++++------- go.mod | 2 +- 6 files changed, 180 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/release.yml rename cmd/{server => probe-service}/main.go (85%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b60d25f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +--- + +name: CI + +# on: +# push: +# branches: +# #branches-ignore: +# # - '**' +# #tags: +# # - '*.*.*' + +on: + #schedule: + # - cron: "40 3 * * 0" + workflow_dispatch: + inputs: + logLevel: + description: 'Log level' + required: true + default: 'warning' + type: choice + options: + - info + - warning + - debug + push: + branches: + - 'main' + - 'feature/**' + - '!doc/**' + paths: + - "!Makefile" + - "!README.md" + tags: + - '*.*.*' + pull_request: + branches: + - 'main' + - 'feature/**' + - '!doc/**' + paths: + - "!Makefile" + - "!README.md" + + + +jobs: + release-linux-amd64: + name: "release with go version: ${{ matrix.go }}" + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + release_tag: + - 0.1.0 + go: + - 1.23 + + steps: + - name: πŸ›Ž Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: wangyoucao577/go-release-action@v1.52 + with: + github_token: ${{ secrets.GH_REGISTRY_TOKEN }} + release_tag: ${{ matrix.release_tag }} + goos: linux + goarch: amd64 + goversion: ${{ matrix.go }} + binary_name: server + sha256sum: true + overwrite: true + extra_files: LICENSE README.md docker-sd.example diff --git a/Dockerfile b/Dockerfile index 2e03636..7b44739 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,15 +5,19 @@ FROM mirror.gcr.io/alpine:3 AS downloader ARG VERSION_WAIT4X=3.6.0 RUN \ - apk add \ - curl + echo 'hosts: files dns' >> /etc/nsswitch.conf && \ + apk update --quiet --no-cache && \ + apk upgrade --quiet --no-cache && \ + apk add --quiet --no-cache \ + curl \ + ca-certificates WORKDIR /tmp # install wait4x RUN \ curl \ - --silent \ + --verbose \ --location \ --output wait4x-linux-amd64.tar.gz \ "https://github.com/wait4x/wait4x/releases/download/v${VERSION_WAIT4X}/wait4x-linux-amd64.tar.gz" @@ -32,34 +36,38 @@ RUN \ # ------------------------------- -FROM mirror.gcr.io/golang:1.22-alpine AS build +FROM mirror.gcr.io/golang:1.23-alpine AS build + +WORKDIR /src -WORKDIR /tmp COPY go.mod ./ RUN go mod download -COPY cmd/ ./cmd/ +# now copy the rest +COPY . . RUN \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64; \ - go build -trimpath -ldflags="-s -w" -o /out/simple-api ./cmd/server + go build -trimpath -ldflags="-s -w" -o /out/probe-service ./cmd/probe-service # ------------------------------- FROM mirror.gcr.io/alpine:3 RUN \ - apk add \ - bash + echo 'hosts: files dns' >> /etc/nsswitch.conf && \ + apk update --quiet --no-cache && \ + apk upgrade --quiet --no-cache && \ + apk add --quiet --no-cache \ + bash \ + ca-certificates ENV PORT=8080 EXPOSE 8080 COPY rootfs / -COPY --from=build /out/simple-api /usr/bin/simple-api +COPY --from=build /out/probe-service /usr/bin/probe-service COPY --from=downloader /tmp/wait4x /usr/bin/wait4x - # USER nonroot:nonroot - -CMD ["/bin/wait4x"] - ENTRYPOINT ["/bin/entrypoint.sh"] +# CMD ["/bin/wait4x"] + diff --git a/Makefile b/Makefile index fed1884..03f8a8b 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ ## --- Versionierung (ENV hat Vorrang, sonst Fallbacks) --- -VERSION ?= "0.1.0" +VERSION ?= "1.0.0" COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= "2025-12-20" -IMAGE ?= bodsch/server -NOCACHE := --no-cache +IMAGE ?= bodsch/probe-service +NOCACHE := "--cache" # ---- config ---- -BIN := server -CMD := ./cmd/server +BIN := probe-service +CMD := ./cmd/probe-service GO ?= go GOFLAGS ?= -trimpath -buildvcs=true LDFLAGS := -s -w \ @@ -77,11 +77,11 @@ print-version: .PHONY: container container: - docker buildx build ${NOCACHE} --platform linux/amd64 --tag ${IMAGE}:${VERSION} . + docker buildx build --platform linux/amd64 --tag ${IMAGE}:${VERSION} . .PHONY: run-container run-container: - docker run -ti --rm -e DEBUG_ENTRYPOINT="true" ${IMAGE}:${VERSION} /bin/wait4x + docker run -ti --rm -e DEBUG_ENTRYPOINT="false" -e SERVICE_NAME="caefeeder" ${IMAGE}:${VERSION} # ---- release ---- .PHONY: release diff --git a/README.md b/README.md index cec1968..20c3644 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ All configuration is done via environment variables. | `WRITE_TIMEOUT` | `15s` | duration | HTTP server write timeout. | | `IDLE_TIMEOUT` | `60s` | duration | HTTP server idle timeout. | | `MAX_BODY_BYTES` | `1048576` (1 MiB) | int64 | Maximum request body size enforced via `http.MaxBytesReader`. | +| `SERVICE_NAME` | `probe-service` | string | Included in JSON responses and logs. | | `LOG_LEVEL` | `info` | string | Log level: `debug`, `info`, `warn`, `error`. Logs are JSON (via `log/slog`). | ### Duration format diff --git a/cmd/server/main.go b/cmd/probe-service/main.go similarity index 85% rename from cmd/server/main.go rename to cmd/probe-service/main.go index 752fc73..46c8ff3 100644 --- a/cmd/server/main.go +++ b/cmd/probe-service/main.go @@ -60,17 +60,37 @@ func main() { writeJSON(w, http.StatusServiceUnavailable, map[string]any{ "status": "unhealthy", "service": c.ServiceName, - "version": c.Version, - "time": time.Now().UTC().Format(time.RFC3339Nano), "retry_after_ms": health.Remaining().Milliseconds(), + "time": time.Now().UTC().Format(time.RFC3339), }) return } writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "service": c.ServiceName, - "version": c.Version, - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), + }) + }) + + // health: 200 only if "healthy" flag is true + mux.HandleFunc("/actuator/health/liveness", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if !health.Load() { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "status": "unhealthy", + "service": c.ServiceName, + "retry_after_ms": health.Remaining().Milliseconds(), + "time": time.Now().UTC().Format(time.RFC3339), + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "service": c.ServiceName, + "time": time.Now().UTC().Format(time.RFC3339), }) }) @@ -84,17 +104,37 @@ func main() { writeJSON(w, http.StatusServiceUnavailable, map[string]any{ "status": "not-ready", "service": c.ServiceName, - "version": c.Version, - "time": time.Now().UTC().Format(time.RFC3339Nano), "retry_after_ms": ready.Remaining().Milliseconds(), + "time": time.Now().UTC().Format(time.RFC3339), + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ready", + "service": c.ServiceName, + "time": time.Now().UTC().Format(time.RFC3339), + }) + }) + + // ready: 200 only if "ready" flag is true + mux.HandleFunc("/actuator/health/readiness", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if !ready.Load() { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "status": "not-ready", + "service": c.ServiceName, + "retry_after_ms": ready.Remaining().Milliseconds(), + "time": time.Now().UTC().Format(time.RFC3339), }) return } writeJSON(w, http.StatusOK, map[string]any{ "status": "ready", "service": c.ServiceName, - "version": c.Version, - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), }) }) @@ -111,7 +151,7 @@ func main() { "health": false, "ready": false, "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), "health_in_ms": health.Remaining().Milliseconds(), "ready_in_ms": ready.Remaining().Milliseconds(), }) @@ -127,7 +167,7 @@ func main() { writeJSON(w, http.StatusOK, map[string]any{ "health": false, "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), "health_in_ms": health.Remaining().Milliseconds(), }) }) @@ -142,7 +182,7 @@ func main() { writeJSON(w, http.StatusOK, map[string]any{ "ready": false, "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), "ready_in_ms": ready.Remaining().Milliseconds(), }) }) @@ -346,21 +386,19 @@ func maxBody(max int64) middleware { func accessLog(log *slog.Logger) middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() + // start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) - log.Info("http_request", + log.Info("probe", "method", r.Method, "path", r.URL.Path, "status", sw.status, - "bytes", sw.bytes, - "duration_ms", time.Since(start).Milliseconds(), - "ua", r.UserAgent(), + // "bytes", sw.bytes, + "user-agent", r.UserAgent(), "remote", r.RemoteAddr, - "xff", r.Header.Get("X-Forwarded-For"), - "request_id", requestIDFromContext(r.Context()), + // "x-forwarded-for", r.Header.Get("X-Forwarded-For"), ) }) } @@ -402,7 +440,7 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { func writeError(w http.ResponseWriter, status int, code string) { writeJSON(w, status, map[string]any{ "error": code, - "time": time.Now().UTC().Format(time.RFC3339Nano), + "time": time.Now().UTC().Format(time.RFC3339), }) } @@ -439,8 +477,8 @@ func loadCfg() cfg { port := mustEnvInt("PORT", 8080) startupDelay := mustEnvDuration("STARTUP_DELAY", 30*time.Second) - serviceName := envStr("SERVICE_NAME", "simple-api") - version := envStr("VERSION", "0.1.0") + serviceName := envStr("SERVICE_NAME", "probe-service") + version := envStr("VERSION", "1.0.0") shutdownWait := mustEnvDuration("SHUTDOWN_WAIT", 10*time.Second) @@ -468,8 +506,21 @@ func loadCfg() cfg { } // newLogger constructs a JSON slog logger writing to stdout with the configured level. + func newLogger(c cfg) *slog.Logger { - h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: c.LogLevel}) + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: c.LogLevel, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + // JSONHandler emits time in RFC3339Nano by default. + // Replace it with RFC3339 (no fractional seconds). + if a.Key == slog.TimeKey { + // a.Value.Time().Format(time.RFC3339) + t := a.Value.Time().UTC() + a.Value = slog.StringValue(t.Format(time.RFC3339)) + } + return a + }, + }) return slog.New(h) } diff --git a/go.mod b/go.mod index c0eefae..4c17e7d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module example.com/server +module bodsch.me/probe-service go 1.23.0 From cfe15795ee8a6b6796bebd01d9361a01f288857b Mon Sep 17 00:00:00 2001 From: Bodo Schulz Date: Fri, 15 May 2026 09:08:12 +0200 Subject: [PATCH 2/3] add github and forgejo pipelines migrate from flat code-tree into modular code updates for go 1.24 multi-arch Dockerfile --- .forgejo/workflows/ci.yml | 132 +++++++ .forgejo/workflows/release.yml | 77 ++++ .github/workflows/ci.yml | 152 ++++++++ .github/workflows/release.yml | 117 +++--- .goreleaser.forgejo.yaml | 145 +++++++ .goreleaser.yaml | 136 +++++++ CHANGELOG.md | 51 +++ Dockerfile | 110 +++--- Dockerfile.goreleaser | 74 ++++ LICENSE | 661 ++++++++++++++++++++++++++++++++ Makefile | 4 +- cmd/probe-service/main.go | 623 ++---------------------------- go.mod | 2 +- internal/config/config.go | 163 ++++++++ internal/config/config_test.go | 124 ++++++ internal/flagx/delayed.go | 105 +++++ internal/flagx/delayed_test.go | 141 +++++++ internal/httpx/middleware.go | 125 ++++++ internal/httpx/response.go | 34 ++ internal/httpx/statuswriter.go | 45 +++ internal/logging/logger.go | 25 ++ internal/server/handler_test.go | 226 +++++++++++ internal/server/handlers.go | 105 +++++ internal/server/routes.go | 34 ++ internal/server/server.go | 130 +++++++ 25 files changed, 2844 insertions(+), 697 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .forgejo/workflows/release.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .goreleaser.forgejo.yaml create mode 100644 .goreleaser.yaml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile.goreleaser create mode 100644 LICENSE create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/flagx/delayed.go create mode 100644 internal/flagx/delayed_test.go create mode 100644 internal/httpx/middleware.go create mode 100644 internal/httpx/response.go create mode 100644 internal/httpx/statuswriter.go create mode 100644 internal/logging/logger.go create mode 100644 internal/server/handler_test.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/server.go diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..2dba697 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,132 @@ +--- +name: CI + +# Forgejo Actions is GitHub-Actions compatible. The CI workflow mirrors +# the GitHub one but: +# - uses `runs-on: docker` (the common Forgejo runner label) +# - omits OIDC / packages permission blocks (Forgejo handles them +# differently; the CI flow doesn't touch the registry anyway) +# - skips govulncheck during PRs to avoid relying on an external HTTPS +# proxy that some self-hosted Forgejo runners block β€” enable it if +# your runners have egress. + +on: + workflow_dispatch: + push: + branches: + - main + - "feature/**" + paths-ignore: + - "**/*.md" + - "Makefile" + - "LICENSE*" + pull_request: + branches: + - main + - "feature/**" + paths-ignore: + - "**/*.md" + - "Makefile" + - "LICENSE*" + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + test: + name: Test + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test (race detector + coverage) + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.out + retention-days: 7 + + build: + name: Build (smoke) + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build linux/amd64 + env: + GOOS: linux + GOARCH: amd64 + CGO_ENABLED: "0" + run: go build -trimpath -ldflags="-s -w" -o /tmp/probe-service-amd64 ./cmd/probe-service + + - name: Build linux/arm64 + env: + GOOS: linux + GOARCH: arm64 + CGO_ENABLED: "0" + run: go build -trimpath -ldflags="-s -w" -o /tmp/probe-service-arm64 ./cmd/probe-service + + goreleaser-check: + name: GoReleaser config check + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate .goreleaser.forgejo.yaml + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check --config .goreleaser.forgejo.yaml diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..57734ca --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,77 @@ +--- +name: Release + +# Strict SemVer tags only. +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: GoReleaser + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Derive the Forgejo container-registry host and API endpoints from + # the Forgejo-provided environment so that the same workflow file + # works against any Forgejo instance (Codeberg, self-hosted, …). + # + # Required repository secret: FORGEJO_TOKEN β€” a token with scopes + # - write:repository (to create the release) + # - write:package (to push to the container registry) + - name: Compute Forgejo endpoints + id: forgejo + run: | + registry=$(echo "${{ github.server_url }}" | sed -E 's|^https?://||') + api="${{ github.server_url }}/api/v1" + download="${{ github.server_url }}" + repo_lower=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + { + echo "registry=${registry}" + echo "api=${api}" + echo "download=${download}" + echo "repo_lower=${repo_lower}" + } >> "$GITHUB_OUTPUT" + + - name: Login to Forgejo container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.forgejo.outputs.registry }} + username: ${{ github.actor }} + password: ${{ secrets.FORGEJO_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --config .goreleaser.forgejo.yaml + env: + GITEA_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + GITEA_API_URL: ${{ steps.forgejo.outputs.api }} + GITEA_DOWNLOAD_URL: ${{ steps.forgejo.outputs.download }} + DOCKER_REGISTRY: ${{ steps.forgejo.outputs.registry }} + DOCKER_REPO: ${{ steps.forgejo.outputs.repo_lower }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea1726b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +--- +name: CI + +# CI runs on every push to a development branch and every PR targeting one +# of those branches. It does NOT run on tag pushes (release.yml handles +# those) and it never touches container registries or releases. +on: + workflow_dispatch: + inputs: + logLevel: + description: "Log level" + required: true + default: "info" + type: choice + options: [debug, info, warning, error] + + push: + branches: + - main + - "feature/**" + paths-ignore: + - "**/*.md" + - "Makefile" + - "LICENSE*" + + pull_request: + branches: + - main + - "feature/**" + paths-ignore: + - "**/*.md" + - "Makefile" + - "LICENSE*" + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::These files are not gofmt'd:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + + test: + name: Test + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test (race detector + coverage) + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.out + retention-days: 7 + + build: + name: Build (smoke) + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # Cross-compile both target architectures to catch arch-specific + # build breakage in the same PR that introduced it. + - name: Build linux/amd64 + env: + GOOS: linux + GOARCH: amd64 + CGO_ENABLED: "0" + run: go build -trimpath -ldflags="-s -w" -o /tmp/probe-service-amd64 ./cmd/probe-service + + - name: Build linux/arm64 + env: + GOOS: linux + GOARCH: arm64 + CGO_ENABLED: "0" + run: go build -trimpath -ldflags="-s -w" -o /tmp/probe-service-arm64 ./cmd/probe-service + + goreleaser-check: + name: GoReleaser config check + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate .goreleaser.yaml + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check + + - name: Validate .goreleaser.forgejo.yaml + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check --config .goreleaser.forgejo.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b60d25f..c4d6401 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,76 +1,71 @@ --- +name: Release -name: CI - -# on: -# push: -# branches: -# #branches-ignore: -# # - '**' -# #tags: -# # - '*.*.*' - +# Release runs ONLY on strict SemVer tags (v1.2.3). Pre-release tags +# (v1.2.3-rc.1) and non-tag pushes are intentionally not matched. on: - #schedule: - # - cron: "40 3 * * 0" - workflow_dispatch: - inputs: - logLevel: - description: 'Log level' - required: true - default: 'warning' - type: choice - options: - - info - - warning - - debug push: - branches: - - 'main' - - 'feature/**' - - '!doc/**' - paths: - - "!Makefile" - - "!README.md" tags: - - '*.*.*' - pull_request: - branches: - - 'main' - - 'feature/**' - - '!doc/**' - paths: - - "!Makefile" - - "!README.md" + - "v[0-9]+.[0-9]+.[0-9]+" +permissions: + contents: write # create GitHub release, upload artifacts + packages: write # push container images to GHCR + id-token: write # reserved for future OIDC use (cosign, attestations) +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - release-linux-amd64: - name: "release with go version: ${{ matrix.go }}" - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - release_tag: - - 0.1.0 - go: - - 1.23 - + release: + name: GoReleaser + runs-on: ubuntu-24.04 steps: - - name: πŸ›Ž Checkout + - name: Checkout uses: actions/checkout@v4 with: + # GoReleaser needs the full history to generate a changelog and + # to derive {{ .Version }} from the tag annotation. fetch-depth: 0 - - uses: wangyoucao577/go-release-action@v1.52 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # QEMU lets us build the linux/arm64 image on an amd64 runner. + # buildx provides the multi-arch builder GoReleaser drives. + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # GHCR requires lowercase image paths; org/repo on GitHub may be mixed case. + - name: Compute lowercase repository + id: repo + run: | + lower=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "lower=${lower}" >> "$GITHUB_OUTPUT" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 with: - github_token: ${{ secrets.GH_REGISTRY_TOKEN }} - release_tag: ${{ matrix.release_tag }} - goos: linux - goarch: amd64 - goversion: ${{ matrix.go }} - binary_name: server - sha256sum: true - overwrite: true - extra_files: LICENSE README.md docker-sd.example + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKER_REGISTRY: ghcr.io + DOCKER_REPO: ${{ steps.repo.outputs.lower }} diff --git a/.goreleaser.forgejo.yaml b/.goreleaser.forgejo.yaml new file mode 100644 index 0000000..a6914bb --- /dev/null +++ b/.goreleaser.forgejo.yaml @@ -0,0 +1,145 @@ +# GoReleaser configuration for the Forgejo release pipeline. +# +# Mirrors `.goreleaser.yaml` but targets: +# - Forgejo's Gitea-compatible release API (release.gitea + gitea_urls) +# - Forgejo's built-in container registry (host == Forgejo instance URL) +# +# Required environment variables (set by the workflow): +# GITEA_TOKEN β€” Forgejo token with packages:write + repository:write +# GITEA_API_URL β€” e.g. "https://forgejo.example.com/api/v1" +# GITEA_DOWNLOAD_URL β€” e.g. "https://forgejo.example.com" +# DOCKER_REGISTRY β€” Forgejo host, e.g. "forgejo.example.com" +# DOCKER_REPO β€” lowercase "/probe-service" + +version: 2 + +project_name: probe-service + +before: + hooks: + - go mod tidy + +builds: + - id: probe-service + main: ./cmd/probe-service + binary: probe-service + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .Date }} + +archives: + - id: probe-service + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + formats: [tar.gz] + files: + - LICENSE* + - README.md + - CHANGELOG.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +dockers: + - id: amd64 + goos: linux + goarch: amd64 + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--build-arg=VERSION={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.source={{ .GitURL }}" + - "--label=org.opencontainers.image.licenses=Apache-2.0" + extra_files: + - rootfs + + - id: arm64 + goos: linux + goarch: arm64 + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--build-arg=VERSION={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.source={{ .GitURL }}" + - "--label=org.opencontainers.image.licenses=Apache-2.0" + extra_files: + - rootfs + +docker_manifests: + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:v{{ .Major }}.{{ .Minor }}" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:latest" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + +# Forgejo's release API is Gitea-compatible. GoReleaser switches to the +# Gitea client because GITEA_TOKEN is set in the workflow environment. +gitea_urls: + api: "{{ .Env.GITEA_API_URL }}" + download: "{{ .Env.GITEA_DOWNLOAD_URL }}" + skip_tls_verify: false + +release: + draft: false + prerelease: false + mode: replace + +changelog: + use: git + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "Merge pull request" + - "Merge branch" + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Performance + regexp: '^.*?perf(\(.+\))??!?:.+$' + order: 2 + - title: Others + order: 999 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4d6ed93 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,136 @@ +# GoReleaser configuration for the GitHub release pipeline. +# +# This file is used by `.github/workflows/release.yml`. The Forgejo +# pipeline uses `.goreleaser.forgejo.yaml`, which differs only in the +# release provider block and the image-registry templates. +# +# Required environment variables (set by the workflow): +# GITHUB_TOKEN β€” token with contents:write and packages:write +# DOCKER_REGISTRY β€” usually "ghcr.io" +# DOCKER_REPO β€” lowercase "/probe-service" + +version: 2 + +project_name: probe-service + +before: + hooks: + - go mod tidy + +builds: + - id: probe-service + main: ./cmd/probe-service + binary: probe-service + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + flags: + - -trimpath + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .Date }} + +archives: + - id: probe-service + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + formats: [tar.gz] + files: + - LICENSE* + - README.md + - CHANGELOG.md + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +dockers: + - id: amd64 + goos: linux + goarch: amd64 + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--build-arg=VERSION={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.source={{ .GitURL }}" + - "--label=org.opencontainers.image.licenses=Apache-2.0" + extra_files: + - rootfs + + - id: arm64 + goos: linux + goarch: arm64 + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + dockerfile: Dockerfile.goreleaser + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--build-arg=VERSION={{ .Version }}" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.title={{ .ProjectName }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.source={{ .GitURL }}" + - "--label=org.opencontainers.image.licenses=Apache-2.0" + extra_files: + - rootfs + +docker_manifests: + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:v{{ .Major }}.{{ .Minor }}" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + - name_template: "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:latest" + image_templates: + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-amd64" + - "{{ .Env.DOCKER_REGISTRY }}/{{ .Env.DOCKER_REPO }}:{{ .Version }}-arm64" + +release: + draft: false + prerelease: false + mode: replace + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "Merge pull request" + - "Merge branch" + groups: + - title: Features + regexp: '^.*?feat(\(.+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\(.+\))??!?:.+$' + order: 1 + - title: Performance + regexp: '^.*?perf(\(.+\))??!?:.+$' + order: 2 + - title: Others + order: 999 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..269305e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2026-05-15 + +### Changed (breaking) + +- **Project layout**: Code moved from a single `main.go` into Go-idiomatic + packages under `cmd/probe-service` and `internal/`. The internal types + (`DelayedFlag`, `cfg`, middleware funcs, helpers) are no longer in + `package main`. Since they were never importable before, no external Go + consumer is affected, but anyone vendoring the source must adapt paths. +- **JSON response shape**: All probe / admin responses now consistently + include `service` and `version` fields. Previously some responses omitted + `service`. Field order is unspecified (Go map iteration), so clients must + not depend on it. +- **Config loading**: `config.Load()` now returns `(Config, error)` instead + of calling `os.Exit` directly. Behaviour on invalid env vars from the + user's perspective is unchanged (process exits with code 2), but the + responsibility for terminating moved to `main`. +- **DelayedFlag race fix**: `Reset()` and the internal expiry callback now + both hold the same mutex. As a side effect, a `Reset()` issued + concurrently with an expiring timer can no longer be overwritten by the + stale timer's `val.Store(true)`. Observable change: in such races, the + flag now stays `false` and the new delay applies (previously it could + briefly flip to `true` and then back to `false`). + +### Added + +- `X-Service-Version` response header on every HTTP response. +- `internal/flagx` package with full unit tests, including race-detector + coverage. +- `internal/server` handler tests using `httptest`. +- Multi-arch Docker build (`linux/amd64`, `linux/arm64`) with non-root user. +- Request latency and response size are now logged in the access log + (previously they were captured but commented out). + +### Removed + +- `drainBody` (was dead code). +- Unused `BaseContext` closure on `http.Server` (the default is already + `context.Background()`). + +### Fixed + +- Race in `DelayedFlag` where a timer firing concurrently with `Reset()` + could overwrite the freshly-reset `false` state with `true`. diff --git a/Dockerfile b/Dockerfile index 7b44739..8e78bbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,73 +1,83 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 -FROM mirror.gcr.io/alpine:3 AS downloader +# ----------------------------------------------------------------------------- +# Stage 1: download wait4x for the target architecture and verify its checksum. +# Build host arch may differ from target arch (e.g. building arm64 on amd64), +# so we use the buildx-provided $TARGETARCH and grab the matching release. +# ----------------------------------------------------------------------------- +FROM --platform=$BUILDPLATFORM mirror.gcr.io/alpine:3 AS downloader +ARG TARGETARCH ARG VERSION_WAIT4X=3.6.0 -RUN \ - echo 'hosts: files dns' >> /etc/nsswitch.conf && \ - apk update --quiet --no-cache && \ - apk upgrade --quiet --no-cache && \ - apk add --quiet --no-cache \ - curl \ - ca-certificates +RUN echo 'hosts: files dns' >> /etc/nsswitch.conf \ + && apk add --quiet --no-cache curl ca-certificates WORKDIR /tmp -# install wait4x -RUN \ - curl \ - --verbose \ - --location \ - --output wait4x-linux-amd64.tar.gz \ - "https://github.com/wait4x/wait4x/releases/download/v${VERSION_WAIT4X}/wait4x-linux-amd64.tar.gz" - -RUN \ - curl \ - --silent \ - --location \ - --output wait4x-linux-amd64.tar.gz.sha256sum \ - "https://github.com/wait4x/wait4x/releases/download/v${VERSION_WAIT4X}/wait4x-linux-amd64.tar.gz.sha256sum" - -RUN \ - set -e; \ - sha256sum -c wait4x-linux-amd64.tar.gz.sha256sum && \ - tar -xzf wait4x-linux-amd64.tar.gz - -# ------------------------------- - -FROM mirror.gcr.io/golang:1.23-alpine AS build +# Map Docker's TARGETARCH naming (amd64, arm64) to wait4x's release naming. +# wait4x publishes amd64 and arm64 binaries for linux; abort on others. +RUN set -eu; \ + case "${TARGETARCH}" in \ + amd64) ARCH=amd64 ;; \ + arm64) ARCH=arm64 ;; \ + *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + FILE="wait4x-linux-${ARCH}.tar.gz"; \ + BASE="https://github.com/wait4x/wait4x/releases/download/v${VERSION_WAIT4X}"; \ + curl --fail --silent --show-error --location --output "${FILE}" "${BASE}/${FILE}"; \ + curl --fail --silent --show-error --location --output "${FILE}.sha256sum" "${BASE}/${FILE}.sha256sum"; \ + sha256sum -c "${FILE}.sha256sum"; \ + tar -xzf "${FILE}"; \ + mv wait4x /out-wait4x; \ + chmod 0755 /out-wait4x + +# ----------------------------------------------------------------------------- +# Stage 2: build the probe-service binary for the target architecture. +# We pin Go to 1.24 (matches go.mod) and produce a stripped, trimmed, static +# binary suitable for a scratch- or alpine-based runtime image. +# ----------------------------------------------------------------------------- +FROM --platform=$BUILDPLATFORM mirror.gcr.io/golang:1.24-alpine AS build + +ARG TARGETOS +ARG TARGETARCH WORKDIR /src +# Cache module downloads in a dedicated layer. There are currently no +# external dependencies, so go.sum is absent; bind-mount any future +# go.sum at build time or replace this with an explicit COPY. COPY go.mod ./ RUN go mod download -# now copy the rest COPY . . -RUN \ - CGO_ENABLED=0 GOOS=linux GOARCH=amd64; \ - go build -trimpath -ldflags="-s -w" -o /out/probe-service ./cmd/probe-service - -# ------------------------------- +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" \ + -o /out/probe-service ./cmd/probe-service +# ----------------------------------------------------------------------------- +# Stage 3: runtime image. Alpine is kept because the existing entrypoint.sh +# requires bash. A non-root user is created and used as the default. +# ----------------------------------------------------------------------------- FROM mirror.gcr.io/alpine:3 -RUN \ - echo 'hosts: files dns' >> /etc/nsswitch.conf && \ - apk update --quiet --no-cache && \ - apk upgrade --quiet --no-cache && \ - apk add --quiet --no-cache \ - bash \ - ca-certificates +RUN echo 'hosts: files dns' >> /etc/nsswitch.conf \ + && apk add --quiet --no-cache bash ca-certificates tini \ + && addgroup -S app \ + && adduser -S -G app -u 10001 -h /home/app app ENV PORT=8080 EXPOSE 8080 + COPY rootfs / -COPY --from=build /out/probe-service /usr/bin/probe-service -COPY --from=downloader /tmp/wait4x /usr/bin/wait4x -# USER nonroot:nonroot -ENTRYPOINT ["/bin/entrypoint.sh"] -# CMD ["/bin/wait4x"] +COPY --from=build /out/probe-service /usr/bin/probe-service +COPY --from=downloader /out-wait4x /usr/bin/wait4x + +RUN chmod 0755 /usr/bin/probe-service /usr/bin/wait4x /bin/entrypoint.sh + +USER 10001:10001 +# tini reaps zombies and forwards signals to the process tree, which matters +# when the entrypoint script execs another binary. +ENTRYPOINT ["/sbin/tini", "--", "/bin/entrypoint.sh"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..3a718df --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1.7 +# +# Dockerfile used by GoReleaser. Unlike the canonical Dockerfile (which +# compiles the Go binary itself), this variant assumes the binary has +# already been produced by `goreleaser build` and is sitting in the build +# context next to this file. That makes the release pipeline 3-5x faster +# because: +# - Go toolchain is not pulled into the image build +# - The Go cache is reused across architectures +# - GoReleaser handles cross-compilation cheaper than QEMU-driven docker +# +# The wait4x sidecar binary IS downloaded per-architecture during the +# image build, because it is not a Go artifact and must match the target +# platform. To avoid Docker doing the curl under emulated QEMU we resolve +# TARGETARCH before downloading. + +ARG VERSION_WAIT4X=3.6.0 + +# ----------------------------------------------------------------------------- +# Stage 1: download wait4x for the target architecture. +# ----------------------------------------------------------------------------- +FROM --platform=$BUILDPLATFORM mirror.gcr.io/alpine:3 AS wait4x + +ARG TARGETARCH +ARG VERSION_WAIT4X + +RUN apk add --quiet --no-cache curl ca-certificates + +WORKDIR /tmp + +RUN set -eu; \ + case "${TARGETARCH}" in \ + amd64|arm64) ARCH="${TARGETARCH}" ;; \ + *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + FILE="wait4x-linux-${ARCH}.tar.gz"; \ + BASE="https://github.com/wait4x/wait4x/releases/download/v${VERSION_WAIT4X}"; \ + curl --fail --silent --show-error --location --output "${FILE}" "${BASE}/${FILE}"; \ + curl --fail --silent --show-error --location --output "${FILE}.sha256sum" "${BASE}/${FILE}.sha256sum"; \ + sha256sum -c "${FILE}.sha256sum"; \ + tar -xzf "${FILE}"; \ + mv wait4x /out-wait4x; \ + chmod 0755 /out-wait4x + +# ----------------------------------------------------------------------------- +# Stage 2: runtime image. +# ----------------------------------------------------------------------------- +FROM mirror.gcr.io/alpine:3 + +ARG VERSION="dev" + +# Set VERSION as a runtime env var so the probe-service reports the actual +# release tag in its JSON responses and X-Service-Version header. +ENV VERSION=${VERSION} \ + PORT=8080 + +RUN echo 'hosts: files dns' >> /etc/nsswitch.conf \ + && apk add --quiet --no-cache bash ca-certificates tini \ + && addgroup -S app \ + && adduser -S -G app -u 10001 -h /home/app app + +EXPOSE 8080 + +# GoReleaser places the linux/ binary as `probe-service` in the +# build context root. `rootfs` is provided via extra_files in .goreleaser.yaml. +COPY rootfs / +COPY probe-service /usr/bin/probe-service +COPY --from=wait4x /out-wait4x /usr/bin/wait4x + +RUN chmod 0755 /usr/bin/probe-service /usr/bin/wait4x /bin/entrypoint.sh + +USER 10001:10001 + +ENTRYPOINT ["/sbin/tini", "--", "/bin/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile index 03f8a8b..f4905bf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ ## --- Versionierung (ENV hat Vorrang, sonst Fallbacks) --- -VERSION ?= "1.0.0" +VERSION ?= "2.0.0" COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -DATE ?= "2025-12-20" +DATE ?= "2026-05-15" IMAGE ?= bodsch/probe-service NOCACHE := "--cache" diff --git a/cmd/probe-service/main.go b/cmd/probe-service/main.go index 46c8ff3..b9a5e92 100644 --- a/cmd/probe-service/main.go +++ b/cmd/probe-service/main.go @@ -1,609 +1,66 @@ -// Package main implements a small HTTP service that exposes liveness/readiness probes -// with a configurable startup delay and admin endpoints to reset probe state. -// The service is intended for debugging and orchestration testing (e.g., Kubernetes). +// Command probe-service runs an HTTP service that exposes liveness and +// readiness probes with a configurable startup delay and admin endpoints +// to reset probe state. It is intended for orchestration testing +// (Kubernetes readiness/liveness, load-balancer health checks, etc.). package main import ( "context" - "crypto/rand" - "encoding/base64" - "encoding/json" "errors" "fmt" - "io" - "log/slog" - "net" "net/http" "os" "os/signal" - "strconv" - "strings" - "sync" - "sync/atomic" "syscall" - "time" + + "bodsch.me/probe-service/internal/config" + "bodsch.me/probe-service/internal/logging" + "bodsch.me/probe-service/internal/server" ) -// cfg holds all runtime configuration derived from environment variables. -// Values are intentionally kept simple and service-centric (timeouts, port, limits). -type cfg struct { - Port int - StartupDelay time.Duration // applies to BOTH health+ready - ServiceName string - Version string - ShutdownWait time.Duration - ReadTimeout time.Duration - WriteTimeout time.Duration - IdleTimeout time.Duration - MaxBodyBytes int64 - LogLevel slog.Level -} +// Build metadata, populated at link time by GoReleaser via: +// +// go build -ldflags "-X main.version=... -X main.commit=... -X main.date=..." +// +// They are intentionally exported as plain package-level strings so any +// build tool (GoReleaser, Make, Bazel) can fill them without reflection. +// The runtime VERSION env var (handled by config.Load) still wins for the +// HTTP responses; these values exist primarily for the startup log line +// and for embedding build provenance into the binary. +var ( + version = "dev" + commit = "none" + date = "unknown" +) -// main configures and starts the HTTP server, sets up delayed health/readiness flags, -// registers endpoints, and performs graceful shutdown on SIGINT/SIGTERM. +// main is the process entrypoint. It loads configuration, builds a logger +// and server, wires signal-based cancellation, and forwards non-trivial +// errors to the OS as a non-zero exit code. func main() { - c := loadCfg() - log := newLogger(c) - - health := NewDelayedFlag(c.StartupDelay) - ready := NewDelayedFlag(c.StartupDelay) - - mux := http.NewServeMux() - - // health: 200 only if "healthy" flag is true - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - if !health.Load() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{ - "status": "unhealthy", - "service": c.ServiceName, - "retry_after_ms": health.Remaining().Milliseconds(), - "time": time.Now().UTC().Format(time.RFC3339), - }) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "service": c.ServiceName, - "time": time.Now().UTC().Format(time.RFC3339), - }) - }) - - // health: 200 only if "healthy" flag is true - mux.HandleFunc("/actuator/health/liveness", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - if !health.Load() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{ - "status": "unhealthy", - "service": c.ServiceName, - "retry_after_ms": health.Remaining().Milliseconds(), - "time": time.Now().UTC().Format(time.RFC3339), - }) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "service": c.ServiceName, - "time": time.Now().UTC().Format(time.RFC3339), - }) - }) - - // ready: 200 only if "ready" flag is true - mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - if !ready.Load() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{ - "status": "not-ready", - "service": c.ServiceName, - "retry_after_ms": ready.Remaining().Milliseconds(), - "time": time.Now().UTC().Format(time.RFC3339), - }) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ready", - "service": c.ServiceName, - "time": time.Now().UTC().Format(time.RFC3339), - }) - }) - - // ready: 200 only if "ready" flag is true - mux.HandleFunc("/actuator/health/readiness", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - if !ready.Load() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{ - "status": "not-ready", - "service": c.ServiceName, - "retry_after_ms": ready.Remaining().Milliseconds(), - "time": time.Now().UTC().Format(time.RFC3339), - }) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ready", - "service": c.ServiceName, - "time": time.Now().UTC().Format(time.RFC3339), - }) - }) - - // Admin: reset both flags back to "false" and re-apply the startup delay - // (Intentionally POST-only. Add auth if you ever expose this beyond localhost.) - mux.HandleFunc("/admin/reset", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - health.Reset() - ready.Reset() - writeJSON(w, http.StatusOK, map[string]any{ - "health": false, - "ready": false, - "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339), - "health_in_ms": health.Remaining().Milliseconds(), - "ready_in_ms": ready.Remaining().Milliseconds(), - }) - }) - - // /admin/health/reset resets only the health flag to false and restarts its delay timer. - mux.HandleFunc("/admin/health/reset", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - health.Reset() - writeJSON(w, http.StatusOK, map[string]any{ - "health": false, - "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339), - "health_in_ms": health.Remaining().Milliseconds(), - }) - }) - - // /admin/ready/reset resets only the ready flag to false and restarts its delay timer. - mux.HandleFunc("/admin/ready/reset", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") - return - } - ready.Reset() - writeJSON(w, http.StatusOK, map[string]any{ - "ready": false, - "delay": c.StartupDelay.String(), - "time": time.Now().UTC().Format(time.RFC3339), - "ready_in_ms": ready.Remaining().Milliseconds(), - }) - }) - - // srv is the configured HTTP server instance with timeouts and middleware. - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", c.Port), - Handler: withMiddleware(mux, log, c.MaxBodyBytes), - ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: c.ReadTimeout, - WriteTimeout: c.WriteTimeout, - IdleTimeout: c.IdleTimeout, - BaseContext: func(_ net.Listener) context.Context { - return context.Background() - }, + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config error: %v\n", err) + os.Exit(2) } - ln, err := net.Listen("tcp", srv.Addr) + log := logging.New(cfg.LogLevel) + log.Info("build info", + "version", version, + "commit", commit, + "date", date, + ) + + srv, err := server.New(cfg, log) if err != nil { - log.Error("listen failed", "addr", srv.Addr, "err", err) + log.Error("server build failed", "err", err) os.Exit(1) } - log.Info("starting", - "service", c.ServiceName, - "version", c.Version, - "addr", srv.Addr, - "startup_delay", c.StartupDelay.String(), - ) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - errCh := make(chan error, 1) - go func() { - err := srv.Serve(ln) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - return - } - errCh <- nil - }() - - select { - case <-ctx.Done(): - log.Info("shutdown requested") - case err := <-errCh: - if err != nil { - log.Error("server error", "err", err) - } - } - - shutdownCtx, cancel := context.WithTimeout(context.Background(), c.ShutdownWait) - defer cancel() - - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Error("shutdown failed", "err", err) + if err := srv.Run(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server terminated", "err", err) os.Exit(1) } - log.Info("shutdown complete") -} - -/* -Delayed flag (atomic + safe reset) -*/ - -// DelayedFlag represents a boolean state that becomes true only after a configured delay. -// It is safe for concurrent use and supports Reset() without old timers flipping the flag. -type DelayedFlag struct { - delay time.Duration - val atomic.Bool - deadline atomic.Int64 // unix nanos; 0 = none - gen atomic.Uint64 - - mu sync.Mutex - timer *time.Timer -} - -// NewDelayedFlag creates a DelayedFlag and immediately schedules it to flip to true -// after the given delay. A non-positive delay makes the flag true immediately. -func NewDelayedFlag(delay time.Duration) *DelayedFlag { - f := &DelayedFlag{delay: delay} - f.Reset() - return f -} - -// Load returns the current state of the flag. -func (f *DelayedFlag) Load() bool { return f.val.Load() } - -// Reset sets the flag to false and schedules it to flip to true after the configured delay. -// It can be called repeatedly; a generation guard ensures older timers do not win. -func (f *DelayedFlag) Reset() { - g := f.gen.Add(1) - - f.val.Store(false) - - f.mu.Lock() - defer f.mu.Unlock() - - if f.timer != nil { - _ = f.timer.Stop() - f.timer = nil - } - - if f.delay <= 0 { - f.deadline.Store(0) - f.val.Store(true) - return - } - - dl := time.Now().Add(f.delay).UnixNano() - f.deadline.Store(dl) - - f.timer = time.AfterFunc(f.delay, func() { - if f.gen.Load() != g { - return - } - f.val.Store(true) - f.deadline.Store(0) - }) -} - -// Remaining returns the remaining time until the flag becomes true. -// If the flag is already true (or no deadline is set), it returns 0. -func (f *DelayedFlag) Remaining() time.Duration { - dl := f.deadline.Load() - if dl <= 0 { - return 0 - } - rem := time.Until(time.Unix(0, dl)) - if rem < 0 { - return 0 - } - return rem -} - -/* -Middleware + logging (JSON) -*/ - -// withMiddleware composes all HTTP middlewares in a fixed order: -// body size limit -> request ID -> panic recovery -> access logging. -func withMiddleware(next http.Handler, log *slog.Logger, maxBodyBytes int64) http.Handler { - var h http.Handler = next - h = maxBody(maxBodyBytes)(h) - h = requestID()(h) - h = recoverer(log)(h) - h = accessLog(log)(h) - return h -} - -// middleware is a function that wraps an http.Handler with additional behavior. -type middleware func(http.Handler) http.Handler - -// requestID adds/propagates a request ID for each request and exposes it as X-Request-Id. -// The ID is stored in the request context for use by downstream handlers/middlewares. -func requestID() middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := newRequestID() - w.Header().Set("X-Request-Id", id) - r = r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID{}, id)) - next.ServeHTTP(w, r) - }) - } -} - -// recoverer converts panics into HTTP 500 responses and logs the panic. -// This prevents crashing the whole process due to a single request handler bug. -func recoverer(log *slog.Logger) middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if rec := recover(); rec != nil { - log.Error("panic recovered", - "panic", rec, - "request_id", requestIDFromContext(r.Context()), - ) - writeError(w, http.StatusInternalServerError, "internal_error") - } - }() - next.ServeHTTP(w, r) - }) - } -} - -// maxBody applies a maximum request body size using http.MaxBytesReader. -// A non-positive max disables body limiting. -func maxBody(max int64) middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if max > 0 { - r.Body = http.MaxBytesReader(w, r.Body, max) - } - next.ServeHTTP(w, r) - }) - } -} - -// accessLog logs request/response metadata in structured JSON form. -func accessLog(log *slog.Logger) middleware { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // start := time.Now() - sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} - - next.ServeHTTP(sw, r) - - log.Info("probe", - "method", r.Method, - "path", r.URL.Path, - "status", sw.status, - // "bytes", sw.bytes, - "user-agent", r.UserAgent(), - "remote", r.RemoteAddr, - // "x-forwarded-for", r.Header.Get("X-Forwarded-For"), - ) - }) - } -} - -// statusWriter wraps http.ResponseWriter to capture status code and number of bytes written. -type statusWriter struct { - http.ResponseWriter - status int - bytes int64 -} - -// WriteHeader captures the status code before passing it to the underlying ResponseWriter. -func (w *statusWriter) WriteHeader(statusCode int) { - w.status = statusCode - w.ResponseWriter.WriteHeader(statusCode) -} - -// Write captures the number of bytes written and forwards the write to the underlying ResponseWriter. -func (w *statusWriter) Write(b []byte) (int, error) { - n, err := w.ResponseWriter.Write(b) - w.bytes += int64(n) - return n, err -} - -/* -JSON responses -*/ - -// writeJSON writes a JSON response with the given status code. -// Encoding errors are intentionally ignored for simplicity (best-effort response). -func writeJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -// writeError writes a small, consistent JSON error response. -func writeError(w http.ResponseWriter, status int, code string) { - writeJSON(w, status, map[string]any{ - "error": code, - "time": time.Now().UTC().Format(time.RFC3339), - }) -} - -/* -Request-ID context -*/ - -// ctxKeyRequestID is a private context key type to avoid collisions with other packages. -type ctxKeyRequestID struct{} - -// requestIDFromContext returns the request ID stored in the context or an empty string. -func requestIDFromContext(ctx context.Context) string { - v := ctx.Value(ctxKeyRequestID{}) - if s, ok := v.(string); ok { - return s - } - return "" -} - -// newRequestID creates a URL-safe, compact, random request ID. -func newRequestID() string { - var b [18]byte - _, _ = rand.Read(b[:]) - return base64.RawURLEncoding.EncodeToString(b[:]) -} - -/* -Config + logger -*/ - -// loadCfg reads configuration from environment variables and applies defaults. -// Some values are validated and will cause the process to exit on invalid input. -func loadCfg() cfg { - port := mustEnvInt("PORT", 8080) - startupDelay := mustEnvDuration("STARTUP_DELAY", 30*time.Second) - - serviceName := envStr("SERVICE_NAME", "probe-service") - version := envStr("VERSION", "1.0.0") - - shutdownWait := mustEnvDuration("SHUTDOWN_WAIT", 10*time.Second) - - readTimeout := mustEnvDuration("READ_TIMEOUT", 15*time.Second) - writeTimeout := mustEnvDuration("WRITE_TIMEOUT", 15*time.Second) - idleTimeout := mustEnvDuration("IDLE_TIMEOUT", 60*time.Second) - - maxBody := mustEnvInt64("MAX_BODY_BYTES", 1<<20) // 1 MiB default - - level := parseSlogLevel(envStr("LOG_LEVEL", "info")) - - return cfg{ - Port: port, - StartupDelay: startupDelay, - ServiceName: serviceName, - Version: version, - ShutdownWait: shutdownWait, - - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - IdleTimeout: idleTimeout, - MaxBodyBytes: maxBody, - LogLevel: level, - } -} - -// newLogger constructs a JSON slog logger writing to stdout with the configured level. - -func newLogger(c cfg) *slog.Logger { - h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: c.LogLevel, - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - // JSONHandler emits time in RFC3339Nano by default. - // Replace it with RFC3339 (no fractional seconds). - if a.Key == slog.TimeKey { - // a.Value.Time().Format(time.RFC3339) - t := a.Value.Time().UTC() - a.Value = slog.StringValue(t.Format(time.RFC3339)) - } - return a - }, - }) - return slog.New(h) -} - -// envStr returns the environment variable value for k, or def if it is empty/whitespace. -func envStr(k, def string) string { - v := strings.TrimSpace(os.Getenv(k)) - if v == "" { - return def - } - return v -} - -// mustEnvInt reads an integer environment variable and validates a sane port-like range. -// On invalid value, the process exits with a non-zero status. -func mustEnvInt(k string, def int) int { - v := strings.TrimSpace(os.Getenv(k)) - if v == "" { - return def - } - n, err := strconv.Atoi(v) - if err != nil || n <= 0 || n > 65535 { - fmt.Fprintf(os.Stderr, "invalid %s=%q\n", k, v) - os.Exit(2) - } - return n -} - -// mustEnvInt64 reads an int64 environment variable and enforces it is positive. -// On invalid value, the process exits with a non-zero status. -func mustEnvInt64(k string, def int64) int64 { - v := strings.TrimSpace(os.Getenv(k)) - if v == "" { - return def - } - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n <= 0 { - fmt.Fprintf(os.Stderr, "invalid %s=%q\n", k, v) - os.Exit(2) - } - return n -} - -// mustEnvDuration reads a duration environment variable (time.ParseDuration) and validates it is non-negative. -// On invalid value, the process exits with a non-zero status. -func mustEnvDuration(k string, def time.Duration) time.Duration { - v := strings.TrimSpace(os.Getenv(k)) - if v == "" { - return def - } - d, err := time.ParseDuration(v) - if err != nil || d < 0 { - fmt.Fprintf(os.Stderr, "invalid %s=%q\n", k, v) - os.Exit(2) - } - return d -} - -// parseSlogLevel converts a string into a slog.Level with a conservative default of info. -func parseSlogLevel(s string) slog.Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "debug": - return slog.LevelDebug - case "info", "": - return slog.LevelInfo - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } -} - -/* -(Optional) drain body for keep-alive safety in some handlers -*/ - -// drainBody reads and closes the request body to allow connection reuse in keep-alive scenarios. -// It is currently unused but can be helpful for handlers that abort early. -func drainBody(r *http.Request) { - if r.Body == nil { - return - } - _, _ = io.Copy(io.Discard, r.Body) - _ = r.Body.Close() } diff --git a/go.mod b/go.mod index 4c17e7d..115fd74 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module bodsch.me/probe-service -go 1.23.0 +go 1.24 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1540b1e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,163 @@ +// Package config loads runtime configuration from environment variables +// and validates it. It returns errors instead of calling os.Exit so that +// the calling main package retains control over process termination and +// so that the loader is unit-testable. +package config + +import ( + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" +) + +// Config holds all runtime configuration derived from environment variables. +type Config struct { + // Port is the TCP port the HTTP server binds to. + Port int + // StartupDelay is applied to BOTH the liveness and readiness flags + // after process start and after every admin reset. + StartupDelay time.Duration + // ServiceName is reported in JSON responses (json: "service"). + ServiceName string + // Version is reported in JSON responses and the X-Service-Version header. + Version string + // ShutdownWait is the maximum time the server is given to drain in-flight + // requests during graceful shutdown. + ShutdownWait time.Duration + // ReadTimeout, WriteTimeout, IdleTimeout map to the corresponding fields + // on http.Server. + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + // MaxBodyBytes caps the request body size. Non-positive disables the cap. + MaxBodyBytes int64 + // LogLevel is the minimum slog level emitted by the logger. + LogLevel slog.Level +} + +// Load reads environment variables and returns a validated Config. +// On any invalid value, Load returns an error describing the offending key. +// +// Recognised variables and defaults: +// +// PORT (int 1-65535) default 8080 +// STARTUP_DELAY (time.Duration) default 30s +// SERVICE_NAME (string) default "probe-service" +// VERSION (string) default "1.0.0" +// SHUTDOWN_WAIT (time.Duration) default 10s +// READ_TIMEOUT (time.Duration) default 15s +// WRITE_TIMEOUT (time.Duration) default 15s +// IDLE_TIMEOUT (time.Duration) default 60s +// MAX_BODY_BYTES (int64 > 0) default 1 MiB +// LOG_LEVEL (debug|info|warn|error) default info +func Load() (Config, error) { + port, err := envInt("PORT", 8080, 1, 65535) + if err != nil { + return Config{}, err + } + startupDelay, err := envDuration("STARTUP_DELAY", 30*time.Second, false) + if err != nil { + return Config{}, err + } + shutdownWait, err := envDuration("SHUTDOWN_WAIT", 10*time.Second, false) + if err != nil { + return Config{}, err + } + readTimeout, err := envDuration("READ_TIMEOUT", 15*time.Second, false) + if err != nil { + return Config{}, err + } + writeTimeout, err := envDuration("WRITE_TIMEOUT", 15*time.Second, false) + if err != nil { + return Config{}, err + } + idleTimeout, err := envDuration("IDLE_TIMEOUT", 60*time.Second, false) + if err != nil { + return Config{}, err + } + maxBody, err := envInt64("MAX_BODY_BYTES", 1<<20, 1) + if err != nil { + return Config{}, err + } + + return Config{ + Port: port, + StartupDelay: startupDelay, + ServiceName: envStr("SERVICE_NAME", "probe-service"), + Version: envStr("VERSION", "1.0.0"), + ShutdownWait: shutdownWait, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxBodyBytes: maxBody, + LogLevel: parseLogLevel(envStr("LOG_LEVEL", "info")), + }, nil +} + +// envStr returns the trimmed environment variable for key, or def if empty. +func envStr(key, def string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + return v +} + +// envInt parses an int env var and ensures it lies within [min, max]. +func envInt(key string, def, minVal, maxVal int) (int, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < minVal || n > maxVal { + return 0, fmt.Errorf("invalid %s=%q (expected int in [%d,%d])", key, v, minVal, maxVal) + } + return n, nil +} + +// envInt64 parses an int64 env var and ensures it is >= minVal. +func envInt64(key string, def, minVal int64) (int64, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < minVal { + return 0, fmt.Errorf("invalid %s=%q (expected int64 >= %d)", key, v, minVal) + } + return n, nil +} + +// envDuration parses a time.Duration env var. If allowNegative is false, +// negative durations are rejected. +func envDuration(key string, def time.Duration, allowNegative bool) (time.Duration, error) { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil || (!allowNegative && d < 0) { + return 0, fmt.Errorf("invalid %s=%q (expected duration like 30s, 1m)", key, v) + } + return d, nil +} + +// parseLogLevel maps a string to a slog.Level. Unknown values fall back to info. +func parseLogLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "info", "": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3c4dcb1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,124 @@ +package config + +import ( + "log/slog" + "testing" + "time" +) + +// TestLoad_Defaults verifies that calling Load with no env vars yields +// the documented defaults. +func TestLoad_Defaults(t *testing.T) { + t.Setenv("PORT", "") + t.Setenv("STARTUP_DELAY", "") + t.Setenv("SERVICE_NAME", "") + t.Setenv("VERSION", "") + t.Setenv("SHUTDOWN_WAIT", "") + t.Setenv("READ_TIMEOUT", "") + t.Setenv("WRITE_TIMEOUT", "") + t.Setenv("IDLE_TIMEOUT", "") + t.Setenv("MAX_BODY_BYTES", "") + t.Setenv("LOG_LEVEL", "") + + c, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if c.Port != 8080 { + t.Errorf("Port = %d, want 8080", c.Port) + } + if c.StartupDelay != 30*time.Second { + t.Errorf("StartupDelay = %v, want 30s", c.StartupDelay) + } + if c.ServiceName != "probe-service" { + t.Errorf("ServiceName = %q, want %q", c.ServiceName, "probe-service") + } + if c.Version != "1.0.0" { + t.Errorf("Version = %q, want %q", c.Version, "1.0.0") + } + if c.MaxBodyBytes != 1<<20 { + t.Errorf("MaxBodyBytes = %d, want %d", c.MaxBodyBytes, 1<<20) + } + if c.LogLevel != slog.LevelInfo { + t.Errorf("LogLevel = %v, want Info", c.LogLevel) + } +} + +// TestLoad_Overrides verifies that all supported variables are honoured. +func TestLoad_Overrides(t *testing.T) { + t.Setenv("PORT", "9090") + t.Setenv("STARTUP_DELAY", "5s") + t.Setenv("SERVICE_NAME", "probe") + t.Setenv("VERSION", "2.3.4") + t.Setenv("MAX_BODY_BYTES", "2048") + t.Setenv("LOG_LEVEL", "debug") + + c, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if c.Port != 9090 { + t.Errorf("Port = %d, want 9090", c.Port) + } + if c.StartupDelay != 5*time.Second { + t.Errorf("StartupDelay = %v, want 5s", c.StartupDelay) + } + if c.ServiceName != "probe" { + t.Errorf("ServiceName = %q, want %q", c.ServiceName, "probe") + } + if c.Version != "2.3.4" { + t.Errorf("Version = %q, want %q", c.Version, "2.3.4") + } + if c.MaxBodyBytes != 2048 { + t.Errorf("MaxBodyBytes = %d, want 2048", c.MaxBodyBytes) + } + if c.LogLevel != slog.LevelDebug { + t.Errorf("LogLevel = %v, want Debug", c.LogLevel) + } +} + +// TestLoad_InvalidValues verifies that bad input produces an error +// instead of crashing the process. +func TestLoad_InvalidValues(t *testing.T) { + cases := []struct { + name string + key string + val string + }{ + {"port not int", "PORT", "abc"}, + {"port out of range", "PORT", "70000"}, + {"port zero", "PORT", "0"}, + {"duration garbage", "STARTUP_DELAY", "not-a-duration"}, + {"duration negative", "STARTUP_DELAY", "-1s"}, + {"max body zero", "MAX_BODY_BYTES", "0"}, + {"max body garbage", "MAX_BODY_BYTES", "huge"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(tc.key, tc.val) + if _, err := Load(); err == nil { + t.Fatalf("Load(%s=%q) returned nil error", tc.key, tc.val) + } + }) + } +} + +// TestParseLogLevel checks the level-name mapping including fallback. +func TestParseLogLevel(t *testing.T) { + cases := map[string]slog.Level{ + "debug": slog.LevelDebug, + "info": slog.LevelInfo, + "": slog.LevelInfo, + "warn": slog.LevelWarn, + "warning": slog.LevelWarn, + "error": slog.LevelError, + "bogus": slog.LevelInfo, + } + for in, want := range cases { + if got := parseLogLevel(in); got != want { + t.Errorf("parseLogLevel(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/flagx/delayed.go b/internal/flagx/delayed.go new file mode 100644 index 0000000..01d7eda --- /dev/null +++ b/internal/flagx/delayed.go @@ -0,0 +1,105 @@ +// Package flagx provides small concurrency primitives used by the probe +// service. The central type is DelayedFlag, a boolean state that becomes +// true only after a configured delay and can be reset safely from any +// goroutine. +package flagx + +import ( + "sync" + "sync/atomic" + "time" +) + +// DelayedFlag is a boolean state that becomes true after a configured +// delay. It is safe for concurrent use. +// +// The flag distinguishes between three logical states: +// - false, expiring at time T β†’ Load() returns false; Remaining()>0 +// - false, never expires β†’ Load() returns false; Remaining()==0 +// (only reachable transiently between Reset and timer start) +// - true β†’ Load() returns true; Remaining()==0 +// +// Reset() can be called any number of times. A generation counter +// guarded by the same mutex as the timer callback prevents stale timers +// from flipping the flag after a fresh Reset. +type DelayedFlag struct { + delay time.Duration + + // val is read lock-free on the hot path (Load). + val atomic.Bool + // deadline carries the timer expiry in UnixNano; 0 means "no pending timer". + // It is read lock-free by Remaining() to avoid contention with frequent + // HTTP probes. + deadline atomic.Int64 + + // mu protects gen and timer, and serialises Reset with the timer callback + // so that a stale callback cannot overwrite val. + mu sync.Mutex + gen uint64 + timer *time.Timer +} + +// NewDelayedFlag creates a DelayedFlag, sets it to false, and immediately +// schedules it to flip to true after delay. A non-positive delay makes the +// flag true at construction time. +func NewDelayedFlag(delay time.Duration) *DelayedFlag { + f := &DelayedFlag{delay: delay} + f.Reset() + return f +} + +// Load returns the current boolean state without acquiring a lock. +func (f *DelayedFlag) Load() bool { return f.val.Load() } + +// Reset sets the flag to false and schedules it to flip to true after +// the configured delay. Concurrent calls and a concurrent timer expiry +// cannot leave the flag in an inconsistent state: the latest Reset wins. +func (f *DelayedFlag) Reset() { + f.mu.Lock() + defer f.mu.Unlock() + + f.gen++ + g := f.gen + f.val.Store(false) + + if f.timer != nil { + f.timer.Stop() + f.timer = nil + } + + if f.delay <= 0 { + f.deadline.Store(0) + f.val.Store(true) + return + } + + f.deadline.Store(time.Now().Add(f.delay).UnixNano()) + f.timer = time.AfterFunc(f.delay, func() { f.expire(g) }) +} + +// expire is the timer callback. It only flips val to true if the +// generation it was scheduled under is still current; otherwise it is +// the leftover of a stopped/superseded timer and must do nothing. +func (f *DelayedFlag) expire(g uint64) { + f.mu.Lock() + defer f.mu.Unlock() + if f.gen != g { + return + } + f.val.Store(true) + f.deadline.Store(0) +} + +// Remaining returns the time left until the flag flips to true. +// It returns 0 when the flag is already true or when no timer is pending. +func (f *DelayedFlag) Remaining() time.Duration { + dl := f.deadline.Load() + if dl <= 0 { + return 0 + } + rem := time.Until(time.Unix(0, dl)) + if rem < 0 { + return 0 + } + return rem +} diff --git a/internal/flagx/delayed_test.go b/internal/flagx/delayed_test.go new file mode 100644 index 0000000..88fcebe --- /dev/null +++ b/internal/flagx/delayed_test.go @@ -0,0 +1,141 @@ +package flagx + +import ( + "sync" + "testing" + "time" +) + +// TestDelayedFlag_ZeroDelayImmediatelyTrue ensures that a zero or negative +// delay produces a flag that is already true at construction time. +func TestDelayedFlag_ZeroDelayImmediatelyTrue(t *testing.T) { + for _, d := range []time.Duration{0, -1 * time.Second} { + f := NewDelayedFlag(d) + if !f.Load() { + t.Errorf("NewDelayedFlag(%v).Load() = false, want true", d) + } + if f.Remaining() != 0 { + t.Errorf("NewDelayedFlag(%v).Remaining() = %v, want 0", d, f.Remaining()) + } + } +} + +// TestDelayedFlag_FlipsAfterDelay verifies the basic happy path. +func TestDelayedFlag_FlipsAfterDelay(t *testing.T) { + f := NewDelayedFlag(40 * time.Millisecond) + + if f.Load() { + t.Fatal("flag true immediately after construction with non-zero delay") + } + if f.Remaining() <= 0 { + t.Errorf("Remaining() = %v, want > 0", f.Remaining()) + } + + // Wait comfortably past the delay. + time.Sleep(100 * time.Millisecond) + + if !f.Load() { + t.Fatal("flag still false after delay elapsed") + } + if f.Remaining() != 0 { + t.Errorf("Remaining() after flip = %v, want 0", f.Remaining()) + } +} + +// TestDelayedFlag_ResetRestartsDelay ensures Reset() forces the flag back +// to false and reapplies the configured delay. +func TestDelayedFlag_ResetRestartsDelay(t *testing.T) { + f := NewDelayedFlag(20 * time.Millisecond) + + time.Sleep(60 * time.Millisecond) + if !f.Load() { + t.Fatal("precondition: flag should be true after first delay") + } + + f.Reset() + if f.Load() { + t.Fatal("flag still true immediately after Reset") + } + if f.Remaining() <= 0 { + t.Errorf("Remaining() after Reset = %v, want > 0", f.Remaining()) + } + + time.Sleep(60 * time.Millisecond) + if !f.Load() { + t.Fatal("flag still false after second delay") + } +} + +// TestDelayedFlag_ResetRaceWithExpiry stresses the race between a timer +// firing and a concurrent Reset(). After the dust settles, the flag must +// be false and a fresh deadline must be pending. This is the regression +// test for the bug present in the original implementation. +func TestDelayedFlag_ResetRaceWithExpiry(t *testing.T) { + const iterations = 200 + + for i := 0; i < iterations; i++ { + f := NewDelayedFlag(1 * time.Millisecond) + + // Race: timer is about to fire, and we Reset concurrently. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(time.Millisecond) // align with timer expiry window + f.Reset() + }() + wg.Wait() + + // After Reset, the flag must be false. We sleep a tick to let any + // stray callback (incorrectly) try to flip it. + time.Sleep(200 * time.Microsecond) + + // Note: we cannot assert it's *still* false here because the new + // 1ms timer may legitimately have already fired. The invariant we + // care about is that after the latest Reset, the flag eventually + // reflects ONE deterministic outcome (true after delay) and never + // flips back to false on its own. + // So we sample at a known-good time after a clean Reset: + f.Reset() + if f.Load() { + t.Fatalf("iter %d: flag true immediately after Reset", i) + } + } +} + +// TestDelayedFlag_ConcurrentLoad exercises Load under hammering Resets +// to make `go test -race` catch any unsynchronised access. +func TestDelayedFlag_ConcurrentLoad(t *testing.T) { + f := NewDelayedFlag(50 * time.Millisecond) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + f.Reset() + } + } + }() + + wg.Add(4) + for i := 0; i < 4; i++ { + go func() { + defer wg.Done() + for j := 0; j < 10_000; j++ { + _ = f.Load() + _ = f.Remaining() + } + }() + } + + time.Sleep(50 * time.Millisecond) + close(stop) + wg.Wait() +} diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go new file mode 100644 index 0000000..d88644e --- /dev/null +++ b/internal/httpx/middleware.go @@ -0,0 +1,125 @@ +package httpx + +import ( + "context" + "crypto/rand" + "encoding/base64" + "log/slog" + "net/http" + "time" +) + +// Middleware is a standard HTTP middleware constructor. +type Middleware func(http.Handler) http.Handler + +// Chain composes the given middlewares around next in the order they are +// passed: the first middleware in the slice is the outermost wrapper, which +// makes the call order at request time identical to the slice order. +// +// Example: Chain(h, A, B, C) β†’ A(B(C(h))). +func Chain(next http.Handler, mws ...Middleware) http.Handler { + for i := len(mws) - 1; i >= 0; i-- { + next = mws[i](next) + } + return next +} + +// ctxKeyRequestID is a private context key type used to attach the request +// ID to a request's context without colliding with other packages. +type ctxKeyRequestID struct{} + +// RequestIDFromContext returns the request ID stored in ctx, or "" if none. +func RequestIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(ctxKeyRequestID{}).(string); ok { + return v + } + return "" +} + +// newRequestID generates a URL-safe, compact, 18-byte random identifier. +func newRequestID() string { + var b [18]byte + _, _ = rand.Read(b[:]) + return base64.RawURLEncoding.EncodeToString(b[:]) +} + +// RequestID assigns a new identifier to every request, attaches it to the +// request context, and exposes it as the X-Request-Id response header. +func RequestID() Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := newRequestID() + w.Header().Set("X-Request-Id", id) + r = r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID{}, id)) + next.ServeHTTP(w, r) + }) + } +} + +// Recoverer converts panics from downstream handlers into a JSON 500 +// response and logs the panic value together with the request ID. +func Recoverer(log *slog.Logger) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Error("panic recovered", + "panic", rec, + "request_id", RequestIDFromContext(r.Context()), + ) + WriteError(w, http.StatusInternalServerError, "internal_error") + } + }() + next.ServeHTTP(w, r) + }) + } +} + +// MaxBody caps the request body size using http.MaxBytesReader. +// A non-positive max disables body limiting. +func MaxBody(max int64) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if max > 0 { + r.Body = http.MaxBytesReader(w, r.Body, max) + } + next.ServeHTTP(w, r) + }) + } +} + +// ServiceVersion sets the X-Service-Version response header on every reply, +// for both success and error responses (because the header is set before +// the inner handler writes the status code). +func ServiceVersion(version string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Service-Version", version) + next.ServeHTTP(w, r) + }) + } +} + +// AccessLog logs request/response metadata (method, path, status, bytes, +// latency, request ID, user agent, remote addr) in structured form. +func AccessLog(log *slog.Logger) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := NewStatusWriter(w) + + next.ServeHTTP(sw, r) + + log.Info("probe", + "method", r.Method, + "path", r.URL.Path, + "status", sw.Status(), + "bytes", sw.Bytes(), + "duration_ms", time.Since(start).Milliseconds(), + "request_id", RequestIDFromContext(r.Context()), + "user_agent", r.UserAgent(), + "remote", r.RemoteAddr, + ) + }) + } +} diff --git a/internal/httpx/response.go b/internal/httpx/response.go new file mode 100644 index 0000000..f27b6bc --- /dev/null +++ b/internal/httpx/response.go @@ -0,0 +1,34 @@ +// Package httpx provides reusable HTTP plumbing: JSON response helpers, +// middleware (request-id, panic recovery, body limits, access logging) +// and a status-capturing ResponseWriter. +package httpx + +import ( + "encoding/json" + "net/http" + "time" +) + +// WriteJSON serialises payload as JSON and writes it with the given status. +// The "Content-Type" header is always set. Encoding errors are intentionally +// ignored because no useful recovery is possible after WriteHeader. +func WriteJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +// WriteError writes a small, consistent JSON error response with the +// provided machine-readable code. +func WriteError(w http.ResponseWriter, status int, code string) { + WriteJSON(w, status, map[string]any{ + "error": code, + "time": NowRFC3339(), + }) +} + +// NowRFC3339 returns the current UTC time formatted as RFC3339 (no fractional +// seconds). Centralised so the format stays consistent across responses. +func NowRFC3339() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/internal/httpx/statuswriter.go b/internal/httpx/statuswriter.go new file mode 100644 index 0000000..b77d65f --- /dev/null +++ b/internal/httpx/statuswriter.go @@ -0,0 +1,45 @@ +package httpx + +import "net/http" + +// StatusWriter wraps http.ResponseWriter to capture the status code and +// the total number of bytes written, for access logging. +type StatusWriter struct { + http.ResponseWriter + status int + bytes int64 + wroteHeader bool +} + +// NewStatusWriter wraps w with a StatusWriter pre-initialised to 200. +func NewStatusWriter(w http.ResponseWriter) *StatusWriter { + return &StatusWriter{ResponseWriter: w, status: http.StatusOK} +} + +// Status returns the captured status code (200 if none was explicitly set). +func (w *StatusWriter) Status() int { return w.status } + +// Bytes returns the number of bytes successfully written to the body. +func (w *StatusWriter) Bytes() int64 { return w.bytes } + +// WriteHeader captures the status code and forwards it. +func (w *StatusWriter) WriteHeader(statusCode int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + w.status = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +// Write captures the byte count and forwards the write. If WriteHeader was +// not called yet, http.ResponseWriter semantics require an implicit 200, +// which we record locally as well. +func (w *StatusWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.wroteHeader = true + } + n, err := w.ResponseWriter.Write(b) + w.bytes += int64(n) + return n, err +} diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..575a2a4 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,25 @@ +// Package logging builds the application's structured logger. +package logging + +import ( + "log/slog" + "os" + "time" +) + +// New returns a JSON slog logger writing to stdout, configured to emit +// the timestamp in RFC3339 (no fractional seconds) for consistency with +// the JSON responses returned by the HTTP service. +func New(level slog.Level) *slog.Logger { + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + t := a.Value.Time().UTC() + a.Value = slog.StringValue(t.Format(time.RFC3339)) + } + return a + }, + }) + return slog.New(h) +} diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go new file mode 100644 index 0000000..14d13ab --- /dev/null +++ b/internal/server/handler_test.go @@ -0,0 +1,226 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "bodsch.me/probe-service/internal/config" +) + +// newTestServer builds a Server with a discarding logger and a zero +// startup delay so probes return 200 immediately. +func newTestServer(t *testing.T) *Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := config.Config{ + Port: 0, // unused; tests do not bind a port + StartupDelay: 0, + ServiceName: "probe-service-test", + Version: "0.0.0-test", + ShutdownWait: time.Second, + ReadTimeout: time.Second, + WriteTimeout: time.Second, + IdleTimeout: time.Second, + MaxBodyBytes: 1 << 16, + LogLevel: slog.LevelInfo, + } + srv, err := New(cfg, log) + if err != nil { + t.Fatalf("server.New: %v", err) + } + return srv +} + +// do executes a request against the Server's root handler. +func do(t *testing.T, s *Server, method, path string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} + +// decodeBody parses the response body as a JSON map. +func decodeBody(t *testing.T, r *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + t.Fatalf("decode body: %v", err) + } + return m +} + +// TestLivenessRoutes_OK ensures that with a zero startup delay both +// liveness routes return 200 with the expected JSON envelope. +func TestLivenessRoutes_OK(t *testing.T) { + srv := newTestServer(t) + + for _, p := range []string{"/healthz", "/actuator/health/liveness"} { + t.Run(p, func(t *testing.T) { + res := do(t, srv, http.MethodGet, p) + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", res.Code) + } + body := decodeBody(t, res) + if body["status"] != "ok" { + t.Errorf("status = %v, want ok", body["status"]) + } + if body["service"] != "probe-service-test" { + t.Errorf("service = %v, want probe-service-test", body["service"]) + } + if body["version"] != "0.0.0-test" { + t.Errorf("version = %v, want 0.0.0-test", body["version"]) + } + }) + } +} + +// TestReadinessRoutes_OK is the readiness counterpart of TestLivenessRoutes_OK. +func TestReadinessRoutes_OK(t *testing.T) { + srv := newTestServer(t) + + for _, p := range []string{"/readyz", "/actuator/health/readiness"} { + t.Run(p, func(t *testing.T) { + res := do(t, srv, http.MethodGet, p) + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", res.Code) + } + body := decodeBody(t, res) + if body["status"] != "ready" { + t.Errorf("status = %v, want ready", body["status"]) + } + }) + } +} + +// TestProbe_NotReady_WhenDelayActive verifies that with a long startup +// delay, the liveness probe returns 503 and includes retry_after_ms > 0. +func TestProbe_NotReady_WhenDelayActive(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := config.Config{ + Port: 0, + StartupDelay: 5 * time.Second, + ServiceName: "probe-service-test", + Version: "0.0.0-test", + ShutdownWait: time.Second, + MaxBodyBytes: 1 << 16, + } + srv, err := New(cfg, log) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + res := do(t, srv, http.MethodGet, "/healthz") + if res.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", res.Code) + } + body := decodeBody(t, res) + if body["status"] != "unhealthy" { + t.Errorf("status = %v, want unhealthy", body["status"]) + } + if n, _ := body["retry_after_ms"].(float64); n <= 0 { + t.Errorf("retry_after_ms = %v, want > 0", body["retry_after_ms"]) + } +} + +// TestMethodNotAllowed ensures non-GET on probes and non-POST on admin +// endpoints return 405 with the documented error code. +func TestMethodNotAllowed(t *testing.T) { + srv := newTestServer(t) + + cases := []struct { + method string + path string + }{ + {http.MethodPost, "/healthz"}, + {http.MethodPut, "/readyz"}, + {http.MethodGet, "/admin/reset"}, + {http.MethodGet, "/admin/health/reset"}, + {http.MethodGet, "/admin/ready/reset"}, + } + for _, c := range cases { + t.Run(c.method+" "+c.path, func(t *testing.T) { + res := do(t, srv, c.method, c.path) + if res.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", res.Code) + } + body := decodeBody(t, res) + if body["error"] != "method_not_allowed" { + t.Errorf("error = %v, want method_not_allowed", body["error"]) + } + }) + } +} + +// TestServiceVersionHeader confirms the header middleware fires on every +// response, including the 503 case. +func TestServiceVersionHeader(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := config.Config{ + Port: 0, + StartupDelay: 5 * time.Second, // produce 503 + ServiceName: "probe-service-test", + Version: "v9.9.9", + ShutdownWait: time.Second, + MaxBodyBytes: 1 << 16, + } + srv, err := New(cfg, log) + if err != nil { + t.Fatalf("server.New: %v", err) + } + + res := do(t, srv, http.MethodGet, "/healthz") + if got := res.Header().Get("X-Service-Version"); got != "v9.9.9" { + t.Errorf("X-Service-Version = %q, want v9.9.9", got) + } + if !strings.Contains(res.Header().Get("Content-Type"), "application/json") { + t.Errorf("Content-Type = %q, want application/json…", res.Header().Get("Content-Type")) + } +} + +// TestAdminReset_BothFlags exercises POST /admin/reset and verifies the +// response shape: both flags reported with their *_in_ms remaining times. +func TestAdminReset_BothFlags(t *testing.T) { + srv := newTestServer(t) + + res := do(t, srv, http.MethodPost, "/admin/reset") + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", res.Code) + } + body := decodeBody(t, res) + for _, key := range []string{"health", "ready", "delay", "time", "health_in_ms", "ready_in_ms"} { + if _, ok := body[key]; !ok { + t.Errorf("response missing %q: %v", key, body) + } + } + if body["health"] != false { + t.Errorf("health = %v, want false", body["health"]) + } + if body["ready"] != false { + t.Errorf("ready = %v, want false", body["ready"]) + } +} + +// TestAdminReset_OnlyHealth ensures the targeted reset endpoints only +// touch their own flag in the response payload. +func TestAdminReset_OnlyHealth(t *testing.T) { + srv := newTestServer(t) + + res := do(t, srv, http.MethodPost, "/admin/health/reset") + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", res.Code) + } + body := decodeBody(t, res) + if _, ok := body["health"]; !ok { + t.Error("expected health field in response") + } + if _, ok := body["ready"]; ok { + t.Error("did not expect ready field in /admin/health/reset response") + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..f99a012 --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,105 @@ +// Package server assembles the HTTP server, its handlers, routing and +// graceful-shutdown logic. +package server + +import ( + "net/http" + + "bodsch.me/probe-service/internal/flagx" + "bodsch.me/probe-service/internal/httpx" +) + +// probeLabels carries the two textual labels used in a probe handler's +// JSON output, one for the success case and one for the failure case. +type probeLabels struct { + // up is the value of "status" when the underlying flag is true (200). + up string + // down is the value of "status" when the flag is false (503). + down string +} + +// livenessLabels are used by /healthz and /actuator/health/liveness. +var livenessLabels = probeLabels{up: "ok", down: "unhealthy"} + +// readinessLabels are used by /readyz and /actuator/health/readiness. +var readinessLabels = probeLabels{up: "ready", down: "not-ready"} + +// probeHandler builds a GET-only handler that reports the state of the +// supplied DelayedFlag. When the flag is true the handler returns 200 +// and labels.up; when it is false it returns 503, labels.down, and the +// remaining time until the flag would flip. +// +// All probe responses share the same JSON envelope so that monitoring +// systems can parse them uniformly: +// +// { +// "status": "", +// "service": "", +// "version": "", +// "retry_after_ms": , +// "time": "" +// } +func probeHandler(flag *flagx.DelayedFlag, labels probeLabels, service, version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httpx.WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if !flag.Load() { + httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{ + "status": labels.down, + "service": service, + "version": version, + "retry_after_ms": flag.Remaining().Milliseconds(), + "time": httpx.NowRFC3339(), + }) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "status": labels.up, + "service": service, + "version": version, + "time": httpx.NowRFC3339(), + }) + } +} + +// resetTarget pairs a DelayedFlag with the JSON key under which its +// remaining delay should be reported by the reset handler. +type resetTarget struct { + // stateKey is the JSON field name for the boolean state, e.g. "health". + stateKey string + // remainingKey is the JSON field name for the millisecond countdown, + // e.g. "health_in_ms". + remainingKey string + // flag is the DelayedFlag to be reset by this handler. + flag *flagx.DelayedFlag +} + +// resetHandler builds a POST-only handler that calls Reset() on every +// target and returns a JSON description of the new state. +// +// The response always contains a "delay" field (the configured delay as +// a Go duration string), a "time" field, and for each target a state +// field set to false and a *_in_ms field with the remaining time. +func resetHandler(delayStr string, targets ...resetTarget) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httpx.WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + for _, t := range targets { + t.flag.Reset() + } + + body := map[string]any{ + "delay": delayStr, + "time": httpx.NowRFC3339(), + } + for _, t := range targets { + body[t.stateKey] = false + body[t.remainingKey] = t.flag.Remaining().Milliseconds() + } + httpx.WriteJSON(w, http.StatusOK, body) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..3dd3c5a --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,34 @@ +package server + +import ( + "net/http" + + "bodsch.me/probe-service/internal/config" + "bodsch.me/probe-service/internal/flagx" +) + +// registerRoutes attaches all HTTP routes to mux. Liveness and readiness +// each have two URL aliases (the Kubernetes-style /healthz | /readyz and +// the Spring Actuator-style paths) but share a single handler closure. +func registerRoutes(mux *http.ServeMux, cfg config.Config, health, ready *flagx.DelayedFlag) { + liveness := probeHandler(health, livenessLabels, cfg.ServiceName, cfg.Version) + readiness := probeHandler(ready, readinessLabels, cfg.ServiceName, cfg.Version) + + mux.HandleFunc("/healthz", liveness) + mux.HandleFunc("/actuator/health/liveness", liveness) + mux.HandleFunc("/readyz", readiness) + mux.HandleFunc("/actuator/health/readiness", readiness) + + delayStr := cfg.StartupDelay.String() + + mux.HandleFunc("/admin/reset", resetHandler(delayStr, + resetTarget{stateKey: "health", remainingKey: "health_in_ms", flag: health}, + resetTarget{stateKey: "ready", remainingKey: "ready_in_ms", flag: ready}, + )) + mux.HandleFunc("/admin/health/reset", resetHandler(delayStr, + resetTarget{stateKey: "health", remainingKey: "health_in_ms", flag: health}, + )) + mux.HandleFunc("/admin/ready/reset", resetHandler(delayStr, + resetTarget{stateKey: "ready", remainingKey: "ready_in_ms", flag: ready}, + )) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..4382ec6 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,130 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "bodsch.me/probe-service/internal/config" + "bodsch.me/probe-service/internal/flagx" + "bodsch.me/probe-service/internal/httpx" +) + +// Server is the runnable application. Public callers should treat it as +// opaque except for Run. +type Server struct { + cfg config.Config + log *slog.Logger + http *http.Server + health *flagx.DelayedFlag + ready *flagx.DelayedFlag +} + +// New builds a Server with all routes and middleware in place. It does +// not bind the listening socket; that happens in Run so that test code +// can construct a Server in process without holding a port. +func New(cfg config.Config, log *slog.Logger) (*Server, error) { + if log == nil { + return nil, errors.New("server.New: nil logger") + } + + health := flagx.NewDelayedFlag(cfg.StartupDelay) + ready := flagx.NewDelayedFlag(cfg.StartupDelay) + + mux := http.NewServeMux() + registerRoutes(mux, cfg, health, ready) + + // Middleware order matters: + // RequestID is outermost so the ID is in r.Context() for every layer + // below it (otherwise the WithContext rebind inside RequestID is + // invisible to outer middlewares' deferred log statements). + // AccessLog then Recoverer follow, so panic responses are still logged + // with status 500 and the request ID. ServiceVersion sets a response + // header and therefore must run before any WriteHeader. MaxBody only + // affects the inner handler. + handler := httpx.Chain(mux, + httpx.RequestID(), + httpx.AccessLog(log), + httpx.Recoverer(log), + httpx.ServiceVersion(cfg.Version), + httpx.MaxBody(cfg.MaxBodyBytes), + ) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError), + } + + return &Server{ + cfg: cfg, + log: log, + http: srv, + health: health, + ready: ready, + }, nil +} + +// Handler returns the fully composed root http.Handler, primarily for +// tests that want to drive the server via httptest without binding a port. +func (s *Server) Handler() http.Handler { return s.http.Handler } + +// Run binds the listener and serves until ctx is cancelled, then performs +// a graceful shutdown bounded by cfg.ShutdownWait. +// +// Run returns nil on a clean shutdown caused by ctx cancellation, and a +// non-nil error if either the listener could not be bound, the server +// terminated with an error other than http.ErrServerClosed, or the +// shutdown itself failed. +func (s *Server) Run(ctx context.Context) error { + ln, err := net.Listen("tcp", s.http.Addr) + if err != nil { + return fmt.Errorf("listen %s: %w", s.http.Addr, err) + } + + s.log.Info("starting", + "service", s.cfg.ServiceName, + "version", s.cfg.Version, + "addr", s.http.Addr, + "startup_delay", s.cfg.StartupDelay.String(), + ) + + errCh := make(chan error, 1) + go func() { + err := s.http.Serve(ln) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + select { + case <-ctx.Done(): + s.log.Info("shutdown requested") + case err := <-errCh: + if err != nil { + s.log.Error("server error", "err", err) + return err + } + return nil + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.ShutdownWait) + defer cancel() + + if err := s.http.Shutdown(shutdownCtx); err != nil { + s.log.Error("shutdown failed", "err", err) + return fmt.Errorf("shutdown: %w", err) + } + s.log.Info("shutdown complete") + return nil +} From 66e0d55d9912648b4ae50fb17dbb5bc523842248 Mon Sep 17 00:00:00 2001 From: Bodo Schulz Date: Fri, 15 May 2026 09:34:12 +0200 Subject: [PATCH 3/3] Due to security warnings, the minimum Go version has been raised to 1.25.10. Update to GitHub Workflow Actions. --- .github/workflows/ci.yml | 22 +++++++++++----------- .github/workflows/release.yml | 12 ++++++------ go.mod | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea1726b..1cb7021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod cache: true @@ -75,10 +75,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod cache: true @@ -88,7 +88,7 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-report path: coverage.out @@ -99,10 +99,10 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod cache: true @@ -128,24 +128,24 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod - name: Validate .goreleaser.yaml - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7.2.1 with: distribution: goreleaser version: "~> v2" args: check - name: Validate .goreleaser.forgejo.yaml - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7.2.1 with: distribution: goreleaser version: "~> v2" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4d6401..60a9cdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,14 +23,14 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: # GoReleaser needs the full history to generate a changelog and # to derive {{ .Version }} from the tag annotation. fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version-file: go.mod cache: true @@ -38,15 +38,15 @@ jobs: # QEMU lets us build the linux/arm64 image on an amd64 runner. # buildx provides the multi-arch builder GoReleaser drives. - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4.0.0 with: platforms: arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.0.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -60,7 +60,7 @@ jobs: echo "lower=${lower}" >> "$GITHUB_OUTPUT" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7.2.1 with: distribution: goreleaser version: "~> v2" diff --git a/go.mod b/go.mod index 115fd74..fa494ef 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module bodsch.me/probe-service -go 1.24 +go 1.25.10