Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf3ed60bce | |||
| 39d7d4586b | |||
| b995e012be | |||
| e2ce0453fa | |||
| 004ee6dbfd | |||
| 7884fb1c5f | |||
| 657fca325e | |||
| 74d0c40022 | |||
| 982a8b8bd9 | |||
| 84d7feb357 | |||
| 2e6d68aade | |||
| 6f470761a5 | |||
| f9127aaa39 | |||
| 358dcdfbe0 | |||
| 2796d92948 |
@@ -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 }}"
|
||||
@@ -17,7 +17,7 @@ go test -run TestRouteLookup ./server/ # Run a single test
|
||||
docker build -t mc-router . # Build Docker image
|
||||
```
|
||||
|
||||
Go version: 1.25. Testing uses `testify` (assert/require). Tests are table-driven.
|
||||
Go version: 1.26.2. Testing uses `testify` (assert/require). Tests are table-driven with subtests. Mock pattern: embed `mock.Mock` and call `m.MethodCalled()` (see `k8s_test.go`). Protocol packet tests use hex fixture files in `testdata/` (e.g., `handshake-status.hex`). Test setup for route tests calls `NewRoutes()` and restores the global `Routes` singleton with defer.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -39,7 +39,8 @@ Go version: 1.25. Testing uses `testify` (assert/require). Tests are table-drive
|
||||
- `routes.go` — In-memory route table mapping server addresses to backends; supports default route fallback
|
||||
- `routes_config_loader.go` — Loads/watches JSON routes config file (with fsnotify)
|
||||
- `k8s.go` — Kubernetes service discovery via annotation `mc-router.itzg.me/externalServerName`
|
||||
- `docker.go` / `docker_swarm.go` — Docker/Swarm container discovery via label `mc-router.host`
|
||||
- `docker.go` — Docker container discovery via label `mc-router.host`; event-driven via Docker Events API (`client.Events`). Each event handled incrementally by `applyEvent` → `containersForID` (single `ContainerInspect`) → `applyContainerRoutesLocked` (touches only that container's routes). Full `monitorContainers` re-list runs at startup and on event-stream reconnect (exponential backoff)
|
||||
- `docker_swarm.go` — Docker Swarm service discovery via label `mc-router.host`; event-driven, but each service event triggers a full `reconcileServices` re-list (services churn rarely, swarm has no autoscaling)
|
||||
- `down_scaler.go` — Auto-scale down after idle period
|
||||
- `api_server.go` — REST API (`GET/POST /routes`, `POST /defaultRoute`, `DELETE /routes/{serverAddress}`)
|
||||
- `metrics.go` — Pluggable metrics backends (Prometheus, InfluxDB, expvar, discard)
|
||||
@@ -60,7 +61,7 @@ CLI flags are the primary config mechanism, with environment variable support vi
|
||||
Routes are populated from three sources that can be combined:
|
||||
1. Static `--mapping` flags or JSON config file
|
||||
2. Kubernetes: watches Services with `mc-router.itzg.me/externalServerName` annotation
|
||||
3. Docker/Swarm: watches containers with `mc-router.host` label
|
||||
3. Docker/Swarm: watches containers/services via the Docker Events API, filtered to lifecycle events (label `mc-router.host`)
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
@@ -72,6 +73,21 @@ Routes are populated from three sources that can be combined:
|
||||
- `golang.ngrok.com/ngrok` — ngrok tunnel integration
|
||||
- `github.com/stretchr/testify` — Test assertions
|
||||
|
||||
### Concurrency Model
|
||||
|
||||
- **`routes.go`**: global singleton `var Routes = NewRoutes()`. `sync.RWMutex` protects `mappings` and `defaultRoute` — `RLock` for all reads, `Lock` for mutations.
|
||||
- **`connector.go`**: `ActiveConnections` map guarded by `sync.RWMutex`; `totalActiveConnections` counter uses `atomic.AddInt32`. Shutdown drain uses `sync.Cond` in `WaitForConnections()`.
|
||||
- **Bidirectional proxy**: two goroutines per connection (client→backend, backend→client) communicate via a buffered `chan error` (size 2) — first error triggers mutual close.
|
||||
- All goroutines respect context cancellation via `select { case <-ctx.Done() }`.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Wrap with context: `fmt.Errorf("message: %w", err)`
|
||||
- Check specific sentinels: `errors.Is(err, io.EOF)`
|
||||
- Log with fields: `logrus.WithError(err).WithField("key", val).Error("msg")`
|
||||
|
||||
### Protocol Notes
|
||||
|
||||
The `mcproto` package handles Minecraft Java protocol quirks: Forge mod identifiers appended to server addresses (separated by `\x00`), DNS root zone trailing dots, legacy server list ping format, and VarInt encoding. Server address matching in routes strips these suffixes before lookup.
|
||||
The `mcproto` package handles Minecraft Java protocol quirks: Forge mod identifiers appended to server addresses (separated by `\x00`), DNS root zone trailing dots, legacy server list ping format, and VarInt encoding. Server address matching in routes also strips TCP Shield patterns (`///...`) and lowercases before lookup.
|
||||
|
||||
Auto-scale MOTD fallback: waking servers display `LoadingMOTD`; sleeping servers display `AsleepMOTD`. Per-route MOTDs take precedence over global ones.
|
||||
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25 AS builder
|
||||
FROM golang:1.26.2 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -13,9 +13,11 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
||||
FROM alpine AS certs
|
||||
RUN apk add -U \
|
||||
ca-certificates
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
FROM scratch
|
||||
ENTRYPOINT ["/mc-router"]
|
||||
COPY --from=certs /etc/ssl/certs/ /etc/ssl/certs
|
||||
COPY --from=certs /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=builder /build/mc-router /mc-router
|
||||
|
||||
+3
-1
@@ -1,11 +1,13 @@
|
||||
FROM alpine AS certs
|
||||
RUN apk add -U \
|
||||
ca-certificates
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
FROM scratch
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=certs /etc/ssl/certs/ /etc/ssl/certs
|
||||
COPY --from=certs /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY $TARGETPLATFORM/mc-router /
|
||||
ENTRYPOINT ["/mc-router"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -50,8 +50,6 @@ Some other features included:
|
||||
host:port of a default Minecraft server to use when mapping not found (env DEFAULT)
|
||||
-docker-api-version string
|
||||
Instead of auto-negotiating, use specific Docker API version (env DOCKER_API_VERSION)
|
||||
-docker-refresh-interval int
|
||||
Refresh interval in seconds for the Docker integrations (env DOCKER_REFRESH_INTERVAL) (default 15)
|
||||
-docker-socket string
|
||||
Path to Docker socket to use (env DOCKER_SOCKET) (default "unix:///var/run/docker.sock")
|
||||
-docker-timeout int
|
||||
@@ -121,6 +119,15 @@ Some other features included:
|
||||
|
||||
The [multi-architecture image published at Docker Hub](https://hub.docker.com/repository/docker/itzg/mc-router) supports amd64, arm64, and arm32v6 (i.e. RaspberryPi).
|
||||
|
||||
## Timezone
|
||||
|
||||
The image bundles `tzdata` so log timestamps can match your local timezone. Pick one:
|
||||
|
||||
- Set the `TZ` environment variable, e.g. `TZ=America/New_York`
|
||||
- Bind mount the host's zone file: `-v /etc/localtime:/etc/localtime:ro`
|
||||
|
||||
If neither is set, timestamps are in UTC.
|
||||
|
||||
## Docker Compose Usage
|
||||
|
||||
The diagram below shows how this `docker-compose.yml` configures two Minecraft server services named `vanilla` and `forge`, which also become the internal network aliases. _Notice those services don't need their ports exposed since the internal networking allows for the inter-container access._
|
||||
@@ -162,7 +169,7 @@ To test out this example, add these two entries to my "hosts" file:
|
||||
|
||||
### Using Docker auto-discovery
|
||||
|
||||
When running `mc-router` in a Docker environment you can pass the `--in-docker` or `--in-docker-swarm` command-line argument or set the environment variables `IN_DOCKER` or `IN_DOCKER_SWARM` to "true". With that, it will poll the Docker API periodically to find all the running containers/services for Minecraft instances. To enable discovery, you have to set the `mc-router.host` label on the container.
|
||||
When running `mc-router` in a Docker environment you can pass the `--in-docker` or `--in-docker-swarm` command-line argument or set the environment variables `IN_DOCKER` or `IN_DOCKER_SWARM` to "true". With that, it will subscribe to the Docker event stream to react to container/service lifecycle changes (start, stop, pause, unpause, rename, network connect/disconnect for containers; create, update, remove for swarm services) and update routes immediately. An initial listing is performed on startup, and the stream is reconnected with exponential backoff on errors (e.g. daemon restart). To enable discovery, you have to set the `mc-router.host` label on the container.
|
||||
|
||||
When using in Docker, make sure to volume mount the Docker socket into the container, such as
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -1,13 +1,14 @@
|
||||
module github.com/itzg/mc-router
|
||||
|
||||
go 1.25.8
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.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.11.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
@@ -15,10 +16,10 @@ require (
|
||||
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.34.0
|
||||
k8s.io/api v0.33.4
|
||||
k8s.io/apimachinery v0.33.4
|
||||
k8s.io/client-go v0.33.4
|
||||
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
|
||||
@@ -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
|
||||
@@ -37,14 +41,14 @@ require (
|
||||
github.com/distribution/reference v0.6.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.8.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/google/gnostic-models v0.6.9 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect
|
||||
github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
@@ -67,15 +71,16 @@ require (
|
||||
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.2 // 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.19.0 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // 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/v4 v4.7.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -87,29 +92,28 @@ 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/gogo/protobuf v1.3.2 // 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
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
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.43.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/term v0.34.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-20250502105355-0f33e8f1c979 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
@@ -33,10 +35,10 @@ github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf
|
||||
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
|
||||
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
|
||||
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
|
||||
@@ -44,8 +46,8 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
@@ -58,19 +60,16 @@ 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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
|
||||
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
|
||||
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=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
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=
|
||||
@@ -89,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=
|
||||
@@ -97,8 +104,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -124,16 +129,17 @@ github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6U
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
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=
|
||||
@@ -154,8 +160,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
@@ -165,11 +171,11 @@ 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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
@@ -195,8 +201,10 @@ 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=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY=
|
||||
golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM=
|
||||
golang.ngrok.com/ngrok v1.12.1 h1:fjPyPr/R5/Et02x52iIJD2XqukwYeafsHNvM1ndJDAI=
|
||||
@@ -204,48 +212,45 @@ 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
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-20200226121028-0de0cce0169b/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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
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-20190911185100-cd5d95a43a6e/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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
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=
|
||||
@@ -261,35 +266,35 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn
|
||||
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=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
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.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk=
|
||||
k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc=
|
||||
k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s=
|
||||
k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
|
||||
k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw=
|
||||
k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY=
|
||||
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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
|
||||
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=
|
||||
k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
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/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ type Config struct {
|
||||
InDockerSwarm bool `usage:"Use Docker Swarm service discovery"`
|
||||
DockerSocket string `usage:"Path to Docker socket to use"`
|
||||
DockerTimeout time.Duration `usage:"Timeout (as duration) for the Docker integrations"`
|
||||
DockerRefreshInterval time.Duration `default:"15s" usage:"Refresh interval (as duration) for the Docker integrations"`
|
||||
DockerRefreshInterval time.Duration `usage:"Deprecated and ignored: Docker discovery is now event-driven"`
|
||||
DockerApiVersion string `usage:"Instead of auto-negotiating, use specific Docker API version"`
|
||||
MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb,prometheus"`
|
||||
MetricsBackendConfig MetricsBackendConfig
|
||||
|
||||
+8
-2
@@ -659,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 {
|
||||
|
||||
+265
-96
@@ -9,7 +9,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -30,12 +33,11 @@ const (
|
||||
)
|
||||
|
||||
type dockerWatcherConfig struct {
|
||||
autoScaleUp bool
|
||||
autoScaleDown bool
|
||||
socket string
|
||||
timeout time.Duration
|
||||
refreshInterval time.Duration
|
||||
apiVersion string
|
||||
autoScaleUp bool
|
||||
autoScaleDown bool
|
||||
socket string
|
||||
timeout time.Duration
|
||||
apiVersion string
|
||||
}
|
||||
|
||||
func (c *dockerWatcherConfig) apiVersionOpt() client.Opt {
|
||||
@@ -48,15 +50,14 @@ func (c *dockerWatcherConfig) apiVersionOpt() client.Opt {
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerWatcher(socket string, timeout time.Duration, refreshInterval time.Duration, autoScaleUp bool, autoScaleDown bool, dockerApiVersion string) IDockerWatcher {
|
||||
func NewDockerWatcher(socket string, timeout time.Duration, autoScaleUp bool, autoScaleDown bool, dockerApiVersion string) IDockerWatcher {
|
||||
return &dockerWatcherImpl{
|
||||
config: dockerWatcherConfig{
|
||||
socket: socket,
|
||||
timeout: timeout,
|
||||
refreshInterval: refreshInterval,
|
||||
autoScaleUp: autoScaleUp,
|
||||
autoScaleDown: autoScaleDown,
|
||||
apiVersion: dockerApiVersion,
|
||||
socket: socket,
|
||||
timeout: timeout,
|
||||
autoScaleUp: autoScaleUp,
|
||||
autoScaleDown: autoScaleDown,
|
||||
apiVersion: dockerApiVersion,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -111,12 +112,7 @@ func (w *dockerWatcherImpl) makeWakerFunc(rc *routableContainer) WakerFunc {
|
||||
}
|
||||
endpoint := net.JoinHostPort(data.ip, strconv.Itoa(int(data.port)))
|
||||
|
||||
// Update the route mappings
|
||||
err = w.monitorContainers(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Docker monitoring failed")
|
||||
return "", err
|
||||
}
|
||||
// Route table updates via Docker `start`/`network connect` events.
|
||||
|
||||
// Wait until the container is reachable
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
@@ -164,15 +160,15 @@ func (w *dockerWatcherImpl) makeSleeperFunc(rc *routableContainer) SleeperFunc {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = w.monitorContainers(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Docker monitoring failed")
|
||||
return err
|
||||
}
|
||||
// Route table updates via Docker `die`/`stop`/`network disconnect` events.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// monitorContainers does a full re-list of Docker containers and reconciles
|
||||
// the route table against it. Used for initial sync at startup and for
|
||||
// resync after the event stream reconnects (to catch any events missed
|
||||
// during disconnect).
|
||||
func (w *dockerWatcherImpl) monitorContainers(ctx context.Context) error {
|
||||
w.monitorLock.Lock()
|
||||
defer w.monitorLock.Unlock()
|
||||
@@ -184,51 +180,178 @@ func (w *dockerWatcherImpl) monitorContainers(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
visited := map[string]struct{}{}
|
||||
for _, rs := range containers {
|
||||
if oldRs, ok := w.containerMap[rs.externalContainerName]; !ok {
|
||||
w.containerMap[rs.externalContainerName] = rs
|
||||
logrus.WithField("routableContainer", rs).Debug("ADD")
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalContainerName != "" {
|
||||
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
}
|
||||
} else if oldRs.containerEndpoint != rs.containerEndpoint ||
|
||||
oldRs.containerID != rs.containerID ||
|
||||
oldRs.autoScaleUp != rs.autoScaleUp ||
|
||||
oldRs.autoScaleDown != rs.autoScaleDown ||
|
||||
oldRs.autoScaleAsleepMOTD != rs.autoScaleAsleepMOTD ||
|
||||
oldRs.autoScaleLoadingMOTD != rs.autoScaleLoadingMOTD {
|
||||
w.containerMap[rs.externalContainerName] = rs
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalContainerName != "" {
|
||||
Routes.DeleteMapping(rs.externalContainerName)
|
||||
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||
}
|
||||
visited[rs.externalContainerName] = struct{}{}
|
||||
byID := map[string][]*routableContainer{}
|
||||
for _, rc := range containers {
|
||||
byID[rc.containerID] = append(byID[rc.containerID], rc)
|
||||
}
|
||||
for _, rs := range w.containerMap {
|
||||
if _, ok := visited[rs.externalContainerName]; !ok {
|
||||
delete(w.containerMap, rs.externalContainerName)
|
||||
if rs.externalContainerName != "" {
|
||||
Routes.DeleteMapping(rs.externalContainerName)
|
||||
} else {
|
||||
Routes.SetDefaultRoute("", "", nil, nil, "", "")
|
||||
}
|
||||
logrus.WithField("routableContainer", rs).Debug("DELETE")
|
||||
|
||||
for id, desired := range byID {
|
||||
w.applyContainerRoutesLocked(id, desired)
|
||||
}
|
||||
|
||||
// Remove entries whose container is no longer present at all
|
||||
for name, rc := range w.containerMap {
|
||||
if _, present := byID[rc.containerID]; present {
|
||||
continue
|
||||
}
|
||||
delete(w.containerMap, name)
|
||||
if name != "" {
|
||||
Routes.DeleteMapping(name)
|
||||
} else {
|
||||
Routes.SetDefaultRoute("", "", nil, nil, "", "")
|
||||
}
|
||||
logrus.WithField("routableContainer", rc).Debug("DELETE")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEvent reacts to a single Docker event by reconciling only the routes
|
||||
// belonging to the affected container — no full re-list.
|
||||
func (w *dockerWatcherImpl) applyEvent(ctx context.Context, ev events.Message) error {
|
||||
containerID := ev.Actor.ID
|
||||
if ev.Type == events.NetworkEventType {
|
||||
containerID = ev.Actor.Attributes["container"]
|
||||
}
|
||||
if containerID == "" {
|
||||
logrus.WithField("event", ev).Warn("network event missing container attribute, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
var desired []*routableContainer
|
||||
if !(ev.Type == events.ContainerEventType && ev.Action == events.ActionDestroy) {
|
||||
got, err := w.containersForID(ctx, containerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desired = got
|
||||
}
|
||||
|
||||
w.monitorLock.Lock()
|
||||
defer w.monitorLock.Unlock()
|
||||
|
||||
// Only trace events that affect a routed container — either one we already
|
||||
// track or one becoming routable now. Filters out unrelated daemon noise.
|
||||
relevant := len(desired) > 0
|
||||
if !relevant {
|
||||
for _, rc := range w.containerMap {
|
||||
if rc.containerID == containerID {
|
||||
relevant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if relevant {
|
||||
logrus.WithFields(logrus.Fields{"type": ev.Type, "action": ev.Action, "id": containerID}).Trace("Docker event")
|
||||
}
|
||||
|
||||
w.applyContainerRoutesLocked(containerID, desired)
|
||||
return nil
|
||||
}
|
||||
|
||||
// containersForID inspects a single container and returns the routableContainers
|
||||
// it should produce. Returns nil if the container is gone or not routable.
|
||||
func (w *dockerWatcherImpl) containersForID(ctx context.Context, containerID string) ([]*routableContainer, error) {
|
||||
inspect, err := w.client.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
data, ok := w.parseContainerData(&inspect)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := ""
|
||||
if !data.notRunning {
|
||||
endpoint = fmt.Sprintf("%s:%d", data.ip, data.port)
|
||||
}
|
||||
var result []*routableContainer
|
||||
for _, host := range data.hosts {
|
||||
result = append(result, &routableContainer{
|
||||
containerEndpoint: endpoint,
|
||||
externalContainerName: host,
|
||||
containerID: containerID,
|
||||
autoScaleUp: data.autoScaleUp,
|
||||
autoScaleDown: data.autoScaleDown,
|
||||
autoScaleAsleepMOTD: data.autoScaleAsleepMOTD,
|
||||
autoScaleLoadingMOTD: data.autoScaleLoadingMOTD,
|
||||
})
|
||||
}
|
||||
if data.def != nil && *data.def {
|
||||
result = append(result, &routableContainer{
|
||||
containerEndpoint: endpoint,
|
||||
externalContainerName: "",
|
||||
containerID: containerID,
|
||||
autoScaleUp: data.autoScaleUp,
|
||||
autoScaleDown: data.autoScaleDown,
|
||||
autoScaleAsleepMOTD: data.autoScaleAsleepMOTD,
|
||||
autoScaleLoadingMOTD: data.autoScaleLoadingMOTD,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyContainerRoutesLocked reconciles the routes for a single containerID
|
||||
// against the desired set. Caller must hold monitorLock.
|
||||
func (w *dockerWatcherImpl) applyContainerRoutesLocked(containerID string, desired []*routableContainer) {
|
||||
desiredByName := map[string]*routableContainer{}
|
||||
for _, rc := range desired {
|
||||
desiredByName[rc.externalContainerName] = rc
|
||||
}
|
||||
|
||||
// Drop entries previously owned by this container that are no longer desired
|
||||
for name, rc := range w.containerMap {
|
||||
if rc.containerID != containerID {
|
||||
continue
|
||||
}
|
||||
if _, keep := desiredByName[name]; keep {
|
||||
continue
|
||||
}
|
||||
delete(w.containerMap, name)
|
||||
if name != "" {
|
||||
Routes.DeleteMapping(name)
|
||||
} else {
|
||||
Routes.SetDefaultRoute("", "", nil, nil, "", "")
|
||||
}
|
||||
logrus.WithField("routableContainer", rc).Debug("DELETE")
|
||||
}
|
||||
|
||||
for _, rs := range desired {
|
||||
oldRs, exists := w.containerMap[rs.externalContainerName]
|
||||
if !exists {
|
||||
w.containerMap[rs.externalContainerName] = rs
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalContainerName != "" {
|
||||
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
}
|
||||
logrus.WithField("routableContainer", rs).Debug("ADD")
|
||||
continue
|
||||
}
|
||||
if oldRs.containerEndpoint == rs.containerEndpoint &&
|
||||
oldRs.containerID == rs.containerID &&
|
||||
oldRs.autoScaleUp == rs.autoScaleUp &&
|
||||
oldRs.autoScaleDown == rs.autoScaleDown &&
|
||||
oldRs.autoScaleAsleepMOTD == rs.autoScaleAsleepMOTD &&
|
||||
oldRs.autoScaleLoadingMOTD == rs.autoScaleLoadingMOTD {
|
||||
continue
|
||||
}
|
||||
w.containerMap[rs.externalContainerName] = rs
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalContainerName != "" {
|
||||
Routes.DeleteMapping(rs.externalContainerName)
|
||||
Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, rs.autoScaleAsleepMOTD, rs.autoScaleLoadingMOTD)
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dockerWatcherImpl) Start(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
@@ -248,49 +371,95 @@ func (w *dockerWatcherImpl) Start(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: replace all this with events listening
|
||||
ticker := time.NewTicker(w.config.refreshInterval)
|
||||
w.containerMap = map[string]*routableContainer{}
|
||||
|
||||
logrus.Trace("Performing initial listing of Docker containers")
|
||||
initialContainers, err := w.listContainers(ctx)
|
||||
if err != nil {
|
||||
if err := w.monitorContainers(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.containerMap = map[string]*routableContainer{}
|
||||
for _, c := range initialContainers {
|
||||
w.containerMap[c.externalContainerName] = c
|
||||
wakerFunc := w.makeWakerFunc(c)
|
||||
sleeperFunc := w.makeSleeperFunc(c)
|
||||
if c.externalContainerName != "" {
|
||||
Routes.CreateMapping(c.externalContainerName, c.containerEndpoint, "", wakerFunc, sleeperFunc, c.autoScaleAsleepMOTD, c.autoScaleLoadingMOTD)
|
||||
} else {
|
||||
Routes.SetDefaultRoute(c.containerEndpoint, "", wakerFunc, sleeperFunc, c.autoScaleAsleepMOTD, c.autoScaleLoadingMOTD)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
err := w.monitorContainers(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Docker monitoring failed")
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
logrus.Debug("Stopping Docker monitoring")
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// streamEvents will resync on (re)connect and otherwise apply incremental
|
||||
// updates from the Docker event stream — no periodic polling.
|
||||
go w.streamEvents(ctx)
|
||||
|
||||
logrus.Info("Monitoring Docker for Minecraft containers")
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamEvents subscribes to the Docker event stream and triggers reconciliation
|
||||
// of routes whenever container or network events relevant to routing occur.
|
||||
// Reconnects with backoff on stream errors (e.g. daemon restart).
|
||||
func (w *dockerWatcherImpl) streamEvents(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
logrus.Debug("Stopping Docker monitoring")
|
||||
return
|
||||
}
|
||||
|
||||
eventFilters := filters.NewArgs(
|
||||
filters.Arg("type", string(events.ContainerEventType)),
|
||||
filters.Arg("type", string(events.NetworkEventType)),
|
||||
filters.Arg("event", string(events.ActionStart)),
|
||||
filters.Arg("event", string(events.ActionUnPause)),
|
||||
filters.Arg("event", string(events.ActionStop)),
|
||||
filters.Arg("event", string(events.ActionDie)),
|
||||
filters.Arg("event", string(events.ActionPause)),
|
||||
filters.Arg("event", string(events.ActionDestroy)),
|
||||
filters.Arg("event", string(events.ActionRename)),
|
||||
filters.Arg("event", string(events.ActionConnect)),
|
||||
filters.Arg("event", string(events.ActionDisconnect)),
|
||||
)
|
||||
|
||||
eventCh, errCh := w.client.Events(ctx, events.ListOptions{Filters: eventFilters})
|
||||
|
||||
// Resync after (re)connecting in case we missed events while disconnected
|
||||
if err := w.monitorContainers(ctx); err != nil {
|
||||
logrus.WithError(err).Error("Docker resync failed")
|
||||
} else {
|
||||
backoff = time.Second
|
||||
}
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-eventCh:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
if err := w.applyEvent(ctx, ev); err != nil {
|
||||
logrus.WithError(err).Error("Docker event handling failed")
|
||||
}
|
||||
case err, ok := <-errCh:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logrus.WithError(err).Warn("Docker event stream error, reconnecting")
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dockerWatcherImpl) listContainers(ctx context.Context) ([]*routableContainer, error) {
|
||||
containers, err := w.client.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
|
||||
+129
-79
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
dockertypes "github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
@@ -19,23 +20,24 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewDockerSwarmWatcher(socket string, timeout time.Duration, refreshInterval time.Duration, autoScaleUp bool, autoScaleDown bool, dockerApiVersion string) IDockerWatcher {
|
||||
func NewDockerSwarmWatcher(socket string, timeout time.Duration, autoScaleUp bool, autoScaleDown bool, dockerApiVersion string) IDockerWatcher {
|
||||
return &dockerSwarmWatcherImpl{
|
||||
config: dockerWatcherConfig{
|
||||
socket: socket,
|
||||
timeout: timeout,
|
||||
refreshInterval: refreshInterval,
|
||||
autoScaleUp: autoScaleUp,
|
||||
autoScaleDown: autoScaleDown,
|
||||
apiVersion: dockerApiVersion,
|
||||
socket: socket,
|
||||
timeout: timeout,
|
||||
autoScaleUp: autoScaleUp,
|
||||
autoScaleDown: autoScaleDown,
|
||||
apiVersion: dockerApiVersion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type dockerSwarmWatcherImpl struct {
|
||||
sync.RWMutex
|
||||
config dockerWatcherConfig
|
||||
client *client.Client
|
||||
config dockerWatcherConfig
|
||||
client *client.Client
|
||||
serviceMap map[string]*routableService
|
||||
monitorLock sync.Mutex
|
||||
}
|
||||
|
||||
func (w *dockerSwarmWatcherImpl) makeWakerFunc(_ *routableService) WakerFunc {
|
||||
@@ -75,85 +77,133 @@ func (w *dockerSwarmWatcherImpl) Start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(w.config.refreshInterval)
|
||||
serviceMap := map[string]*routableService{}
|
||||
w.serviceMap = map[string]*routableService{}
|
||||
|
||||
logrus.Trace("Performing initial listing of Docker containers")
|
||||
initialServices, err := w.listServices(ctx)
|
||||
if err != nil {
|
||||
logrus.Trace("Performing initial listing of Docker swarm services")
|
||||
if err := w.reconcileServices(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range initialServices {
|
||||
serviceMap[s.externalServiceName] = s
|
||||
wakerFunc := w.makeWakerFunc(s)
|
||||
sleeperFunc := w.makeSleeperFunc(s)
|
||||
if s.externalServiceName != "" {
|
||||
Routes.CreateMapping(s.externalServiceName, s.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
} else {
|
||||
Routes.SetDefaultRoute(s.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
services, err := w.listServices(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Docker failed to list services")
|
||||
continue
|
||||
}
|
||||
|
||||
visited := map[string]struct{}{}
|
||||
for _, rs := range services {
|
||||
if oldRs, ok := serviceMap[rs.externalServiceName]; !ok {
|
||||
serviceMap[rs.externalServiceName] = rs
|
||||
logrus.WithField("routableService", rs).Debug("ADD")
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
}
|
||||
} else if oldRs.containerEndpoint != rs.containerEndpoint {
|
||||
serviceMap[rs.externalServiceName] = rs
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.DeleteMapping(rs.externalServiceName)
|
||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||
}
|
||||
visited[rs.externalServiceName] = struct{}{}
|
||||
}
|
||||
for _, rs := range serviceMap {
|
||||
if _, ok := visited[rs.externalServiceName]; !ok {
|
||||
delete(serviceMap, rs.externalServiceName)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.DeleteMapping(rs.externalServiceName)
|
||||
} else {
|
||||
Routes.SetDefaultRoute("", "", nil, nil, "", "")
|
||||
}
|
||||
logrus.WithField("routableService", rs).Debug("DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go w.streamEvents(ctx)
|
||||
|
||||
logrus.Info("Monitoring Docker Swarm for Minecraft services")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dockerSwarmWatcherImpl) reconcileServices(ctx context.Context) error {
|
||||
w.monitorLock.Lock()
|
||||
defer w.monitorLock.Unlock()
|
||||
|
||||
services, err := w.listServices(ctx)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Docker failed to list services")
|
||||
return err
|
||||
}
|
||||
|
||||
visited := map[string]struct{}{}
|
||||
for _, rs := range services {
|
||||
if oldRs, ok := w.serviceMap[rs.externalServiceName]; !ok {
|
||||
w.serviceMap[rs.externalServiceName] = rs
|
||||
logrus.WithField("routableService", rs).Debug("ADD")
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
}
|
||||
} else if oldRs.containerEndpoint != rs.containerEndpoint {
|
||||
w.serviceMap[rs.externalServiceName] = rs
|
||||
wakerFunc := w.makeWakerFunc(rs)
|
||||
sleeperFunc := w.makeSleeperFunc(rs)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.DeleteMapping(rs.externalServiceName)
|
||||
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
} else {
|
||||
Routes.SetDefaultRoute(rs.containerEndpoint, "", wakerFunc, sleeperFunc, "", "")
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
|
||||
}
|
||||
visited[rs.externalServiceName] = struct{}{}
|
||||
}
|
||||
for _, rs := range w.serviceMap {
|
||||
if _, ok := visited[rs.externalServiceName]; !ok {
|
||||
delete(w.serviceMap, rs.externalServiceName)
|
||||
if rs.externalServiceName != "" {
|
||||
Routes.DeleteMapping(rs.externalServiceName)
|
||||
} else {
|
||||
Routes.SetDefaultRoute("", "", nil, nil, "", "")
|
||||
}
|
||||
logrus.WithField("routableService", rs).Debug("DELETE")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *dockerSwarmWatcherImpl) streamEvents(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
logrus.Debug("Stopping Docker Swarm monitoring")
|
||||
return
|
||||
}
|
||||
|
||||
eventFilters := filters.NewArgs(
|
||||
filters.Arg("type", string(events.ServiceEventType)),
|
||||
filters.Arg("event", string(events.ActionCreate)),
|
||||
filters.Arg("event", string(events.ActionUpdate)),
|
||||
filters.Arg("event", string(events.ActionRemove)),
|
||||
)
|
||||
|
||||
eventCh, errCh := w.client.Events(ctx, events.ListOptions{Filters: eventFilters})
|
||||
|
||||
if err := w.reconcileServices(ctx); err != nil {
|
||||
logrus.WithError(err).Error("Docker Swarm resync failed")
|
||||
} else {
|
||||
backoff = time.Second
|
||||
}
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-eventCh:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"type": ev.Type, "action": ev.Action, "id": ev.Actor.ID}).Trace("Docker Swarm event")
|
||||
if err := w.reconcileServices(ctx); err != nil {
|
||||
logrus.WithError(err).Error("Docker Swarm reconciliation failed")
|
||||
}
|
||||
case err, ok := <-errCh:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logrus.WithError(err).Warn("Docker Swarm event stream error, reconnecting")
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *dockerSwarmWatcherImpl) listServices(ctx context.Context) ([]*routableService, error) {
|
||||
services, err := w.client.ServiceList(ctx, dockertypes.ServiceListOptions{})
|
||||
if err != nil {
|
||||
|
||||
+7
-2
@@ -140,9 +140,14 @@ func NewServer(ctx context.Context, config *Config) (*Server, error) {
|
||||
routeWatchers = append(routeWatchers, k8sWatcher)
|
||||
}
|
||||
|
||||
if config.DockerRefreshInterval != 0 {
|
||||
logrus.WithField("value", config.DockerRefreshInterval).
|
||||
Warn("--docker-refresh-interval is deprecated and ignored; Docker discovery is now event-driven")
|
||||
}
|
||||
|
||||
// TODO convert to RouteFinder
|
||||
if config.InDocker {
|
||||
watcher := NewDockerWatcher(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval, config.AutoScale.Up, config.AutoScale.Down, config.DockerApiVersion)
|
||||
watcher := NewDockerWatcher(config.DockerSocket, config.DockerTimeout, config.AutoScale.Up, config.AutoScale.Down, config.DockerApiVersion)
|
||||
err = watcher.Start(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not start docker integration: %w", err)
|
||||
@@ -151,7 +156,7 @@ func NewServer(ctx context.Context, config *Config) (*Server, error) {
|
||||
|
||||
// TODO convert to RouteFinder
|
||||
if config.InDockerSwarm {
|
||||
watcher := NewDockerSwarmWatcher(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval, config.AutoScale.Up, config.AutoScale.Down, config.DockerApiVersion)
|
||||
watcher := NewDockerSwarmWatcher(config.DockerSocket, config.DockerTimeout, config.AutoScale.Up, config.AutoScale.Down, config.DockerApiVersion)
|
||||
err = watcher.Start(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not start docker swarm integration: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user