Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ENV_APP_LOG_LEVEL=debug
ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log"
ENV_APP_LOGS_DATE_FORMAT="2006-01-02"
ENV_APP_URL=
API_LOGS_PATH="./storage/logs/api"

# --- The App master key for encryption.
ENV_APP_MASTER_KEY=
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ help:
@printf " $(BOLD)$(GREEN)watch-local$(NC) : Start the Docker local stack in the foreground.\n"
@printf " $(BOLD)$(GREEN)build-ci$(NC) : Build the main application for the CI.\n"
@printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n"
@printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n\n"
@printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n"
@printf " $(BOLD)$(GREEN)prewarm-cli-docker$(NC): Warm Docker CLI caches and build the reusable CLI binary.\n\n"

@printf "$(BOLD)$(BLUE)Database Commands:$(NC)\n"
@printf " $(BOLD)$(GREEN)db:local$(NC) : Set up or manage the local database environment.\n"
Expand Down
24 changes: 20 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ volumes:
caddy_config:
go_mod_cache:
driver: local
go_build_cache:
driver: local
prometheus_data_prod:
driver: local
prometheus_data_local:
Expand Down Expand Up @@ -48,7 +50,8 @@ services:
container_name: oullin_proxy_prod
restart: unless-stopped
depends_on:
- api
api:
condition: service_healthy

# --- The 443:443/udp is required for HTTP/3
# NOTES:
Expand Down Expand Up @@ -86,7 +89,8 @@ services:
container_name: oullin_local_proxy
restart: unless-stopped
depends_on:
- api
api:
condition: service_healthy
ports:
- "18080:80"
- "127.0.0.1:2019:2019" # Admin API - localhost only for debugging
Expand Down Expand Up @@ -340,15 +344,19 @@ services:
dockerfile: ./infra/docker/dockerfile-api
target: builder
args:
- BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r2}
- BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r3}
volumes:
- .:/app
- go_mod_cache:/go/pkg/mod
- go_build_cache:/tmp/go-build
- "${ENV_SPA_DIR}:${ENV_SPA_DIR}"
- "${ENV_SPA_IMAGES_DIR}:${ENV_SPA_IMAGES_DIR}"
working_dir: /app
environment:
CGO_ENABLED: 1
GOPATH: /go
GOMODCACHE: /go/pkg/mod
GOCACHE: /tmp/go-build
GOTOOLCHAIN: ${GO_LOCAL_TOOLCHAIN:-go1.26.1}
ENV_DB_HOST: api-db
ENV_SPA_DIR: ${ENV_SPA_DIR}
Expand All @@ -375,18 +383,20 @@ services:
volumes:
- ./.env:/app/.env:ro
- ./storage/fixture:/app/storage/fixture:ro
- ${API_LOGS_PATH:-./storage/logs/api}:/app/storage/logs
environment:
CGO_ENABLED: 1
GOTOOLCHAIN: ${GO_LOCAL_TOOLCHAIN:-go1.26.1}
# --- This ensures the Go web server listens for connections from other
# containers (like Caddy), not just from within itself.
ENV_APP_LOGS_DIR: /app/storage/logs/logs_%s.log
ENV_DB_HOST: api-db
ENV_HTTP_HOST: 0.0.0.0
build:
context: .
dockerfile: ./infra/docker/dockerfile-api
args:
- BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r2}
- BASE_IMAGE_VERSION=${BASE_IMAGE_VERSION:-1.26.1-alpine3.23-r3}
- APP_VERSION=0.0.0.1
- APP_HOST_PORT=${ENV_HTTP_PORT}
- APP_USER=${ENV_DOCKER_USER}
Expand All @@ -405,6 +415,12 @@ services:
# local: use Caddy on port 18080 (handles /api prefix like production)
# prod : Caddy handles all traffic (no direct API access)
- ${ENV_HTTP_PORT}
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:$${ENV_HTTP_PORT:-8080}/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
oullin_net: {}
caddy_net:
Expand Down
7 changes: 6 additions & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Review the `.env` file and adjust the settings as needed.

- `ENV_APP_NAME`: Name of the application.
- `ENV_APP_ENV_TYPE`: Environment type (e.g., `local`, `production`).
- `ENV_APP_LOGS_DIR`: Application log filename pattern inside the API runtime.
- `API_LOGS_PATH`: Host path mounted to `/app/storage/logs` for persistent API logs.
- `ENV_DB_*`: Database connection details.
- `ENV_HTTP_PORT`: Port for the HTTP server (default: `8080`).

Expand Down Expand Up @@ -55,7 +57,8 @@ The application uses a PostgreSQL database. You can manage it using the followin

To run the application locally:

- **CLI Mode**: `make run-cli`
- **Optional first-run prewarm**: `make prewarm-cli-docker` warms the Docker CLI module cache, build cache, and reusable CLI binary. It does **not** start the database.
- **CLI Mode**: `make run-cli` reuses `oullin_db` when it is already healthy, starts `api-db` only when needed, and reuses a Docker-built CLI binary on warm runs.
- **Metal (Dev) Mode**: `make run-metal`

### Monitoring
Expand All @@ -67,6 +70,8 @@ The project includes a monitoring stack with Prometheus and Grafana.
- Check Status: `make monitor-status`
- Open Grafana: `make monitor-grafana`

For production outage investigation, use [Uptime Incident Log Runbook](UPTIME_LOGS.md).

## Testing

Run the test suite:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/andybalholm/brotli v1.2.0
github.com/chai2010/webp v1.4.0
github.com/felixge/httpsnoop v1.0.4
github.com/gen2brain/avif v0.4.4
github.com/getsentry/sentry-go v0.43.0
github.com/go-playground/validator/v10 v10.30.1
Expand Down Expand Up @@ -43,7 +44,6 @@ require (
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
Expand Down
31 changes: 31 additions & 0 deletions handler/health.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package handler

import (
"net/http"
"time"

"github.com/oullin/handler/payload"
"github.com/oullin/pkg/endpoint"
"github.com/oullin/pkg/portal"
)

type HealthHandler struct{}

func NewHealthHandler() HealthHandler {
return HealthHandler{}
}

func (h HealthHandler) Handle(w http.ResponseWriter, r *http.Request) *endpoint.ApiError {
resp := endpoint.NewNoCacheResponse(w, r)

data := payload.KeepAliveResponse{
Message: "ok",
DateTime: time.Now().UTC().Format(portal.DatesLayout),
}

if err := resp.RespondOk(data); err != nil {
return endpoint.LogInternalError("could not encode health response", err)
}

return nil
}
39 changes: 39 additions & 0 deletions handler/health_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package handler

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/oullin/handler/payload"
"github.com/oullin/pkg/portal"
)

func TestHealthHandler(t *testing.T) {
h := NewHealthHandler()
req := httptest.NewRequest("GET", "/health", nil)
rec := httptest.NewRecorder()

if err := h.Handle(rec, req); err != nil {
t.Fatalf("handle err: %v", err)
}

if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}

var resp payload.KeepAliveResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}

if resp.Message != "ok" {
t.Fatalf("unexpected message: %s", resp.Message)
}

if _, err := time.Parse(portal.DatesLayout, resp.DateTime); err != nil {
t.Fatalf("invalid datetime: %v", err)
}
}
26 changes: 25 additions & 1 deletion handler/keep_alive_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"fmt"
"log/slog"
"net/http"
"time"

Expand Down Expand Up @@ -31,9 +32,32 @@ func (h KeepAliveDBHandler) Handle(w http.ResponseWriter, r *http.Request) *endp
)
}

started := time.Now()
if err := h.db.Ping(); err != nil {
return endpoint.LogInternalError("database ping failed", err)
slog.Error(
"database ping failed",
"duration_ms", time.Since(started).Milliseconds(),
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
"request_id", r.Header.Get(portal.RequestIDHeader),
"error", err,
)

return &endpoint.ApiError{
Message: "Internal server error: database ping failed",
Status: http.StatusInternalServerError,
Err: err,
}
}
slog.Info(
"database ping completed",
"duration_ms", time.Since(started).Milliseconds(),
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
"request_id", r.Header.Get(portal.RequestIDHeader),
)

resp := endpoint.NewNoCacheResponse(w, r)
now := time.Now().UTC()
Expand Down
18 changes: 18 additions & 0 deletions handler/keep_alive_db_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package handler

import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -50,12 +53,27 @@ func TestKeepAliveDBHandler(t *testing.T) {
})

t.Run("db ping failure", func(t *testing.T) {
var logs bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() {
slog.SetDefault(previous)
})

db.Close()
req := httptest.NewRequest("GET", "/ping-db", nil)
req.SetBasicAuth("user", "pass")
rec := httptest.NewRecorder()
if err := h.Handle(rec, req); err == nil || err.Status != http.StatusInternalServerError {
t.Fatalf("expected internal error, got %#v", err)
}

got := logs.String()
if count := strings.Count(got, "level=ERROR"); count != 1 {
t.Fatalf("expected one error log, got %d: %s", count, got)
}
if !strings.Contains(got, `msg="database ping failed"`) {
t.Fatalf("expected structured db ping failure log, got %s", got)
}
})
}
Loading
Loading