7 Commits

Author SHA1 Message Date
claude-timemachine cf3ed60bce automc ui: match wrapper-console palette + flat layout
CI / validate (push) Successful in 13s
CI / docker (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:43:04 +02:00
claude-timemachine 39d7d4586b automc ui: per-route active connection counts + total in header
CI / validate (push) Successful in 15s
CI / docker (push) Successful in 12s
Pulls mc_router_active_connections + mc_router_server_active_connections
from the Prometheus default registry on every /api/routes call. UI
gains an Active column on the routes table and the header subtitle
shows "N routes · M active conn".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:39:59 +02:00
claude-timemachine b995e012be automc ui: relative api paths so /infra/mc-router/ proxying works
CI / validate (push) Successful in 13s
CI / docker (push) Successful in 10s
Same fix as svc-proxy: absolute /api/* hit the server-manager origin
when the UI is reverse-proxied at /infra/mc-router/. Switch to ./api/*
so the requests stay under the proxied prefix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:35:00 +02:00
claude-timemachine e2ce0453fa automc: operator UI on AUTOMC_UI_BINDING
CI / validate (push) Successful in 12s
CI / docker (push) Successful in 15s
Adds a separate HTTP server (not the upstream API on :25590) for the
operator dashboard. Single-page UI with two panes:
  * routes table — current pg-synced mappings, polled every 2s
  * logs — SSE stream backed by a logrus hook + 500-entry ring buffer

Opt-in via AUTOMC_UI_BINDING (e.g. ":8082"); unset = no-op, behaves
exactly like upstream. Designed to live behind server-manager's
/infra/mc-router/* reverse-proxy.

Patch is internal/automc-only, same fork philosophy as the rest —
upstream files stay verbatim.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:03:48 +02:00
claude-timemachine 004ee6dbfd automc: drop waker gate on backend-down predefined status
CI / validate (push) Successful in 38s
CI / docker (push) Successful in 16s
Previously, the predefined "asleep" MOTD response on backend-dial-failure
was only served when the route had a waker registered. For static fleets
(no auto-scale) the gate left clients with a silently-closed TCP socket on
status-pings whenever the backend was down — they couldn't tell whether
the route was wrong, the host was offline, or the proxy was down.

Drop the `waker != nil` gate so any known route's dial-failure on
NextState=Status falls back to the configured asleep/loading MOTD. The
existing per-route override + global AUTO_SCALE_ASLEEP_MOTD chain handles
the actual text. Login attempts still close silently as before — only
status pings get the fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:24:32 +02:00
claude-timemachine 7884fb1c5f 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.
2026-05-27 22:57:51 +02:00
claude-timemachine 657fca325e 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.
2026-05-27 11:10:02 +02:00
18 changed files with 1377 additions and 95 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.
+12
View File
@@ -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
+5
View File
@@ -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)
+206
View File
@@ -0,0 +1,206 @@
# 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, enabled 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.
### 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`).
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.
### 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
```
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.
## 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
```
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.
+27 -24
View File
@@ -3,23 +3,23 @@ module github.com/itzg/mc-router
go 1.26.2
require (
github.com/containerd/errdefs v1.0.0
github.com/fsnotify/fsnotify v1.10.1
github.com/go-kit/kit v0.13.0
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.12.0
github.com/pires/go-proxyproto v0.11.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
golang.ngrok.com/ngrok v1.12.1
golang.org/x/text v0.37.0
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
golang.org/x/text v0.36.0
k8s.io/api v0.35.4
k8s.io/apimachinery v0.35.4
k8s.io/client-go v0.35.4
)
// go-kit pulls in old, ambiguous package
@@ -31,11 +31,15 @@ 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
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -60,25 +64,23 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.ngrok.com/muxado/v2 v2.0.1 // indirect
golang.org/x/sync v0.20.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
)
require (
@@ -90,6 +92,7 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -101,16 +104,16 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.3.0 // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+86 -62
View File
@@ -1,13 +1,15 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -29,8 +31,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
@@ -56,6 +58,8 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -64,12 +68,14 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
@@ -82,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=
@@ -122,12 +136,16 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -153,31 +171,32 @@ 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=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 h1:FyjCyI9jVEfqhUh2MoSkmolPjfh5fp2hnV0b0irxH4Q=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -193,17 +212,19 @@ golang.ngrok.com/ngrok v1.12.1/go.mod h1:BKOMdoZXfD4w6o3EtE7Cu9TVbaUWBqptrZRWnVc
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -214,32 +235,34 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97 h1:W18sezcAYs+3tDZX4F80yctqa12jcP1PUS2gQu1zTPU=
google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97/go.mod h1:iargEX0SFPm3xcfMI0d1domjg0ZF4Aa0p2awqyxhvF0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -249,28 +272,29 @@ 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=
gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988=
k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU=
k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds=
k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc=
k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8=
k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+116
View File
@@ -0,0 +1,116 @@
package automc
import (
"fmt"
"sync"
"time"
"github.com/sirupsen/logrus"
)
// LogEntry is the structured shape pushed to UI subscribers via SSE.
type LogEntry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Msg string `json:"msg"`
Attrs string `json:"attrs,omitempty"`
}
// LogBus is a fan-out buffer for logrus entries: a ring of the last N
// entries (replayed on connect) + live broadcast to current subscribers.
// Identical model to the one in svc-proxy/internal/httpsrv — kept local to
// avoid a cross-repo dep on the fork.
type LogBus struct {
cap int
mu sync.RWMutex
ring []LogEntry
next int
full bool
listeners map[chan LogEntry]struct{}
}
func NewLogBus(capacity int) *LogBus {
if capacity <= 0 {
capacity = 500
}
return &LogBus{
cap: capacity,
ring: make([]LogEntry, capacity),
listeners: map[chan LogEntry]struct{}{},
}
}
func (b *LogBus) Push(e LogEntry) {
b.mu.Lock()
b.ring[b.next] = e
b.next = (b.next + 1) % b.cap
if b.next == 0 {
b.full = true
}
subs := make([]chan LogEntry, 0, len(b.listeners))
for ch := range b.listeners {
subs = append(subs, ch)
}
b.mu.Unlock()
for _, ch := range subs {
select {
case ch <- e:
default:
}
}
}
func (b *LogBus) Backlog() []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
if !b.full {
out := make([]LogEntry, b.next)
copy(out, b.ring[:b.next])
return out
}
out := make([]LogEntry, 0, b.cap)
out = append(out, b.ring[b.next:]...)
out = append(out, b.ring[:b.next]...)
return out
}
func (b *LogBus) Subscribe() chan LogEntry {
ch := make(chan LogEntry, 32)
b.mu.Lock()
b.listeners[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *LogBus) Unsubscribe(ch chan LogEntry) {
b.mu.Lock()
delete(b.listeners, ch)
b.mu.Unlock()
close(ch)
}
// logrusBusHook adapts the LogBus to logrus's Hook interface. Registered
// globally from Wire() so every upstream log emission is captured.
type logrusBusHook struct{ bus *LogBus }
func (h *logrusBusHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (h *logrusBusHook) Fire(e *logrus.Entry) error {
var attrs string
for k, v := range e.Data {
if attrs != "" {
attrs += " "
}
attrs += fmt.Sprintf("%s=%v", k, v)
}
h.bus.Push(LogEntry{
Time: e.Time,
Level: e.Level.String(),
Msg: e.Message,
Attrs: attrs,
})
return nil
}
+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 IS NOT NULL AND domain != '' AND address != '' AND enabled IS NOT FALSE`)
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
}
+134
View File
@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>mc-router</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0e0e0e; color: #eee;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.4; }
body { display: flex; flex-direction: column; }
header { padding: 8px 12px; background: #1a1a1a; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center; flex: 0 0 auto; }
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
header h1 .meta { color: #888; font-weight: normal; margin-left: 10px; }
#status { font-size: 12px; color: #6f6; }
#status.disconnected { color: #f66; }
#status.connecting { color: #fc6; }
main { flex: 1; display: grid; grid-template-rows: 1fr 1fr; min-height: 0; }
section { padding: 12px 16px; overflow: auto; min-height: 0; }
section + section { border-top: 1px solid #333; }
h2 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: #888;
text-transform: uppercase; letter-spacing: 0.6px;
display: flex; justify-content: space-between; align-items: center; }
h2 .count { color: #888; font-weight: normal; margin-left: 6px; }
h2 button { background: transparent; color: #888; border: 1px solid #333;
padding: 2px 8px; cursor: pointer; font: inherit; font-size: 11px; border-radius: 3px; }
h2 button:hover { color: #eee; border-color: #6cf; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 4px 10px; border-bottom: 1px solid #1f1f1f; }
th { color: #888; font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
td.num { text-align: right; }
.empty { color: #888; padding: 16px 0; }
pre.logbox { margin: 0; white-space: pre-wrap; word-break: break-word; }
.log-line { padding: 0; }
.log-line .ts { color: #888; margin-right: 8px; }
.log-line .lvl { margin-right: 6px; font-weight: 600; }
.log-line.lvl-debug .lvl { color: #888; }
.log-line.lvl-info .lvl { color: #6cf; }
.log-line.lvl-warning .lvl, .log-line.lvl-warn .lvl { color: #fc6; }
.log-line.lvl-error .lvl { color: #f66; }
.log-line .attrs { color: #888; margin-left: 8px; }
</style>
</head>
<body>
<header>
<h1>mc-router <span class="meta" id="meta">— connecting…</span></h1>
<span id="status" class="connecting">log stream: connecting…</span>
</header>
<main>
<section>
<h2><span>Routes <span class="count" id="route-count"></span></span></h2>
<table>
<thead>
<tr>
<th>Server address (host)</th>
<th>Backend</th>
<th class="num">Active</th>
</tr>
</thead>
<tbody id="route-rows"></tbody>
</table>
<div class="empty" id="route-empty">no routes registered</div>
</section>
<section>
<h2><span>Logs</span><button onclick="document.getElementById('logbox').innerHTML=''">clear</button></h2>
<pre class="logbox" id="logbox"></pre>
</section>
</main>
<script>
async function refreshRoutes() {
try {
const r = await fetch('./api/routes');
const j = await r.json();
const rows = document.getElementById('route-rows');
const empty = document.getElementById('route-empty');
const count = document.getElementById('route-count');
rows.innerHTML = '';
if (!j.routes || j.routes.length === 0) {
empty.style.display = '';
count.textContent = '';
} else {
empty.style.display = 'none';
count.textContent = '(' + j.routes.length + ')';
for (const r of j.routes) {
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + r.server_address + '</td>' +
'<td>' + r.backend + '</td>' +
'<td class="num">' + r.active_connections + '</td>';
rows.appendChild(tr);
}
}
document.getElementById('meta').textContent =
'— ' + j.routes.length + ' routes · ' + (j.total_connections || 0) + ' active conn';
} catch (e) {
document.getElementById('meta').textContent = '— api error';
}
}
setInterval(refreshRoutes, 2000);
refreshRoutes();
function startLogStream() {
const status = document.getElementById('status');
const box = document.getElementById('logbox');
const es = new EventSource('./api/logs');
es.onopen = () => { status.textContent = 'log stream: live'; status.className = ''; };
es.onerror = () => { status.textContent = 'log stream: reconnecting…'; status.className = 'disconnected'; };
es.onmessage = ev => {
let e;
try { e = JSON.parse(ev.data); } catch { return; }
const ts = e.time ? e.time.split('T')[1].split('.')[0] : '';
const div = document.createElement('div');
const lvl = (e.level || 'info').toLowerCase();
div.className = 'log-line lvl-' + lvl;
div.innerHTML =
'<span class="ts">' + ts + '</span>' +
'<span class="lvl">' + (e.level || 'info') + '</span>' +
(e.msg || '') +
(e.attrs ? '<span class="attrs">' + e.attrs + '</span>' : '');
box.appendChild(div);
const parent = box.parentElement;
if (parent.scrollHeight - parent.scrollTop - parent.clientHeight < 60) {
parent.scrollTop = parent.scrollHeight;
}
while (box.children.length > 1000) box.removeChild(box.firstChild);
};
}
startLogStream();
</script>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
package automc
import (
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"sort"
"time"
"github.com/itzg/mc-router/server"
"github.com/prometheus/client_golang/prometheus"
"github.com/sirupsen/logrus"
)
//go:embed static/*
var staticFS embed.FS
// startUI starts a separate HTTP server on uiBinding serving the operator
// dashboard (embedded index.html), an SSE log feed, and a JSON snapshot of
// the current route table. The upstream JSON API on API_BINDING is left
// untouched so existing tooling keeps working.
func startUI(ctx context.Context, uiBinding string, bus *LogBus) {
mux := http.NewServeMux()
sub, err := fs.Sub(staticFS, "static")
if err != nil {
logrus.WithError(err).Error("automc ui: embed misconfigured")
return
}
mux.Handle("GET /", http.FileServer(http.FS(sub)))
mux.HandleFunc("GET /api/routes", routesSnapshotHandler)
mux.HandleFunc("GET /api/logs", sseLogsHandler(bus))
srv := &http.Server{
Addr: uiBinding,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
go func() {
logrus.WithField("binding", uiBinding).Info("automc: ui server listening")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logrus.WithError(err).Error("automc ui server failed")
}
}()
}
// RouteSnapshot is one row of the routes table the UI renders. Same shape as
// the upstream /routes JSON but flatter — the UI doesn't need both backend
// and scalingTarget shown separately. ActiveConnections is the live gauge
// value scraped from the Prometheus registry (-1 if the metric isn't
// registered, which can happen briefly at startup).
type RouteSnapshot struct {
ServerAddress string `json:"server_address"`
Backend string `json:"backend"`
ActiveConnections int `json:"active_connections"`
}
func routesSnapshotHandler(w http.ResponseWriter, _ *http.Request) {
mappings := server.Routes.GetMappings()
perRoute, total := scrapeActiveConnections()
out := make([]RouteSnapshot, 0, len(mappings))
for addr, backend := range mappings {
conns, ok := perRoute[addr]
if !ok {
conns = 0
}
out = append(out, RouteSnapshot{ServerAddress: addr, Backend: backend, ActiveConnections: conns})
}
sort.Slice(out, func(i, j int) bool { return out[i].ServerAddress < out[j].ServerAddress })
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"routes": out,
"total_connections": total,
"at": time.Now(),
})
}
// scrapeActiveConnections walks the Prometheus default registry to extract
// two gauges that mc-router exposes:
//
// mc_router_active_connections (no labels)
// mc_router_server_active_connections{server_address=…} (per route)
//
// Returns (perServerAddress, total). On any gathering error returns zero
// values silently — the UI shows 0 rather than blocking on a metrics issue.
func scrapeActiveConnections() (map[string]int, int) {
per := map[string]int{}
total := 0
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
return per, 0
}
for _, mf := range families {
switch mf.GetName() {
case "mc_router_active_connections":
for _, m := range mf.GetMetric() {
if g := m.GetGauge(); g != nil {
total = int(g.GetValue())
}
}
case "mc_router_server_active_connections":
for _, m := range mf.GetMetric() {
var addr string
for _, lp := range m.GetLabel() {
if lp.GetName() == "server_address" {
addr = lp.GetValue()
break
}
}
if addr == "" {
continue
}
if g := m.GetGauge(); g != nil {
per[addr] = int(g.GetValue())
}
}
}
}
return per, total
}
func sseLogsHandler(bus *LogBus) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
for _, e := range bus.Backlog() {
writeSSEEvent(w, e)
}
flusher.Flush()
ch := bus.Subscribe()
defer bus.Unsubscribe(ch)
hb := time.NewTicker(30 * time.Second)
defer hb.Stop()
for {
select {
case <-r.Context().Done():
return
case e := <-ch:
writeSSEEvent(w, e)
flusher.Flush()
case <-hb.C:
_, _ = io.WriteString(w, ":hb\n\n")
flusher.Flush()
}
}
}
}
func writeSSEEvent(w io.Writer, e LogEntry) {
fmt.Fprintf(w, "data: {\"time\":%q,\"level\":%q,\"msg\":%q,\"attrs\":%q}\n\n",
e.Time.Format(time.RFC3339Nano), e.Level, e.Msg, e.Attrs)
}
+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)
}
}
+33
View File
@@ -0,0 +1,33 @@
// 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")
// Operator UI on a separate port — upstream's API_BINDING stays
// JSON-only and untouched. Enable by setting AUTOMC_UI_BINDING (e.g.
// ":8082"); leave unset to skip and behave exactly like upstream.
if uiBinding := os.Getenv("AUTOMC_UI_BINDING"); uiBinding != "" {
bus := NewLogBus(500)
logrus.AddHook(&logrusBusHook{bus: bus})
startUI(ctx, uiBinding, bus)
}
return nil
}
+3 -3
View File
@@ -85,7 +85,7 @@ func ReadPacket(reader *bufio.Reader, addr net.Addr, state State) (*Packet, erro
logrus.
WithField("client", addr).
WithField("packet", packet).
Trace("Read packet")
Debug("Read packet")
return packet, nil
}
@@ -206,7 +206,7 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) {
logrus.
WithField("client", addr).
WithField("length", frame.Length).
Trace("Read frame length")
Debug("Read frame length")
frame.Payload = make([]byte, frame.Length)
total := 0
@@ -223,7 +223,7 @@ func ReadFrame(reader io.Reader, addr net.Addr) (*Frame, error) {
WithField("client", addr).
WithField("total", total).
WithField("length", frame.Length).
Trace("Reading frame content")
Debug("Reading frame content")
if n == 0 {
logrus.
+8 -3
View File
@@ -526,7 +526,6 @@ func (c *Connector) cleanupBackendConnection(clientAddr net.Addr, serverAddress
logrus.
WithField("client", clientAddr).
WithField("backendHostPort", backendHostPort).
WithField("player", playerInfo).
WithField("connectionCount", c.activeConnections.GetCount(backendHostPort)).
Info("Closed connection to backend")
if checkScaleDown && c.scaleActiveConnections.GetCount(scalingTarget) <= 0 {
@@ -660,12 +659,18 @@ func (c *Connector) findAndConnectBackend(frontendConn net.Conn,
}
}
if waker != nil && nextState == mcproto.StateStatus {
if nextState == mcproto.StateStatus {
// Previously gated on `waker != nil` so only auto-scale routes
// got a predefined response. For static fleets (no waker) clients
// just saw a closed connection on backend-down. Now any known
// route whose backend dial fails returns the configured asleep/
// loading MOTD — falls back to the global AUTO_SCALE_ASLEEP_MOTD
// when no per-route override is set.
logrus.WithFields(logrus.Fields{
"client": clientAddr,
"server": serverAddress,
"isLegacy": isLegacy,
}).Debug("Scalable backend unreachable: serving predefined status response")
}).Debug("Backend unreachable: serving predefined status response")
br := bufio.NewReader(frontendConn)
if isLegacy {
-3
View File
@@ -482,9 +482,6 @@ func (w *dockerWatcherImpl) listContainers(ctx context.Context) ([]*routableCont
if !data.notRunning {
endpoint = fmt.Sprintf("%s:%d", data.ip, data.port)
}
logrus.WithField("backendEndpoint", endpoint).
WithField("containerID", container.ID).
Debug("Found routable Docker container")
for _, host := range data.hosts {
result = append(result, &routableContainer{