diff --git a/Makefile b/Makefile index 6a0345b..94b03db 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,15 @@ test: .PHONY: release release: curl -sL https://git.io/goreleaser | bash + +.PHONY: sync-upstream +sync-upstream: + git fetch upstream + git checkout automc + git rebase upstream/main + go build ./... + go test ./internal/automc/... + +.PHONY: automc-build +automc-build: + go build -o mc-router ./cmd/mc-router diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go index a058982..5def768 100644 --- a/cmd/mc-router/main.go +++ b/cmd/mc-router/main.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/itzg/go-flagsfiller" + "github.com/itzg/mc-router/internal/automc" "github.com/itzg/mc-router/server" "github.com/sirupsen/logrus" ) @@ -65,6 +66,10 @@ func main() { logrus.WithError(err).Fatal("Could not setup server") } + if err := automc.Wire(ctx); err != nil { + logrus.WithError(err).Fatal("automc Wire failed") + } + var wg sync.WaitGroup wg.Go(s.Run) diff --git a/docs/AUTOMC.md b/docs/AUTOMC.md new file mode 100644 index 0000000..87f71f1 --- /dev/null +++ b/docs/AUTOMC.md @@ -0,0 +1,85 @@ +# automc extensions + +Soft fork of `itzg/mc-router` that adds Postgres-driven route management and an HTTP waker, without touching upstream behavior by default. + +The `internal/automc` package is opt-in via env vars: with `AUTOMC_DSN` unset, the binary behaves exactly like upstream. + +## Environment variables + +| Var | Required | Purpose | +|---|---|---| +| `AUTOMC_DSN` | yes (to enable) | Postgres DSN, e.g. `postgres://user:pass@host:5432/automc?sslmode=disable`. When unset, automc is a no-op. | +| `AUTOMC_WAKER_URL` | no | Base URL of the waker control plane (server-manager). When set, stopped backends are auto-started on login. | +| `AUTOMC_WAKER_TOKEN` | no | Sent as `X-API-Key` header on every waker request. | + +Recommended companion upstream flags: + +- `--use-asleep-motd` / `--use-loading-motd` — supplies friendly MOTD to clients during the wake window. Already implemented upstream; automc does not duplicate this. + +## Postgres schema + +Apply this once to the database referenced by `AUTOMC_DSN`. mc-router only reads; the trigger is what tells it to re-read. + +```sql +CREATE TABLE IF NOT EXISTS servers ( + name TEXT PRIMARY KEY, + domain TEXT NOT NULL, + address TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'stopped', + UNIQUE(domain) +); + +CREATE OR REPLACE FUNCTION automc_notify_routes_changed() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('automc_routes_changed', ''); + RETURN NULL; +END; +$$ 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 + FOR EACH ROW EXECUTE FUNCTION automc_notify_routes_changed(); +``` + +The trigger fires on every mutation to a route-relevant column. mc-router holds a persistent `LISTEN automc_routes_changed` and re-runs `SELECT name, domain, address FROM servers WHERE domain != '' AND address != ''`, diffing against its in-memory map. Adds/removes/changes call `server.Routes.CreateMapping` and `DeleteMapping` directly — no file I/O. + +State column is not read by mc-router; it exists to drive the trigger and for the waker's own ready-check. + +## 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`). + +The waker: + +1. `POST {AUTOMC_WAKER_URL}/servers/{name}/start` — fire-and-forget start signal. `409 Conflict` is treated as success (already starting). +2. Polls `GET {AUTOMC_WAKER_URL}/servers/{name}` every 2 s, expecting JSON `{"state":"running","address":"host:port"}`. +3. Returns the polled `address` once `state == "running"`, or errors after 90 s. + +The polled address overrides the route's static address for that connection only — useful when the backend's IP is allocated lazily. + +## Upstream sync + +``` +make sync-upstream +``` + +Fetches `upstream/main`, rebases the `automc` branch onto it, builds, and runs the automc tests. The patch surface is intentionally tiny so rebase conflicts are rare: + +``` +cmd/mc-router/main.go — 1 import line + 4-line Wire call +internal/automc/ — new directory; no upstream conflicts possible +docs/AUTOMC.md — new doc; no upstream conflicts +Makefile — appended targets only +go.mod / go.sum — pgx dep added; mergeable +``` + +If upstream renames `server.Routes.CreateMapping` or changes its signature, only `pgsync.go:apply` needs adjustment. + +## Verification + +``` +go build ./... +go test ./internal/automc/... +go vet ./... +``` diff --git a/go.mod b/go.mod index 9068015..4ee0aa0 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/itzg/go-flagsfiller v1.17.0 + github.com/jackc/pgx/v5 v5.8.0 github.com/juju/ratelimit v1.0.2 github.com/pires/go-proxyproto v0.11.0 github.com/pkg/errors v0.9.1 @@ -30,6 +31,9 @@ exclude google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4 require ( github.com/beorn7/perks v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect diff --git a/go.sum b/go.sum index 06511bb..050d7d9 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,14 @@ github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSH github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/itzg/go-flagsfiller v1.17.0 h1:Zkg+qsbB24Msu78l+1aqzXAIEKEeLRzAiK7DN40Fdkk= github.com/itzg/go-flagsfiller v1.17.0/go.mod h1:ub1t7dNqIj57TWKUtEqfopXg0xKbBgd9JVuCLmelwNo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -163,6 +171,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -263,6 +272,7 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= diff --git a/internal/automc/pgsync.go b/internal/automc/pgsync.go new file mode 100644 index 0000000..71af089 --- /dev/null +++ b/internal/automc/pgsync.go @@ -0,0 +1,133 @@ +package automc + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/itzg/mc-router/server" + "github.com/jackc/pgx/v5" + "github.com/sirupsen/logrus" +) + +const ( + notifyChannel = "automc_routes_changed" + reconnectMin = 1 * time.Second + reconnectMax = 30 * time.Second +) + +type route struct { + name string + domain string + address string +} + +type syncer struct { + dsn string + waker *wakerConfig + current map[string]route +} + +func newSyncer(dsn string, w *wakerConfig) *syncer { + return &syncer{dsn: dsn, waker: w, current: map[string]route{}} +} + +func (s *syncer) run(ctx context.Context) { + backoff := reconnectMin + for { + if ctx.Err() != nil { + return + } + err := s.connectAndLoop(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + logrus.WithError(err).Warnf("automc pgsync disconnected; reconnecting in %s", backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff *= 2 + if backoff > reconnectMax { + backoff = reconnectMax + } + } +} + +func (s *syncer) connectAndLoop(ctx context.Context) error { + conn, err := pgx.Connect(ctx, s.dsn) + if err != nil { + return fmt.Errorf("pgx connect: %w", err) + } + defer conn.Close(context.Background()) + + if _, err := conn.Exec(ctx, "LISTEN "+notifyChannel); err != nil { + return fmt.Errorf("LISTEN: %w", err) + } + logrus.Infof("automc pgsync connected; LISTEN %s", notifyChannel) + + if err := s.refresh(ctx, conn); err != nil { + return fmt.Errorf("initial refresh: %w", err) + } + + for { + if _, err := conn.WaitForNotification(ctx); err != nil { + return fmt.Errorf("wait notification: %w", err) + } + if err := s.refresh(ctx, conn); err != nil { + return fmt.Errorf("refresh: %w", err) + } + } +} + +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 != ''`) + if err != nil { + return err + } + defer rows.Close() + + desired := map[string]route{} + for rows.Next() { + var r route + if err := rows.Scan(&r.name, &r.domain, &r.address); err != nil { + return err + } + desired[r.domain] = r + } + if err := rows.Err(); err != nil { + return err + } + + add, del := diff(s.current, desired) + s.apply(add, del) + s.current = desired + return nil +} + +func (s *syncer) apply(add []route, del []string) { + for _, host := range del { + if server.Routes.DeleteMapping(host) { + logrus.Infof("automc route -: %s", host) + } + } + for _, r := range add { + server.Routes.CreateMapping(r.domain, r.address, "", s.waker.wakerFor(r.name), nil, "", "") + logrus.Infof("automc route +: %s → %s (%s)", r.domain, r.address, r.name) + } +} + +func diff(prev, next map[string]route) (add []route, del []string) { + for host, r := range next { + if p, ok := prev[host]; !ok || p.address != r.address || p.name != r.name { + add = append(add, r) + } + } + for host := range prev { + if _, ok := next[host]; !ok { + del = append(del, host) + } + } + return add, del +} diff --git a/internal/automc/pgsync_test.go b/internal/automc/pgsync_test.go new file mode 100644 index 0000000..c9e398a --- /dev/null +++ b/internal/automc/pgsync_test.go @@ -0,0 +1,103 @@ +package automc + +import ( + "sort" + "testing" +) + +func TestDiff(t *testing.T) { + cases := []struct { + name string + prev, next map[string]route + wantAddHost []string + wantDel []string + }{ + { + name: "empty to empty", + prev: map[string]route{}, + next: map[string]route{}, + wantAddHost: nil, + wantDel: nil, + }, + { + name: "add one", + prev: map[string]route{}, + next: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + wantAddHost: []string{"a.example.com"}, + wantDel: nil, + }, + { + name: "delete one", + prev: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + next: map[string]route{}, + wantAddHost: nil, + wantDel: []string{"a.example.com"}, + }, + { + name: "address change", + prev: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + next: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.2:25565"}}, + wantAddHost: []string{"a.example.com"}, + wantDel: nil, + }, + { + name: "name change with same address triggers re-register (waker rebind)", + prev: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + next: map[string]route{"a.example.com": {name: "b", domain: "a.example.com", address: "10.0.0.1:25565"}}, + wantAddHost: []string{"a.example.com"}, + wantDel: nil, + }, + { + name: "no change", + prev: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + next: map[string]route{"a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}}, + wantAddHost: nil, + wantDel: nil, + }, + { + name: "mixed add + delete", + prev: map[string]route{ + "a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}, + "b.example.com": {name: "b", domain: "b.example.com", address: "10.0.0.2:25565"}, + }, + next: map[string]route{ + "a.example.com": {name: "a", domain: "a.example.com", address: "10.0.0.1:25565"}, + "c.example.com": {name: "c", domain: "c.example.com", address: "10.0.0.3:25565"}, + }, + wantAddHost: []string{"c.example.com"}, + wantDel: []string{"b.example.com"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + add, del := diff(tc.prev, tc.next) + gotAdd := make([]string, 0, len(add)) + for _, r := range add { + gotAdd = append(gotAdd, r.domain) + } + sort.Strings(gotAdd) + sort.Strings(del) + sort.Strings(tc.wantAddHost) + sort.Strings(tc.wantDel) + if !equalSlice(gotAdd, tc.wantAddHost) { + t.Errorf("add: got %v want %v", gotAdd, tc.wantAddHost) + } + if !equalSlice(del, tc.wantDel) { + t.Errorf("del: got %v want %v", del, tc.wantDel) + } + }) + } +} + +func equalSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/automc/waker.go b/internal/automc/waker.go new file mode 100644 index 0000000..70120d4 --- /dev/null +++ b/internal/automc/waker.go @@ -0,0 +1,122 @@ +package automc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/itzg/mc-router/server" +) + +const ( + wakerPollInterval = 2 * time.Second + wakerPollTimeout = 90 * time.Second +) + +var wakerPollIntervalForTest = wakerPollInterval + +type wakerConfig struct { + baseURL string + token string + client *http.Client +} + +func newWakerConfig(baseURL, token string) *wakerConfig { + if baseURL == "" { + return nil + } + return &wakerConfig{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (w *wakerConfig) wakerFor(serverName string) server.WakerFunc { + if w == nil { + return nil + } + return func(ctx context.Context) (string, error) { + if err := w.start(ctx, serverName); err != nil { + return "", err + } + return w.pollUntilRunning(ctx, serverName) + } +} + +func (w *wakerConfig) start(ctx context.Context, name string) error { + u := fmt.Sprintf("%s/servers/%s/start", w.baseURL, url.PathEscape(name)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil) + if err != nil { + return err + } + w.setAuth(req) + resp, err := w.client.Do(req) + if err != nil { + return fmt.Errorf("waker start %s: %w", name, err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusConflict { + // 409 = already starting/running, treat as success + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("waker start %s: %s — %s", name, resp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func (w *wakerConfig) pollUntilRunning(ctx context.Context, name string) (string, error) { + deadline := time.Now().Add(wakerPollTimeout) + ticker := time.NewTicker(wakerPollIntervalForTest) + defer ticker.Stop() + + for { + state, addr, err := w.queryState(ctx, name) + if err == nil && state == "running" && addr != "" { + return addr, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf("waker timeout for %s after %s (last state=%q err=%v)", name, wakerPollTimeout, state, err) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-ticker.C: + } + } +} + +func (w *wakerConfig) queryState(ctx context.Context, name string) (string, string, error) { + u := fmt.Sprintf("%s/servers/%s", w.baseURL, url.PathEscape(name)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", "", err + } + w.setAuth(req) + resp, err := w.client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("query state %s: %s", name, resp.Status) + } + var body struct { + State string `json:"state"` + Address string `json:"address"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", err + } + return body.State, body.Address, nil +} + +func (w *wakerConfig) setAuth(req *http.Request) { + if w.token != "" { + req.Header.Set("X-API-Key", w.token) + } +} diff --git a/internal/automc/waker_test.go b/internal/automc/waker_test.go new file mode 100644 index 0000000..231aaff --- /dev/null +++ b/internal/automc/waker_test.go @@ -0,0 +1,110 @@ +package automc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestWakerNilWhenURLEmpty(t *testing.T) { + w := newWakerConfig("", "") + if w != nil { + t.Fatalf("expected nil waker config when URL empty, got %+v", w) + } + if w.wakerFor("foo") != nil { + t.Fatalf("expected nil WakerFunc from nil config") + } +} + +func TestWakerStartThenPoll(t *testing.T) { + var startCalls int32 + var pollCalls int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != "secret" { + http.Error(w, "no auth", http.StatusUnauthorized) + return + } + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start"): + atomic.AddInt32(&startCalls, 1) + w.WriteHeader(http.StatusAccepted) + case r.Method == http.MethodGet: + n := atomic.AddInt32(&pollCalls, 1) + state := "starting" + addr := "" + if n >= 2 { + state = "running" + addr = "10.0.0.5:25565" + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "state": state, + "address": addr, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + wc := newWakerConfig(srv.URL, "secret") + wc.client.Timeout = 2 * time.Second + + // Tighten poll interval for the test only. + saved := wakerPollIntervalForTest + wakerPollIntervalForTest = 10 * time.Millisecond + t.Cleanup(func() { wakerPollIntervalForTest = saved }) + + fn := wc.wakerFor("test1") + if fn == nil { + t.Fatal("expected non-nil WakerFunc") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + addr, err := fn(ctx) + if err != nil { + t.Fatalf("wake failed: %v", err) + } + if addr != "10.0.0.5:25565" { + t.Errorf("addr: got %q want 10.0.0.5:25565", addr) + } + if atomic.LoadInt32(&startCalls) != 1 { + t.Errorf("expected 1 start call, got %d", startCalls) + } + if got := atomic.LoadInt32(&pollCalls); got < 2 { + t.Errorf("expected >=2 polls, got %d", got) + } +} + +func TestWakerStartHandles409(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/start") { + http.Error(w, "already running", http.StatusConflict) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "state": "running", + "address": "10.0.0.6:25565", + }) + })) + defer srv.Close() + + wc := newWakerConfig(srv.URL, "") + wakerPollIntervalForTest = 10 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + addr, err := wc.wakerFor("x")(ctx) + if err != nil { + t.Fatalf("expected 409 to be treated as success, got err: %v", err) + } + if addr != "10.0.0.6:25565" { + t.Errorf("addr: got %q", addr) + } +} diff --git a/internal/automc/wire.go b/internal/automc/wire.go new file mode 100644 index 0000000..82de371 --- /dev/null +++ b/internal/automc/wire.go @@ -0,0 +1,24 @@ +// Package automc wires automc-specific extensions onto upstream mc-router. +// +// All behavior is opt-in via env vars; when AUTOMC_DSN is unset, Wire is a no-op +// and the binary behaves exactly like upstream itzg/mc-router. +package automc + +import ( + "context" + "os" + + "github.com/sirupsen/logrus" +) + +func Wire(ctx context.Context) error { + dsn := os.Getenv("AUTOMC_DSN") + if dsn == "" { + return nil + } + waker := newWakerConfig(os.Getenv("AUTOMC_WAKER_URL"), os.Getenv("AUTOMC_WAKER_TOKEN")) + s := newSyncer(dsn, waker) + go s.run(ctx) + logrus.Info("automc: pg route sync started") + return nil +}