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
bc881dcebb
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]
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -16,18 +50,3 @@ jobs:
|
||||
with:
|
||||
globs: '**/*.md'
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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