automc: pg LISTEN/NOTIFY route source + HTTP waker
Adds opt-in extension package internal/automc/ that: - Subscribes to Postgres notifications on a 'servers' table and pushes route changes into server.Routes (no file I/O, no fsnotify). - Provides a WakerFunc that POSTs to a configurable HTTP control plane (server-manager) and polls until state=running. When AUTOMC_DSN is unset, Wire() is a no-op and the binary behaves exactly like upstream itzg/mc-router. Single patch site in main.go (import + 4-line call) keeps upstream rebases trivial. See docs/AUTOMC.md for env vars and the expected DB schema/trigger.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 ./...
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user