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:
2026-05-27 11:10:02 +02:00
parent 74d0c40022
commit 657fca325e
10 changed files with 608 additions and 0 deletions
+133
View File
@@ -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
}
+103
View File
@@ -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
}
+122
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
}
+24
View File
@@ -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
}