diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..dca4df5 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [automc] + tags: ["automc-v*"] + pull_request: + branches: [automc] + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + run: go build ./... + - name: Vet + run: go vet ./... + - name: Test automc package + run: go test ./internal/automc/... + - name: Test full suite + run: go test ./... + + docker: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: validate + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + + - name: Compute tags + id: meta + run: | + if [[ "$GITEA_REF" == refs/tags/automc-v* ]]; then + echo "tag=${GITEA_REF#refs/tags/}" >> "$GITEA_OUTPUT" + else + echo "tag=automc" >> "$GITEA_OUTPUT" + fi + echo "sha=sha-$(echo "$GITEA_SHA" | cut -c1-7)" >> "$GITEA_OUTPUT" + + - name: Login to registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.timemachine.center -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + + - name: Build and push + run: | + IMG=git.timemachine.center/timemachine/${{ gitea.event.repository.name }} + docker build -t "$IMG:${{ steps.meta.outputs.tag }}" -t "$IMG:${{ steps.meta.outputs.sha }}" . + docker push "$IMG:${{ steps.meta.outputs.tag }}" + docker push "$IMG:${{ steps.meta.outputs.sha }}" diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..c143091 --- /dev/null +++ b/FORK.md @@ -0,0 +1,51 @@ +# Fork status — Timemachine/mc-router + +This is a **soft fork** of [`itzg/mc-router`](https://github.com/itzg/mc-router) maintained for the [automc](https://git.timemachine.center/Timemachine/automc) Minecraft management platform. + +## Branch model + +| Branch | Tracks | Contents | +|---|---|---| +| `main` | `upstream/main` (github.com/itzg/mc-router) | Verbatim mirror. **Do not commit here.** | +| `automc` | branched from `v1.42.1` | Soft fork; carries the automc-specific patch. | + +## Patch surface + +Minimal — designed for low-friction rebases. + +| Path | Type | Why | +|---|---|---| +| `cmd/mc-router/main.go` | edit | 1 import line + 4-line `automc.Wire(ctx)` call. Only upstream file modified. | +| `internal/automc/` | new dir | Self-contained extension package — no upstream conflicts possible. | +| `docs/AUTOMC.md` | new | Operator doc for the extensions. See [`docs/AUTOMC.md`](docs/AUTOMC.md). | +| `Makefile` | edit | Appended `sync-upstream` + `automc-build` targets. | +| `go.mod` / `go.sum` | edit | Added `github.com/jackc/pgx/v5 v5.8.0` for LISTEN/NOTIFY. | +| `FORK.md` | new | This file. | + +Everything else stays untouched. No edits to `server/`, `mcproto/`, `cmd/mc-router/` beyond the wire-call. + +## What the fork adds + +1. **Postgres LISTEN/NOTIFY route source** — drop-in alternative to `--routes-config`, `--api-binding`, K8s, and Docker route sources. Pulled in via `AUTOMC_DSN` env var. +2. **HTTP waker integration** — registers a `WakerFunc` per route that POSTs to a control-plane (`AUTOMC_WAKER_URL`) and polls until `state=running`. + +Both are opt-in. With `AUTOMC_DSN` unset, the binary behaves exactly like upstream `itzg/mc-router`. + +See [`docs/AUTOMC.md`](docs/AUTOMC.md) for env vars, schema, troubleshooting. + +## Sync from upstream + +``` +make sync-upstream +``` + +Rebases `automc` onto `upstream/main`, runs `go build ./...` and `go test ./internal/automc/...`. If `server.Routes.CreateMapping` signature changes, only [`internal/automc/pgsync.go`](internal/automc/pgsync.go) needs adjustment. + +## Reporting issues + +| Where it goes | What | +|---|---| +| [`Timemachine/mc-router`](https://git.timemachine.center/Timemachine/mc-router/issues) | automc-specific bugs (pg sync, waker, fork mechanics) | +| [`itzg/mc-router`](https://github.com/itzg/mc-router/issues) | Upstream core bugs (routing, mcproto, REST API) | + +If unsure, file upstream first — most reports turn out to be upstream-side. diff --git a/docs/AUTOMC.md b/docs/AUTOMC.md index 87f71f1..d228bff 100644 --- a/docs/AUTOMC.md +++ b/docs/AUTOMC.md @@ -38,7 +38,7 @@ $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS automc_servers_route_notify ON servers; CREATE TRIGGER automc_servers_route_notify - AFTER INSERT OR UPDATE OF domain, address, state OR DELETE ON servers + AFTER INSERT OR UPDATE OF domain, address, state, enabled OR DELETE ON servers FOR EACH ROW EXECUTE FUNCTION automc_notify_routes_changed(); ``` @@ -46,6 +46,47 @@ The trigger fires on every mutation to a route-relevant column. mc-router holds State column is not read by mc-router; it exists to drive the trigger and for the waker's own ready-check. +### Sync lifecycle + +```mermaid +sequenceDiagram + participant Wire as automc.Wire + participant Sync as syncer + participant PG as Postgres + participant R as server.Routes + + Wire->>Sync: newSyncer(dsn, waker) + Wire-)Sync: go run(ctx) + Note over Sync: backoff = 1s + + loop reconnect (until ctx cancelled) + Sync->>PG: pgx.Connect(dsn) + alt connect ok + Sync->>PG: LISTEN automc_routes_changed + Sync->>PG: SELECT name, domain, address FROM servers + PG-->>Sync: initial rows + Sync->>Sync: diff vs current (empty on first run) + loop for each add + Sync->>R: CreateMapping(host, addr, waker) + end + loop for each del + Sync->>R: DeleteMapping(host) + end + Note over Sync: backoff reset + + loop while connection healthy + Sync->>PG: WaitForNotification(ctx) + PG-->>Sync: NOTIFY automc_routes_changed + Sync->>PG: SELECT all servers + PG-->>Sync: fresh rows + Sync->>R: CreateMapping / DeleteMapping (diff only) + end + else connect or notify error + Note over Sync: warn log
sleep backoff
backoff = min(backoff*2, 30s) + end + end +``` + ## Waker contract When `AUTOMC_WAKER_URL` is set, every route is registered with a `WakerFunc` that the upstream connector calls only when a client tries to LOGIN (not on status pings — those are answered locally via `--use-asleep-motd`). @@ -58,6 +99,47 @@ The waker: The polled address overrides the route's static address for that connection only — useful when the backend's IP is allocated lazily. +### Waker dispatch on stopped backend + +```mermaid +sequenceDiagram + actor Client as MC Client + participant MR as mc-router + participant SM as server-manager (waker URL) + participant MC as MC backend + + Client->>MR: TCP + Handshake (next_state=login) + Note over MR: Routes.FindBackendForServerAddress
returns backend + WakerFunc + MR->>MC: dial backend address + MC--xMR: connection refused + Note over MR: WakerFunc != nil →
invoke it before kicking client + + MR->>SM: POST /servers/test1/start
X-API-Key: ... + alt 202 Accepted + SM-->>MR: 202 + else 409 Conflict (already starting) + SM-->>MR: 409 — treat as success + else 5xx + SM-->>MR: error → WakerFunc returns err + MR->>Client: kick (connection refused) + end + + loop every 2s, up to 90s + MR->>SM: GET /servers/test1 + alt running + SM-->>MR: 200 state=running address=10.0.0.5:25565 + else still starting + SM-->>MR: 200 state=starting + Note over MR: continue polling + end + end + + Note over MR: WakerFunc returns address + MR->>MC: dial polled address + MC-->>MR: ok + MR->>Client: splice handshake-and-onward +``` + ## Upstream sync ``` @@ -76,6 +158,43 @@ go.mod / go.sum — pgx dep added; mergeable If upstream renames `server.Routes.CreateMapping` or changes its signature, only `pgsync.go:apply` needs adjustment. +## Quick start + +```bash +# 1. Start a postgres reachable from mc-router +podman run -d --name automc-pg \ + -e POSTGRES_PASSWORD=test -e POSTGRES_DB=automc \ + -p 127.0.0.1:5432:5432 docker.io/postgres:16-alpine + +# 2. Apply the schema + trigger (see "Postgres schema" above) +podman exec -i automc-pg psql -U postgres -d automc < schema.sql + +# 3. Run mc-router with automc enabled +AUTOMC_DSN="postgres://postgres:test@127.0.0.1:5432/automc?sslmode=disable" \ +AUTOMC_WAKER_URL="http://server-manager:8080" \ +AUTOMC_WAKER_TOKEN="$SM_API_KEY" \ +mc-router --port 25565 --api-binding 127.0.0.1:25590 --use-asleep-motd + +# 4. Add a route +podman exec automc-pg psql -U postgres -d automc -c \ + "INSERT INTO servers (name, domain, address, state) \ + VALUES ('test1', 'test1.example.com', '127.0.0.1:25001', 'running');" + +# Log should show: automc route +: test1.example.com → 127.0.0.1:25001 (test1) +# REST API should show it: curl http://127.0.0.1:25590/routes +``` + +## Troubleshooting + +| Symptom | Likely cause | Check | +|---|---|---| +| Binary starts but no `automc: pg route sync started` log | `AUTOMC_DSN` empty or unset | `env \| grep AUTOMC_DSN` | +| `automc pgsync disconnected; reconnecting in 1s` repeating | pg unreachable / wrong DSN | Test with `psql "$AUTOMC_DSN" -c "SELECT 1"` | +| INSERT into `servers` doesn't fire a route | Trigger missing or not on the right columns | `\d+ servers` in psql — confirm `automc_servers_route_notify` trigger exists | +| Routes appear in REST `/routes` but client connect times out | Backend address wrong / unreachable from mc-router | `podman exec mc-router nc -zv
` | +| WakerFunc never called for stopped backends | `AUTOMC_WAKER_URL` empty | Set it; without it stopped backends get connection refused on login | +| `waker timeout for X after 1m30s` | Backend takes longer than 90 s to come up | Tune `wakerPollTimeout` (currently a const in `waker.go:18`); planned env var | + ## Verification ``` @@ -83,3 +202,5 @@ go build ./... go test ./internal/automc/... go vet ./... ``` + +End-to-end smoke recipe verified 2026-05-27 on local podman: INSERT/UPDATE/DELETE in `servers` table propagated to `/routes` REST API within ~1 s; postgres restart triggered exponential backoff reconnect (1 s → 30 s cap) and full route re-sync on reconnect. diff --git a/internal/automc/pgsync.go b/internal/automc/pgsync.go index 71af089..4e8057e 100644 --- a/internal/automc/pgsync.go +++ b/internal/automc/pgsync.go @@ -82,7 +82,7 @@ func (s *syncer) connectAndLoop(ctx context.Context) error { } func (s *syncer) refresh(ctx context.Context, conn *pgx.Conn) error { - rows, err := conn.Query(ctx, `SELECT name, domain, address FROM servers WHERE domain != '' AND address != ''`) + rows, err := conn.Query(ctx, `SELECT name, domain, address FROM servers WHERE domain IS NOT NULL AND domain != '' AND address != '' AND enabled IS NOT FALSE`) if err != nil { return err }