automc: schema-fit query + CI + FORK doc
CI / validate (push) Successful in 47s
CI / docker (push) Successful in 44s

- pgsync.go: filter rows where enabled IS NOT FALSE and domain IS NOT NULL,
  matching the existing automc servers table (domain nullable, enabled
  defaults true). Trigger doc now includes UPDATE OF enabled.
- .gitea/workflows/ci.yaml: build/test on push to automc branch + tags,
  publish container as git.timemachine.center/timemachine/mc-router:automc.
- FORK.md: soft-fork relationship doc at repo root.
- docs/AUTOMC.md: quick-start recipe, troubleshooting table,
  sync-lifecycle and waker-dispatch sequence diagrams.
This commit is contained in:
2026-05-27 22:57:51 +02:00
parent 657fca325e
commit 7884fb1c5f
4 changed files with 228 additions and 2 deletions
+54
View File
@@ -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 }}"
+51
View File
@@ -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.
+122 -1
View File
@@ -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<br/>sleep backoff<br/>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<br/>returns backend + WakerFunc
MR->>MC: dial backend address
MC--xMR: connection refused
Note over MR: WakerFunc != nil →<br/>invoke it before kicking client
MR->>SM: POST /servers/test1/start<br/>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 <address>` |
| 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.
+1 -1
View File
@@ -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
}