diff --git a/AGENTS.md b/AGENTS.md index 38ec640..f464774 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,9 +42,12 @@ clicking an item copies it back to the clipboard. code and server URL in `UserDefaults` (`sync.code`, `sync.serverURL`, `sync.version`), pushes the whole store as JSON, and polls every 5s. - `Server/main.go` — the Go sync backend (single file, `net/http` + `lib/pq`). - Also ships `Server/README.md` (self-hosting) and `Server/prompt-sync.service` - (systemd unit). Logs one line per request, creates its schema on startup, and - shuts down gracefully on SIGTERM so a redeploy cannot cut off a push. + Also ships `Server/Dockerfile` + `Server/docker-compose.yml` (Postgres + + server), `Server/README.md` (self-hosting), and `Server/prompt-sync.service` + (systemd unit). Logs one line per request, creates its schema on startup, + shuts down gracefully on SIGTERM so a redeploy cannot cut off a push, and + supports `-healthcheck` (pings local `/healthz`) so the distroless image + needs no shell for its Docker `HEALTHCHECK`. ## Sync protocol diff --git a/README.md b/README.md index 7d5741b..e589b17 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ PROMPT_SYNC_DATABASE_URL="postgres://localhost:5432/prompt_sync?sslmode=disable" go run . # listens on :8080 ``` +Or run it with Docker (`POSTGRES_PASSWORD=… docker compose up -d`) — see +[Server/README.md](Server/README.md) for that and for deploying to a VPS. + Then in the panel: click the sync icon, set the server URL (e.g. `http://192.168.1.10:8080` or an HTTPS host), and either **Generate Code** on the first Mac or paste the code into **Join** on the other. diff --git a/Server/.dockerignore b/Server/.dockerignore new file mode 100644 index 0000000..b341a61 --- /dev/null +++ b/Server/.dockerignore @@ -0,0 +1,4 @@ +.git +prompt-sync +prompt-sync-* +*.md diff --git a/Server/Dockerfile b/Server/Dockerfile new file mode 100644 index 0000000..b34f83a --- /dev/null +++ b/Server/Dockerfile @@ -0,0 +1,27 @@ +# Build stage: the server is pure Go (lib/pq is pure Go), so CGO is off and the +# result is a static binary that runs on a shell-less base image. +FROM golang:1.26-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ + +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o /prompt-sync . + +# Runtime stage: distroless has no shell, so the binary does its own +# healthcheck (see -healthcheck in main.go). Runs as non-root. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /prompt-sync /prompt-sync + +ENV PROMPT_SYNC_ADDR=:8080 +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/prompt-sync", "-healthcheck"] + +ENTRYPOINT ["/prompt-sync"] diff --git a/Server/README.md b/Server/README.md index 8553b9e..3512bc6 100644 --- a/Server/README.md +++ b/Server/README.md @@ -28,6 +28,30 @@ The schema is created on startup. Every request is logged as `METHOD PATH STATUS DURATION`; SIGTERM/SIGINT shut down gracefully so an in-flight push is not cut off mid-write. +## Run with Docker + +`docker-compose.yml` starts Postgres and the server together: + +```bash +cd Server +POSTGRES_PASSWORD=$(openssl rand -hex 16) docker compose up -d +docker compose logs -f server # request log +``` + +Or build the image on its own and point it at an existing Postgres: + +```bash +docker build -t prompt-sync Server/ +docker run -d --name prompt-sync -p 8080:8080 \ + -e PROMPT_SYNC_DATABASE_URL="postgres://user:pass@host:5432/prompt_sync?sslmode=disable" \ + prompt-sync +``` + +The image is a static binary on `distroless` (non-root, no shell, ~6 MB binary), +so its `HEALTHCHECK` calls `prompt-sync -healthcheck`, which pings the local +`/healthz` and exits non-zero on failure — no curl needed in the image. That is +what `docker ps` reports as the container health. + ## Deploy to a Linux host ```bash diff --git a/Server/docker-compose.yml b/Server/docker-compose.yml new file mode 100644 index 0000000..4bd3ed9 --- /dev/null +++ b/Server/docker-compose.yml @@ -0,0 +1,37 @@ +# Postgres + the sync server. `docker compose up -d` is the whole deploy: +# +# POSTGRES_PASSWORD=$(openssl rand -hex 16) docker compose up -d +# +# Then point the app at http://:8080. The data lives in the named volume, +# so `docker compose down` keeps it and `down -v` wipes it. + +services: + db: + image: postgres:18-alpine + environment: + POSTGRES_DB: prompt_sync + POSTGRES_USER: prompt + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + volumes: + - prompt-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prompt -d prompt_sync"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + server: + build: . + environment: + PROMPT_SYNC_ADDR: ":8080" + PROMPT_SYNC_DATABASE_URL: postgres://prompt:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/prompt_sync?sslmode=disable + ports: + - "8080:8080" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + +volumes: + prompt-db: diff --git a/Server/main.go b/Server/main.go index 98c9ca2..4eba7ef 100644 --- a/Server/main.go +++ b/Server/main.go @@ -27,6 +27,7 @@ import ( "os" "os/signal" "regexp" + "strings" "syscall" "time" @@ -48,6 +49,13 @@ type Server struct { } func main() { + // -healthcheck pings the local /healthz and exits non-zero on failure. It + // exists so the container image can run on a shell-less base (distroless), + // where there is no wget or curl for a HEALTHCHECK to use. + if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { + os.Exit(healthcheck()) + } + dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL") if dsn == "" { dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable" @@ -103,6 +111,29 @@ func main() { } } +// healthcheck returns 0 when the local /healthz answers 200. +func healthcheck() int { + addr := os.Getenv("PROMPT_SYNC_ADDR") + if addr == "" { + addr = ":8080" + } + if strings.HasPrefix(addr, ":") { + addr = "127.0.0.1" + addr + } + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Get("http://" + addr + "/healthz") + if err != nil { + log.Printf("healthcheck: %v", err) + return 1 + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + log.Printf("healthcheck: unexpected status %d", response.StatusCode) + return 1 + } + return 0 +} + // withLogging keeps a one-line trail of method, path, status and duration so a // self-hosted deployment can be debugged from journalctl or docker logs. func withLogging(next http.Handler) http.Handler {