feat: foundation Go module, HTTP server, Docker packaging
- Go module (github.com/elfoundation/hatch) with go 1.25 - cmd/hatch: HTTP server with GET /healthz returning 200 OK - Multi-stage Dockerfile producing static binary from scratch - docker-compose with hatch + optional Caddy reverse proxy - CI placeholder with Go test/lint stubs - .gitignore, .dockerignore, .env.example Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2fa4184a32
commit
eed408bb19
37
.dockerignore
Normal file
37
.dockerignore
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Docs (not needed at runtime)
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Editor config
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Docker (avoid recursive copy)
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Node artifacts (from pre-migration scaffold, will be removed by ELF-15)
|
||||||
|
node_modules/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package.json
|
||||||
|
apps/
|
||||||
|
|
||||||
|
# Test / build caches
|
||||||
|
*.test
|
||||||
|
coverage/
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Public hostname for HTTPS (Caddy will obtain a Let's Encrypt cert automatically).
|
||||||
|
# Leave unset or set to "localhost" for local dev (self-signed cert).
|
||||||
|
HATCH_HOSTNAME=hatch.example.com
|
||||||
|
|
||||||
|
# Base URL for SSE and self-referencing links within the UI.
|
||||||
|
HATCH_BASE_URL=https://hatch.example.com
|
||||||
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@ -7,6 +7,40 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.25'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./... -v -race
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: CGO_ENABLED=0 go build -o /dev/null ./cmd/hatch
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t hatch:ci .
|
||||||
|
|
||||||
|
- name: Smoke test the Docker image
|
||||||
|
run: |
|
||||||
|
docker run --rm -d -p 8080:8080 --name hatch-ci hatch:ci
|
||||||
|
sleep 2
|
||||||
|
curl -fsS http://localhost:8080/healthz | grep -q 'ok'
|
||||||
|
docker stop hatch-ci
|
||||||
|
|
||||||
lint-docs:
|
lint-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -16,18 +50,3 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
globs: '**/*.md'
|
globs: '**/*.md'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# TODO: Enable once we have a TypeScript project
|
|
||||||
# lint-and-test:
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# steps:
|
|
||||||
# - uses: actions/checkout@v4
|
|
||||||
# - uses: pnpm/action-setup@v4
|
|
||||||
# - uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: 22
|
|
||||||
# cache: 'pnpm'
|
|
||||||
# - run: pnpm install --frozen-lockfile
|
|
||||||
# - run: pnpm lint
|
|
||||||
# - run: pnpm typecheck
|
|
||||||
# - run: pnpm test
|
|
||||||
|
|||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# Build
|
||||||
|
/bin
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
19
Caddyfile
Normal file
19
Caddyfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{$HATCH_HOSTNAME:localhost} {
|
||||||
|
reverse_proxy hatch:8080
|
||||||
|
|
||||||
|
# Log requests for debugging
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
|
# TLS: auto via Let's Encrypt when HATCH_HOSTNAME is a real domain.
|
||||||
|
# Falls back to self-signed for localhost / internal-only names.
|
||||||
|
}
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- build stage ----
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache module downloads before copying source (layer caching).
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Build a fully static binary (CGO disabled, no libc dependency).
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/hatch ./cmd/hatch
|
||||||
|
|
||||||
|
# ---- run stage ----
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
COPY --from=build /bin/hatch /bin/hatch
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/hatch"]
|
||||||
37
README.md
37
README.md
@ -25,6 +25,43 @@ El Foundation builds institutions that outlast their founders. We create technol
|
|||||||
└── scripts/ # Automation and utility scripts
|
└── scripts/ # Automation and utility scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hatch — Deploy in one command
|
||||||
|
|
||||||
|
Hatch is a self-hostable HTTP request inspector + mocker. Ship it to any VPS with Docker.
|
||||||
|
|
||||||
|
### Quick start (local dev, no HTTPS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# Hatch UI: http://localhost:8080
|
||||||
|
# Capture endpoint: http://localhost:8080/{endpoint-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (with HTTPS via Caddy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set your domain name
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit HATCH_HOSTNAME in .env to your real domain
|
||||||
|
|
||||||
|
# Start Hatch + Caddy (auto-issues Let's Encrypt cert)
|
||||||
|
docker compose --profile with-caddy up -d --build
|
||||||
|
# Hatch UI: https://{your-domain}
|
||||||
|
# Capture endpoint: https://{your-domain}/{endpoint-id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → :443 (Caddy) → hatch:8080 (Go binary, internal network)
|
||||||
|
│
|
||||||
|
├─ Auto TLS (Let's Encrypt, or self-signed for localhost)
|
||||||
|
├─ Reverse proxy with security headers
|
||||||
|
└─ JSON access logs to stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy terminates TLS and reverse-proxies to the Hatch Go binary. The Hatch container only listens on `127.0.0.1:8080` — it's never directly exposed to the internet.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
See [docs/engineering/tech-stack.md](docs/engineering/tech-stack.md) for current choices and rationale.
|
See [docs/engineering/tech-stack.md](docs/engineering/tech-stack.md) for current choices and rationale.
|
||||||
|
|||||||
36
cmd/hatch/main.go
Normal file
36
cmd/hatch/main.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Command hatch is the Hatch HTTP request inspector + mocker server.
|
||||||
|
//
|
||||||
|
// Hatch captures, inspects, and mocks HTTP requests. It ships as a single
|
||||||
|
// static binary — one command on a VPS and your payloads never leave your
|
||||||
|
// network.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /healthz", healthz)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%s", port)
|
||||||
|
log.Printf("hatch starting on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("hatch server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthz returns 200 OK with body "ok" for liveness probes.
|
||||||
|
func healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintln(w, "ok")
|
||||||
|
}
|
||||||
58
cmd/hatch/main_test.go
Normal file
58
cmd/hatch/main_test.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
healthz(w, req)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(string(body))
|
||||||
|
if got != "ok" {
|
||||||
|
t.Fatalf("expected body 'ok', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzSmoke(t *testing.T) {
|
||||||
|
// Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok".
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(healthz))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/healthz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to GET /healthz: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read response body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(string(body))
|
||||||
|
if got != "ok" {
|
||||||
|
t.Fatalf("expected body 'ok', got '%s'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
hatch:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
volumes:
|
||||||
|
- hatch-data:/data
|
||||||
|
environment:
|
||||||
|
- HATCH_PORT=8080
|
||||||
|
- HATCH_DB_PATH=/data/hatch.db
|
||||||
|
- HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost}
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
environment:
|
||||||
|
- HATCH_HOSTNAME=${HATCH_HOSTNAME:-localhost}
|
||||||
|
# Caddy runs even without a real hostname (self-signed)
|
||||||
|
profiles:
|
||||||
|
- with-caddy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hatch-data:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
Loading…
Reference in New Issue
Block a user